perf: reduce startup work and fix grade analytics bar chart animation

Batch settings storage writes, tier plugin startup, lazy-load heavy UI
chunks, and optimize global search indexing. Stop tweening bar height in
grade analytics to prevent invalid negative SVG rect values.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 10:50:26 +09:30
parent 62ed702e64
commit d10fca6c0f
41 changed files with 919 additions and 537 deletions
@@ -131,16 +131,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
if (requestId !== loadRequestId) return;
renderSkeletonLoader(container);
void renderSkeletonLoader(container);
try {
const data = await getAssessmentsData();
if (requestId !== loadRequestId) return;
renderGrid(container, data);
void renderGrid(container, data);
} catch (err) {
if (requestId !== loadRequestId) return;
console.error("Failed to load assessments:", err);
renderErrorState(
void renderErrorState(
container,
err instanceof Error ? err.message : "Unknown error",
);
+28 -16
View File
@@ -1,8 +1,4 @@
import renderSvelte from "@/interface/main";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
@@ -119,26 +115,42 @@ function prepareContainer(container: HTMLElement) {
watchOverviewTheme(container);
}
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(AssessmentsOverview, container, { data });
async function mountOverviewComponent(
container: HTMLElement,
loader: () => Promise<{ default: any }>,
props: Record<string, unknown> = {},
) {
const [{ default: renderSvelte }, { default: Component }] = await Promise.all([
import("@/interface/main"),
loader(),
]);
currentApp = renderSvelte(Component, container, props);
}
export function renderSkeletonLoader(container: HTMLElement) {
export async function renderGrid(container: HTMLElement, data: any) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(SkeletonLoader, container);
await mountOverviewComponent(container, () => import("./AssessmentsOverview.svelte"), {
data,
});
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
export async function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(ErrorState, container, { error });
await mountOverviewComponent(container, () => import("./SkeletonLoader.svelte"));
}
export async function renderLoadingState(container: HTMLElement) {
await renderSkeletonLoader(container);
}
export async function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
await mountOverviewComponent(container, () => import("./ErrorState.svelte"), {
error,
});
}
export function teardownOverviewUi() {
@@ -5,7 +5,7 @@
import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
@@ -129,7 +129,31 @@
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => {
const itemsUpdatedHandler = (event: Event) => {
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
if (
detail?.vectorUpdate &&
!detail.changedItems?.length &&
!detail.removedIds?.length
) {
performSearch();
return;
}
if (detail?.incremental && !detail.fullRebuild) {
const updatedFuse = applyDynamicIndexDelta(
dynamicContentFuse,
dynamicIdToItemMap,
detail,
);
if (updatedFuse) {
dynamicContentFuse = updatedFuse;
performSearch();
return;
}
}
setupSearchIndexes();
performSearch();
};
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const title = document.querySelector("#title");
if (title) {
mountSearchBar(title, api, appRef);
void mountSearchBar(title, api, appRef);
} else {
const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(titleElement, api, appRef);
void mountSearchBar(titleElement, api, appRef);
}
return () => {
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export function mountSearchBar(
export async function mountSearchBar(
titleElement: Element,
api: any,
appRef: {
@@ -305,6 +304,7 @@ export function mountSearchBar(
});
try {
const { default: renderSvelte } = await import("@/interface/main");
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst,
@@ -184,6 +184,56 @@ export async function put(
}
}
/**
* Apply puts and deletes in a single readwrite transaction.
*/
export async function applyStoreDiff(
store: string,
puts: Array<{ key: string; value: any }>,
removeKeys: string[],
): Promise<void> {
if (puts.length === 0 && removeKeys.length === 0) return;
try {
const db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
const upgradedDb = await openDB();
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
return;
}
await runStoreDiffTransaction(db, store, puts, removeKeys);
} catch (error) {
console.error(`Error in applyStoreDiff for store ${store}:`, error);
throw error;
}
}
function runStoreDiffTransaction(
db: IDBDatabase,
store: string,
puts: Array<{ key: string; value: any }>,
removeKeys: string[],
): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, "readwrite");
const objectStore = tx.objectStore(store);
for (const key of removeKeys) {
objectStore.delete(key);
}
for (const { key, value } of puts) {
objectStore.put(value, key);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function remove(store: string, key: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
@@ -1,6 +1,6 @@
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import { decorateIndexItems } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
@@ -89,12 +89,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
}
function getLastRunMeta(jobId: string): Promise<number | undefined> {
return getAll(META_STORE).then((metaItems) => {
const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
return get(META_STORE, jobId).then((rec) => rec?.lastRun);
}
function indexItemStorageKey(item: IndexItem): string {
return JSON.stringify({
id: item.id,
text: item.text,
category: item.category,
content: item.content,
dateAdded: item.dateAdded,
metadata: item.metadata,
actionId: item.actionId,
renderComponentId: item.renderComponentId,
});
}
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
return indexItemStorageKey(a) === indexItemStorageKey(b);
}
async function diffAndStoreItems(
targetStore: string,
items: IndexItem[],
): Promise<void> {
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
);
}
const existing = (await getAll(targetStore)) as IndexItem[];
const existingMap = new Map(
existing.filter((i) => i?.id).map((i) => [i.id, i]),
);
const newMap = new Map(validItems.map((i) => [i.id, i]));
const puts: Array<{ key: string; value: IndexItem }> = [];
const removeKeys: string[] = [];
for (const [id, item] of newMap) {
const prev = existingMap.get(id);
if (!prev || !indexItemsEqual(prev, item)) {
puts.push({ key: id, value: item });
}
}
for (const id of existingMap.keys()) {
if (!newMap.has(id)) {
removeKeys.push(id);
}
}
if (puts.length > 0 || removeKeys.length > 0) {
await applyStoreDiff(targetStore, puts, removeKeys);
}
}
async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
@@ -255,14 +307,7 @@ export async function runIndexing(): Promise<void> {
await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId;
await clear(targetStore);
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
);
}
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
await diffAndStoreItems(targetStore, items);
};
const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId;
@@ -447,35 +492,13 @@ export async function runIndexing(): Promise<void> {
}
allItemsInPrimaryStores = await loadAllStoredItems();
// Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
try {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
// Use JSON serialization to ensure all nested properties are accessible
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
return item;
}
});
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated"));
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: { fullRebuild: true },
}),
);
} finally {
stopHeartbeat();
}
@@ -1,5 +1,5 @@
import type { IndexItem } from "./types";
import { put, getAll } from "./db";
import { getAll, put } from "./db";
import {
buildIndexItem,
extractTextFromValue,
@@ -7,10 +7,8 @@ import {
pickTitle,
} from "./extract";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { loadAllStoredItems } from "./indexer";
import { loadDynamicItems } from "../utils/dynamicItems";
import { renderComponentMap } from "./renderComponents";
import { jobs } from "./jobs";
import { mergeDynamicItems } from "../utils/dynamicItems";
import { decorateIndexItems } from "./renderComponents";
/**
* Passive network observer.
@@ -41,6 +39,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
let installed = false;
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
let pendingDirty = false;
/** Items persisted since the last flush — only these are pushed to the search layer. */
const pendingChangedItems = new Map<string, IndexItem>();
export function isPassiveObserverInstalled(): boolean {
return installed;
@@ -386,6 +386,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
for (const item of items) {
try {
await put(STORE_ID, item, item.id);
pendingChangedItems.set(item.id, item);
} catch (e) {
console.warn(
`[Passive Observer] Failed to persist item ${item.id}:`,
@@ -409,38 +410,20 @@ function scheduleFlush() {
}
async function flushDynamicItems(): Promise<void> {
if (pendingChangedItems.size === 0) return;
const rawChanged = Array.from(pendingChangedItems.values());
pendingChangedItems.clear();
try {
const all = await loadAllStoredItems();
const decorated = all.map((item) => {
try {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent =
renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch {
return { ...item, renderComponent };
}
} catch {
return item;
}
});
loadDynamicItems(decorated);
const decorated = decorateIndexItems(rawChanged);
mergeDynamicItems(decorated);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId: STORE_ID,
changedItems: decorated,
streaming: false,
},
}),
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte";
import type { IndexItem } from "./types";
import { jobs } from "./jobs";
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent,
@@ -22,3 +24,37 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
goal: GenericItem as unknown as typeof SvelteComponent,
passive: GenericItem as unknown as typeof SvelteComponent,
};
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
if (jobDef) {
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
}
if (renderComponentMap[item.renderComponentId]) {
return renderComponentMap[item.renderComponentId];
}
return item.renderComponent;
}
/**
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
*/
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
return items.map((item) => {
try {
const renderComponent = resolveRenderComponent(item);
try {
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
cloned.renderComponent = renderComponent;
return cloned;
} catch {
return { ...item, renderComponent };
}
} catch {
return item;
}
});
}
@@ -132,6 +132,7 @@ export async function hybridSearch(
bm25Results: CombinedResult[],
query: string,
options: HybridSearchOptions = {},
precomputedVectorResults?: VectorSearchResult[],
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
@@ -146,9 +147,10 @@ export async function hybridSearch(
if (trimmedQuery.length > 2) {
try {
// Get more vector results than BM25 results to ensure coverage
// This allows us to find semantic matches that BM25 might have missed
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
const vectorTopK = opts.bm25TopK * 2;
const vectorSearchResults =
precomputedVectorResults ??
(await searchVectors(trimmedQuery, vectorTopK));
// Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>();
@@ -249,14 +251,26 @@ export async function hybridSearchWithExpansion(
const trimmedQuery = query.trim().toLowerCase();
const liveIndexIds = new Set(allItems.map((item) => item.id));
// First, rerank BM25 results
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
// If query is too short, skip vector expansion
if (trimmedQuery.length <= 2) {
return rerankedBm25;
return hybridSearch(bm25Results, query, options);
}
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
} catch (e) {
console.warn("[Hybrid Search] Vector search failed:", e);
return hybridSearch(bm25Results, query, options);
}
// Rerank BM25 results using the single vector pass above
const rerankedBm25 = await hybridSearch(
bm25Results,
query,
options,
vectorResults,
);
// For short / single-token queries vector expansion brings in too much
// noise (and is the main reason results "flicker" between adjacent
// keystrokes). Keep semantic recall for longer queries.
@@ -264,15 +278,6 @@ export async function hybridSearchWithExpansion(
return rerankedBm25.slice(0, opts.finalLimit);
}
// Get vector search results
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
} catch (e) {
console.warn("[Hybrid Search] Vector search failed:", e);
return rerankedBm25;
}
// Find vector results that weren't in BM25 results
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = [];
@@ -101,6 +101,94 @@ if (typeof window !== 'undefined') {
});
}
/** Rebuild Fuse when incremental delta exceeds this count. */
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
export const DYNAMIC_FUSE_OPTIONS = {
keys: [
{ name: "text", weight: 3 },
{ name: "content", weight: 1 },
{ name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.6 },
{ name: "metadata.subjectCode", weight: 1.6 },
{ name: "metadata.subject", weight: 1.4 },
{ name: "metadata.courseCode", weight: 1.2 },
{ name: "metadata.filename", weight: 1.2 },
{ name: "metadata.author", weight: 0.8 },
{ name: "metadata.authorName", weight: 0.8 },
{ name: "metadata.label", weight: 0.6 },
{ name: "metadata.categoryName", weight: 0.6 },
{ name: "metadata.entityType", weight: 0.4 },
],
includeScore: true,
includeMatches: true,
threshold: 0.5,
minMatchCharLength: 2,
distance: 100,
useExtendedSearch: true,
ignoreLocation: true,
findAllMatches: true,
shouldSort: true,
} as const;
export interface DynamicItemsUpdatedDetail {
incremental?: boolean;
fullRebuild?: boolean;
jobId?: string;
changedItems?: IndexItem[];
removedIds?: string[];
vectorUpdate?: boolean;
streaming?: boolean;
newItemCount?: number;
}
export function createDynamicContentFuse(
items: IndexItem[],
): Fuse<IndexItem> {
return new Fuse(
dedupeIndexItemsForSearch(items),
DYNAMIC_FUSE_OPTIONS,
) as Fuse<IndexItem>;
}
/**
* Apply an incremental dynamic-item delta to an existing Fuse index.
* Returns null when a full rebuild is recommended.
*/
export function applyDynamicIndexDelta(
fuse: Fuse<IndexItem> | undefined,
idToItemMap: Map<string, IndexItem>,
detail: DynamicItemsUpdatedDetail,
): Fuse<IndexItem> | null {
const changedItems = detail.changedItems ?? [];
const removedIds = detail.removedIds ?? [];
const deltaSize = changedItems.length + removedIds.length;
if (
detail.fullRebuild ||
!fuse ||
idToItemMap.size === 0 ||
deltaSize === 0 ||
deltaSize > INCREMENTAL_FUSE_REBUILD_THRESHOLD
) {
return null;
}
for (const id of removedIds) {
fuse.remove((item) => item.id === id);
idToItemMap.delete(id);
}
for (const item of changedItems) {
fuse.remove((existing) => existing.id === item.id);
fuse.add(item);
idToItemMap.set(item.id, item);
}
clearSearchCache();
return fuse;
}
export function createSearchIndexes() {
clearSearchCache();
const commands = getStaticCommands();
@@ -118,49 +206,9 @@ export function createSearchIndexes() {
findAllMatches: false, // Performance optimization
};
// Optimized dynamic content search options.
// The expanded corpus mixes structured entities (assessments, subjects)
// with free-form text (course content, notices, folio bodies, passive
// captures) so we list a broad set of metadata keys while keeping titles
// dominant in the ranking.
// NOTE: metadata.route is intentionally excluded. Raw API paths like
// `/seqta/student/load/message/people` should never influence ranking — they
// historically caused passive-capture support records to bubble up above
// real assessments when the user typed substrings that happened to appear in
// the path.
const dynamicOptions = {
keys: [
{ name: "text", weight: 3 }, // Title is king
{ name: "content", weight: 1 },
{ name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.6 },
{ name: "metadata.subjectCode", weight: 1.6 },
{ name: "metadata.subject", weight: 1.4 },
{ name: "metadata.courseCode", weight: 1.2 },
{ name: "metadata.filename", weight: 1.2 },
{ name: "metadata.author", weight: 0.8 },
{ name: "metadata.authorName", weight: 0.8 },
{ name: "metadata.label", weight: 0.6 },
{ name: "metadata.categoryName", weight: 0.6 },
{ name: "metadata.entityType", weight: 0.4 },
],
includeScore: true,
includeMatches: true,
threshold: 0.5,
minMatchCharLength: 2,
distance: 100,
useExtendedSearch: true,
ignoreLocation: true,
findAllMatches: true,
shouldSort: true,
};
return {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: new Fuse(
dynamicItems,
dynamicOptions,
) as Fuse<IndexItem>,
dynamicContentFuse: createDynamicContentFuse(dynamicItems),
commands,
dynamicItems,
};
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
let dynamicItems: IndexItem[] = [];
/**
* Loads a new set of dynamic items.
* Loads a new set of dynamic items (full replace).
*/
export function loadDynamicItems(items: IndexItem[]) {
dynamicItems = items;
}
/**
* Merge changed items and remove deleted ids without reloading the full corpus.
*/
export function mergeDynamicItems(
changedItems: IndexItem[],
removedIds: string[] = [],
): void {
if (changedItems.length === 0 && removedIds.length === 0) return;
const removeSet = new Set(removedIds);
const changeMap = new Map(changedItems.map((item) => [item.id, item]));
const kept = dynamicItems.filter(
(item) => !removeSet.has(item.id) && !changeMap.has(item.id),
);
dynamicItems = [...kept, ...changedItems];
}
/**
* Returns all currently loaded dynamic items.
*/
@@ -310,8 +310,6 @@
y: { type: "tween", duration: 600, easing: cubicInOut },
height: { type: "tween", duration: 600, easing: cubicInOut },
},
},
+7 -6
View File
@@ -1,7 +1,5 @@
import renderSvelte from "@/interface/main";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null;
@@ -11,10 +9,15 @@ let themeCreatorSvelteApp: any = null;
* @param themeID - The ID of the theme to load in the Theme Creator
* @returns void
*/
export function OpenThemeCreator(themeID: string = "") {
export async function OpenThemeCreator(themeID: string = "") {
CloseThemeCreator();
// Only store original color if we're not editing an existing theme
const [{ default: renderSvelte }, { default: themeCreator }] =
await Promise.all([
import("@/interface/main"),
import("@/interface/pages/themeCreator.svelte"),
]);
localStorage.setItem("themeCreatorOpen", "true");
if (!themeID) {
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
const mainContent = document.querySelector("#container") as HTMLDivElement;
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
// close button
const closeButton = document.createElement("button");
closeButton.classList.add("themeCloseButton");
closeButton.textContent = "×";
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
* @returns void
*/
export function CloseThemeCreator() {
// Remove the stored flag
localStorage.removeItem("themeCreatorOpen");
const themeCreator = document.getElementById("themeCreator");
+25 -33
View File
@@ -12,6 +12,7 @@ import { eventManager } from "@/seqta/utils/listeners/EventManager";
import ReactFiber from "@/seqta/utils/ReactFiber";
import browser from "webextension-polyfill";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import type { SettingsState } from "@/types/storage";
function createSEQTAAPI(): SEQTAAPI {
return {
@@ -149,29 +150,27 @@ function createSettingsAPI<T extends PluginSettings>(
settingsWithMeta.loaded = loaded;
// Listen for storage changes and update settingsWithMeta
const handleStorageChange = (
changes: { [key: string]: browser.Storage.StorageChange },
area: string,
) => {
if (area !== "local" || !(storageKey in changes)) return;
const handleSettingsChange = (newValue: unknown) => {
if (!newValue || typeof newValue !== "object") return;
const newValue = changes[storageKey].newValue as
| Partial<Record<keyof T, any>>
| undefined;
if (!newValue) return;
for (const key in newValue) {
const newSettings = newValue as Partial<Record<keyof T, any>>;
for (const key in newSettings) {
const typedKey = key as keyof T;
settingsWithMeta[typedKey] = newValue[typedKey];
listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey]));
settingsWithMeta[typedKey] = newSettings[typedKey];
listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey]));
}
};
browser.storage.onChanged.addListener(handleStorageChange);
settingsState.register(
storageKey as keyof SettingsState,
handleSettingsChange,
);
const dispose = () => {
browser.storage.onChanged.removeListener(handleStorageChange);
settingsState.unregister(
storageKey as keyof SettingsState,
handleSettingsChange,
);
};
const proxy = new Proxy(settingsWithMeta, {
@@ -241,29 +240,22 @@ function createStorageAPI<T = any>(
}
})();
// Listen for storage changes
const handleStorageChange = (
changes: { [key: string]: any },
area: string,
newValue: unknown,
_oldValue: unknown,
key: string,
) => {
if (area === "local") {
Object.entries(changes).forEach(([key, change]) => {
if (key.startsWith(prefix)) {
const shortKey = key.slice(prefix.length);
cache[shortKey] = change.newValue;
if (!key.startsWith(prefix)) return;
// Notify listeners
listeners
.get(shortKey)
?.forEach((callback) => callback(change.newValue));
}
});
}
const shortKey = key.slice(prefix.length);
cache[shortKey] = newValue;
listeners.get(shortKey)?.forEach((callback) => callback(newValue));
};
browser.storage.onChanged.addListener(handleStorageChange);
settingsState.registerGlobal(handleStorageChange);
const dispose = () => {
browser.storage.onChanged.removeListener(handleStorageChange);
settingsState.unregisterGlobal(handleStorageChange);
};
// Create the proxy for direct property access
+44 -11
View File
@@ -23,6 +23,23 @@ interface StorageChange<T = any> {
newValue?: T;
}
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
const PLUGIN_START_PHASES: readonly string[][] = [
["themes", "animated-background"],
[
"timetable",
"timetableEdit",
"notificationCollector",
"enhanced-navigation",
"assessments-overview",
"assessments-average",
"messageFolders",
"profile-picture",
"background-music",
],
["global-search", "grade-analytics"],
];
/**
* Singleton class responsible for the entire lifecycle of plugins.
* This includes registration, starting, stopping, event dispatching,
@@ -215,25 +232,41 @@ export class PluginManager {
}
}
/**
* Attempts to start all registered plugins.
* Errors during the start of individual plugins are caught and logged,
* allowing other plugins to attempt to start.
*
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
* It uses `Promise.allSettled` to wait for all start operations.
*/
public async startAllPlugins(): Promise<void> {
const startPromises = Array.from(this.plugins.keys()).map((id) =>
private async startPluginPhase(pluginIds: string[]): Promise<void> {
const registeredIds = new Set(this.plugins.keys());
const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
const startPromises = idsToStart.map((id) =>
this.startPlugin(id).catch((error) => {
console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
return Promise.reject(error);
}),
);
await Promise.allSettled(startPromises);
}
/**
* Attempts to start all registered plugins in phased order.
* Errors during the start of individual plugins are caught and logged,
* allowing other plugins to attempt to start.
*
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
*/
public async startAllPlugins(): Promise<void> {
for (const phase of PLUGIN_START_PHASES) {
await this.startPluginPhase(phase);
}
const phasedIds = new Set(PLUGIN_START_PHASES.flat());
const remainingIds = Array.from(this.plugins.keys()).filter(
(id) => !phasedIds.has(id),
);
if (remainingIds.length > 0) {
await this.startPluginPhase(remainingIds);
}
}
/**
* Stops a specific plugin by its ID.
* This involves:
-2
View File
@@ -39,8 +39,6 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
pluginManager.registerPlugin(globalSearchPluginLazy);
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
export { init as Monofile } from "./monofile";
export async function initializePlugins(): Promise<void> {
await pluginManager.startAllPlugins();
}
+34 -19
View File
@@ -26,7 +26,6 @@ import {
updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -337,7 +336,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
break;
case "analytics":
console.info("[BetterSEQTA+] Started Init (Analytics)");
if (settingsState.onoff) void loadAnalyticsPage();
if (settingsState.onoff) {
void import("@/plugins/built-in/gradeAnalytics/loadAnalyticsPage").then(
(m) => m.loadAnalyticsPage(),
);
}
finishLoad();
break;
case undefined:
@@ -488,25 +491,37 @@ async function handleReports(node: Element): Promise<void> {
}
}
function CheckNoticeTextColour(notice: any) {
eventManager.register(
"noticeAdded",
{
elementType: "div",
className: "notice",
parentElement: notice,
},
(node) => {
var hex = (node as HTMLElement).style.cssText.split(" ")[1];
if (hex) {
const hex1 = hex.slice(0, -1);
var threshold = GetThresholdOfColor(hex1);
if (settingsState.DarkMode && threshold < 100) {
(node as HTMLElement).style.cssText = "--color: undefined;";
function CheckNoticeTextColour(notice: Element) {
const adjustNoticeColor = (node: Element) => {
const hex = (node as HTMLElement).style.cssText.split(" ")[1];
if (hex) {
const hex1 = hex.slice(0, -1);
const threshold = GetThresholdOfColor(hex1);
if (settingsState.DarkMode && threshold < 100) {
(node as HTMLElement).style.cssText = "--color: undefined;";
}
}
};
for (const node of notice.querySelectorAll("div.notice")) {
adjustNoticeColor(node);
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const added of mutation.addedNodes) {
if (!(added instanceof Element)) continue;
if (added.matches("div.notice")) {
adjustNoticeColor(added);
}
for (const node of added.querySelectorAll("div.notice")) {
adjustNoticeColor(node);
}
}
},
);
}
});
observer.observe(notice, { childList: true, subtree: true });
}
function watchForEngageLogin() {
+6
View File
@@ -0,0 +1,6 @@
export { init as Monofile } from "./monofile";
export async function initializePlugins(): Promise<void> {
const { pluginManager } = await import("./index");
await pluginManager.startAllPlugins();
}