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
+4 -3
View File
@@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64"; import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import * as plugins from "@/plugins"; import { init as Monofile } from "@/plugins/monofile";
import { main } from "@/seqta/main"; import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay"; import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle"; import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
@@ -104,10 +104,11 @@ async function init() {
} }
await main(); await main();
plugins.Monofile(); Monofile();
if (settingsState.onoff) { if (settingsState.onoff) {
await plugins.initializePlugins(); const { initializePlugins } = await import("@/plugins/runtime");
await initializePlugins();
} }
if (settingsState.devMode) { if (settingsState.devMode) {
+5 -4
View File
@@ -10,6 +10,7 @@ import {
performCloudSettingsUploadWithRetry, performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload, requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll, runCloudSettingsPoll,
withSuppressedCloudAutoUpload,
} from "./background/cloudSettingsAutoSync"; } from "./background/cloudSettingsAutoSync";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
@@ -573,10 +574,10 @@ function getDefaultValues(): SettingsState {
return getDefaultSettingsState(); return getDefaultSettingsState();
} }
function SetStorageValue(object: any) { function SetStorageValue(object: SettingsState) {
for (var i in object) { void withSuppressedCloudAutoUpload(() =>
browser.storage.local.set({ [i]: object[i] }); browser.storage.local.set(object as Record<string, unknown>),
} );
} }
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */ /** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
+11
View File
@@ -457,6 +457,17 @@ function onStorageChanged(
})(); })();
} }
export async function withSuppressedCloudAutoUpload<T>(
operation: () => T | Promise<T>,
): Promise<T> {
suppressAutoUploadDuringRestore = true;
try {
return await operation();
} finally {
suppressAutoUploadDuringRestore = false;
}
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages; reloadSeqtaPagesFn = deps.reloadSeqtaPages;
if (autoSyncInitialized) return; if (autoSyncInitialized) return;
+23 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import ColourPicker from './ColourPicker.tsx'; import type { Component } from 'svelte'
import ReactAdapter from './utils/ReactAdapter.svelte';
import { animate } from 'motion'; import { animate } from 'motion';
import { delay } from '@/seqta/utils/delay.ts' import { delay } from '@/seqta/utils/delay.ts'
@@ -15,6 +14,19 @@
let background = $state<HTMLDivElement | null>(null); let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null); let content = $state<HTMLDivElement | null>(null);
let ReactAdapter = $state<Component | null>(null);
let ColourPickerEl = $state<unknown>(null);
let pickerReady = $state(false);
const loadPicker = async () => {
const [adapterMod, pickerMod] = await Promise.all([
import('./utils/ReactAdapter.svelte'),
import('./ColourPicker.tsx'),
]);
ReactAdapter = adapterMod.default;
ColourPickerEl = pickerMod.default;
pickerReady = true;
};
const closePicker = async () => { const closePicker = async () => {
if (standalone) return; if (standalone) return;
@@ -37,10 +49,11 @@
); );
await delay(400); await delay(400);
hidePicker(); hidePicker?.();
} }
onMount(() => { onMount(() => {
void loadPicker().then(() => {
if (standalone) return; if (standalone) return;
if (!background || !content) return; if (!background || !content) return;
@@ -59,6 +72,7 @@
damping: 30 damping: 30
} }
); );
});
const handleEscapeKey = (e: KeyboardEvent) => { const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -82,7 +96,9 @@
{#if standalone} {#if standalone}
<div class="h-auto overflow-clip rounded-xl"> <div class="h-auto overflow-clip rounded-xl">
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} /> {#if pickerReady && ReactAdapter && ColourPickerEl}
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
{/if}
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -96,7 +112,9 @@
bind:this={content} bind:this={content}
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700" class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
> >
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} /> {#if pickerReady && ReactAdapter && ColourPickerEl}
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
{/if}
</div> </div>
</div> </div>
{/if} {/if}
@@ -59,23 +59,16 @@
</div> </div>
</div> </div>
<div class="overflow-hidden px-4 h-full"> <div class="overflow-hidden px-4 h-full">
<MotionDiv {#each tabs as { Content, props }, index (index)}
class="h-full" {#if activeTab === index}
animate={{ x: `${-activeTab * 100}%` }}
transition={springTransition}
>
<div class="flex">
{#each tabs as { Content, props }, index}
<div <div
role="tabpanel" role="tabpanel"
aria-hidden={activeTab !== index} class="focus:outline-none w-full pt-2 overflow-y-scroll no-scrollbar pb-2 h-full tab active"
class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}" >
style="left: {index * 100}%;"> <div class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} /> <Content {...props} />
</div> </div>
{/if}
{/each} {/each}
</div> </div>
</MotionDiv>
</div>
</div> </div>
+5 -2
View File
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import logo from '@/resources/icons/betterseqta-dark-full.png'; import logo from '@/resources/icons/betterseqta-dark-full.png';
import logoDark from '@/resources/icons/betterseqta-light-full.png'; import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte'; import CloudHeader from './CloudHeader.svelte';
const handleCloseStore = () => {
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
};
// Props // Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{ let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
searchTerm: string, searchTerm: string,
@@ -64,7 +67,7 @@
<!-- Close Button --> <!-- Close Button -->
<button <button
onclick={closeStore} onclick={handleCloseStore}
class="p-1 px-3" class="p-1 px-3"
> >
<span class="text-2xl font-IconFamily">&#xed8a;</span> <span class="text-2xl font-IconFamily">&#xed8a;</span>
@@ -2,8 +2,6 @@
import type { CustomTheme, ThemeList } from '@/types/CustomThemes' import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates' import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup' import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
@@ -127,6 +125,17 @@
} }
} }
const openStorePage = async () => {
const { OpenStorePage } = await import('@/seqta/ui/renderStore')
OpenStorePage()
}
const openThemeCreator = async (themeId?: string) => {
const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator')
OpenThemeCreator(themeId)
closeExtensionPopup()
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => { const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!cloudLoggedIn) { if (!cloudLoggedIn) {
@@ -212,8 +221,8 @@
</div> </div>
<div <div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2" class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }} onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }} onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }}
role="button" role="button"
tabindex="-1" tabindex="-1"
> >
@@ -261,7 +270,7 @@
{/if} {/if}
<button <button
onclick={() => OpenStorePage()} onclick={() => void openStorePage()}
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white" class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
> >
<span class="text-xl font-IconFamily">&#xecc5;</span> <span class="text-xl font-IconFamily">&#xecc5;</span>
@@ -269,7 +278,7 @@
</button> </button>
<button <button
onclick={() => { OpenThemeCreator(); closeExtensionPopup() }} onclick={() => void openThemeCreator()}
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white" class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
> >
<span class="text-xl font-IconFamily">&#xec60;</span> <span class="text-xl font-IconFamily">&#xec60;</span>
+1 -53
View File
@@ -1,53 +1 @@
type SettingsPopupCallback = () => void; export { settingsPopup } from "@/seqta/utils/settingsPopup";
/**
* This is a singleton that triggers an update when the settings popup is closed.
* This is used to close the colour picker.
* Usage:
* settingsPopup.addListener(() => {
* console.log('Settings popup closed');
* });
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
private constructor() {}
public static getInstance(): SettingsPopup {
if (!SettingsPopup.instance) {
SettingsPopup.instance = new SettingsPopup();
}
return SettingsPopup.instance;
}
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
}
export const settingsPopup = SettingsPopup.getInstance();
+10 -5
View File
@@ -14,11 +14,11 @@
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte"; import type { Component } from "svelte";
import FontPickerModal from "../components/FontPickerModal.svelte"; import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte"; import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "@/seqta/utils/settingsPopup";
import { import {
checkGithubReleaseUpdate, checkGithubReleaseUpdate,
dismissNightlyUpdate, dismissNightlyUpdate,
@@ -64,7 +64,12 @@
}, 10000); }, 10000);
}; };
const openColourPicker = () => { let ColourPickerComponent = $state<Component | null>(null);
const openColourPicker = async () => {
if (!ColourPickerComponent) {
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
}
showColourPicker = true; showColourPicker = true;
}; };
@@ -363,8 +368,8 @@
/> />
</div> </div>
{#if showColourPicker} {#if showColourPicker && ColourPickerComponent}
<ColourPicker <ColourPickerComponent
hidePicker={() => { hidePicker={() => {
showColourPicker = false; showColourPicker = false;
}} }}
@@ -131,16 +131,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
if (requestId !== loadRequestId) return; if (requestId !== loadRequestId) return;
renderSkeletonLoader(container); void renderSkeletonLoader(container);
try { try {
const data = await getAssessmentsData(); const data = await getAssessmentsData();
if (requestId !== loadRequestId) return; if (requestId !== loadRequestId) return;
renderGrid(container, data); void renderGrid(container, data);
} catch (err) { } catch (err) {
if (requestId !== loadRequestId) return; if (requestId !== loadRequestId) return;
console.error("Failed to load assessments:", err); console.error("Failed to load assessments:", err);
renderErrorState( void renderErrorState(
container, container,
err instanceof Error ? err.message : "Unknown error", 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 { 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"; import { unmount } from "svelte";
let currentApp: any = null; let currentApp: any = null;
@@ -119,26 +115,42 @@ function prepareContainer(container: HTMLElement) {
watchOverviewTheme(container); watchOverviewTheme(container);
} }
export function renderGrid(container: HTMLElement, data: any) { async function mountOverviewComponent(
if (currentApp) unmount(currentApp); container: HTMLElement,
prepareContainer(container); loader: () => Promise<{ default: any }>,
currentApp = renderSvelte(AssessmentsOverview, container, { data }); 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); if (currentApp) unmount(currentApp);
prepareContainer(container); prepareContainer(container);
currentApp = renderSvelte(SkeletonLoader, container); await mountOverviewComponent(container, () => import("./AssessmentsOverview.svelte"), {
data,
});
} }
export function renderLoadingState(container: HTMLElement) { export async function renderSkeletonLoader(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) unmount(currentApp); if (currentApp) unmount(currentApp);
prepareContainer(container); 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() { export function teardownOverviewUi() {
@@ -5,7 +5,7 @@
import { circOut, quintOut } from 'svelte/easing'; import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands'; import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types'; 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 Fuse from 'fuse.js';
import Calculator from './Calculator.svelte'; import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions'; import { actionMap } from '../indexing/actions';
@@ -129,7 +129,31 @@
window.addEventListener('indexing-progress', progressHandler as EventListener); 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(); setupSearchIndexes();
performSearch(); performSearch();
}; };
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const title = document.querySelector("#title"); const title = document.querySelector("#title");
if (title) { if (title) {
mountSearchBar(title, api, appRef); void mountSearchBar(title, api, appRef);
} else { } else {
const titleElement = await waitForElm("#title", true, 100, 60); const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(titleElement, api, appRef); void mountSearchBar(titleElement, api, appRef);
} }
return () => { return () => {
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte"; import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils"; import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
export function mountSearchBar( export async function mountSearchBar(
titleElement: Element, titleElement: Element,
api: any, api: any,
appRef: { appRef: {
@@ -305,6 +304,7 @@ export function mountSearchBar(
}); });
try { try {
const { default: renderSvelte } = await import("@/interface/main");
appRef.current = renderSvelte(SearchBar, searchRootShadow, { appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false, transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst, 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> { export async function remove(store: string, key: string): Promise<void> {
try { try {
const s = await getStore(store, "readwrite"); 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 { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents"; import { decorateIndexItems } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types"; import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager"; import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems"; import { loadDynamicItems } from "../utils/dynamicItems";
@@ -89,12 +89,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
} }
function getLastRunMeta(jobId: string): Promise<number | undefined> { function getLastRunMeta(jobId: string): Promise<number | undefined> {
return getAll(META_STORE).then((metaItems) => { return get(META_STORE, jobId).then((rec) => rec?.lastRun);
const match = metaItems.find((m: any) => m.jobId === jobId); }
return match?.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> { async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId); await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
} }
@@ -255,14 +307,7 @@ export async function runIndexing(): Promise<void> {
await getAll(storeId ?? jobId); await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => { const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId; const targetStore = storeId ?? jobId;
await clear(targetStore); await diffAndStoreItems(targetStore, items);
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)));
}; };
const addItem = async (item: IndexItem, storeId?: string) => { const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId; const targetStore = storeId ?? jobId;
@@ -447,35 +492,13 @@ export async function runIndexing(): Promise<void> {
} }
allItemsInPrimaryStores = await loadAllStoredItems(); allItemsInPrimaryStores = await loadAllStoredItems();
// Create new objects to avoid XrayWrapper issues in Firefox const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
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;
}
});
loadDynamicItems(itemsWithComponents); loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated")); window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: { fullRebuild: true },
}),
);
} finally { } finally {
stopHeartbeat(); stopHeartbeat();
} }
@@ -1,5 +1,5 @@
import type { IndexItem } from "./types"; import type { IndexItem } from "./types";
import { put, getAll } from "./db"; import { getAll, put } from "./db";
import { import {
buildIndexItem, buildIndexItem,
extractTextFromValue, extractTextFromValue,
@@ -7,10 +7,8 @@ import {
pickTitle, pickTitle,
} from "./extract"; } from "./extract";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { loadAllStoredItems } from "./indexer"; import { mergeDynamicItems } from "../utils/dynamicItems";
import { loadDynamicItems } from "../utils/dynamicItems"; import { decorateIndexItems } from "./renderComponents";
import { renderComponentMap } from "./renderComponents";
import { jobs } from "./jobs";
/** /**
* Passive network observer. * Passive network observer.
@@ -41,6 +39,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
let installed = false; let installed = false;
let pendingFlush: ReturnType<typeof setTimeout> | null = null; let pendingFlush: ReturnType<typeof setTimeout> | null = null;
let pendingDirty = false; 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 { export function isPassiveObserverInstalled(): boolean {
return installed; return installed;
@@ -386,6 +386,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
for (const item of items) { for (const item of items) {
try { try {
await put(STORE_ID, item, item.id); await put(STORE_ID, item, item.id);
pendingChangedItems.set(item.id, item);
} catch (e) { } catch (e) {
console.warn( console.warn(
`[Passive Observer] Failed to persist item ${item.id}:`, `[Passive Observer] Failed to persist item ${item.id}:`,
@@ -409,38 +410,20 @@ function scheduleFlush() {
} }
async function flushDynamicItems(): Promise<void> { async function flushDynamicItems(): Promise<void> {
if (pendingChangedItems.size === 0) return;
const rawChanged = Array.from(pendingChangedItems.values());
pendingChangedItems.clear();
try { try {
const all = await loadAllStoredItems(); const decorated = decorateIndexItems(rawChanged);
const decorated = all.map((item) => { mergeDynamicItems(decorated);
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);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("dynamic-items-updated", { new CustomEvent("dynamic-items-updated", {
detail: { detail: {
incremental: true, incremental: true,
jobId: STORE_ID, jobId: STORE_ID,
changedItems: decorated,
streaming: false, streaming: false,
}, },
}), }),
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte"; import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte"; import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte"; import GenericItem from "../components/items/GenericItem.svelte";
import type { IndexItem } from "./types";
import { jobs } from "./jobs";
export const renderComponentMap: Record<string, typeof SvelteComponent> = { export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as 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, goal: GenericItem as unknown as typeof SvelteComponent,
passive: 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[], bm25Results: CombinedResult[],
query: string, query: string,
options: HybridSearchOptions = {}, options: HybridSearchOptions = {},
precomputedVectorResults?: VectorSearchResult[],
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options }; const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
@@ -146,9 +147,10 @@ export async function hybridSearch(
if (trimmedQuery.length > 2) { if (trimmedQuery.length > 2) {
try { try {
// Get more vector results than BM25 results to ensure coverage const vectorTopK = opts.bm25TopK * 2;
// This allows us to find semantic matches that BM25 might have missed const vectorSearchResults =
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2); precomputedVectorResults ??
(await searchVectors(trimmedQuery, vectorTopK));
// Create a map of item ID to vector similarity // Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>(); const vectorMap = new Map<string, number>();
@@ -249,14 +251,26 @@ export async function hybridSearchWithExpansion(
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
const liveIndexIds = new Set(allItems.map((item) => item.id)); 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) { 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 // For short / single-token queries vector expansion brings in too much
// noise (and is the main reason results "flicker" between adjacent // noise (and is the main reason results "flicker" between adjacent
// keystrokes). Keep semantic recall for longer queries. // keystrokes). Keep semantic recall for longer queries.
@@ -264,15 +278,6 @@ export async function hybridSearchWithExpansion(
return rerankedBm25.slice(0, opts.finalLimit); 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 // Find vector results that weren't in BM25 results
const bm25Ids = new Set(bm25Results.map(r => r.item.id)); const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = []; const vectorOnlyResults: CombinedResult[] = [];
@@ -101,36 +101,12 @@ if (typeof window !== 'undefined') {
}); });
} }
export function createSearchIndexes() { /** Rebuild Fuse when incremental delta exceeds this count. */
clearSearchCache(); export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
const commands = getStaticCommands();
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
// Optimized command search options export const DYNAMIC_FUSE_OPTIONS = {
const commandOptions = {
keys: ["text", "category", "keywords"],
includeScore: true,
includeMatches: true,
threshold: 0.35, // Slightly more permissive for better recall
minMatchCharLength: 2,
useExtendedSearch: false,
ignoreLocation: false,
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: [ keys: [
{ name: "text", weight: 3 }, // Title is king { name: "text", weight: 3 },
{ name: "content", weight: 1 }, { name: "content", weight: 1 },
{ name: "category", weight: 0.4 }, { name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.6 }, { name: "metadata.subjectName", weight: 1.6 },
@@ -153,14 +129,86 @@ export function createSearchIndexes() {
ignoreLocation: true, ignoreLocation: true,
findAllMatches: true, findAllMatches: true,
shouldSort: 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();
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
// Optimized command search options
const commandOptions = {
keys: ["text", "category", "keywords"],
includeScore: true,
includeMatches: true,
threshold: 0.35, // Slightly more permissive for better recall
minMatchCharLength: 2,
useExtendedSearch: false,
ignoreLocation: false,
findAllMatches: false, // Performance optimization
}; };
return { return {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>, commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: new Fuse( dynamicContentFuse: createDynamicContentFuse(dynamicItems),
dynamicItems,
dynamicOptions,
) as Fuse<IndexItem>,
commands, commands,
dynamicItems, dynamicItems,
}; };
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
let dynamicItems: IndexItem[] = []; let dynamicItems: IndexItem[] = [];
/** /**
* Loads a new set of dynamic items. * Loads a new set of dynamic items (full replace).
*/ */
export function loadDynamicItems(items: IndexItem[]) { export function loadDynamicItems(items: IndexItem[]) {
dynamicItems = items; 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. * Returns all currently loaded dynamic items.
*/ */
@@ -310,8 +310,6 @@
y: { type: "tween", duration: 600, easing: cubicInOut }, 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 { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte"; import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null; 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 * @param themeID - The ID of the theme to load in the Theme Creator
* @returns void * @returns void
*/ */
export function OpenThemeCreator(themeID: string = "") { export async function OpenThemeCreator(themeID: string = "") {
CloseThemeCreator(); 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"); localStorage.setItem("themeCreatorOpen", "true");
if (!themeID) { if (!themeID) {
localStorage.setItem("originalPreviewColor", settingsState.selectedColor); localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
const mainContent = document.querySelector("#container") as HTMLDivElement; const mainContent = document.querySelector("#container") as HTMLDivElement;
if (mainContent) mainContent.style.width = `calc(100% - ${width})`; if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
// close button
const closeButton = document.createElement("button"); const closeButton = document.createElement("button");
closeButton.classList.add("themeCloseButton"); closeButton.classList.add("themeCloseButton");
closeButton.textContent = "×"; closeButton.textContent = "×";
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
* @returns void * @returns void
*/ */
export function CloseThemeCreator() { export function CloseThemeCreator() {
// Remove the stored flag
localStorage.removeItem("themeCreatorOpen"); localStorage.removeItem("themeCreatorOpen");
const themeCreator = document.getElementById("themeCreator"); 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 ReactFiber from "@/seqta/utils/ReactFiber";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import type { SettingsState } from "@/types/storage";
function createSEQTAAPI(): SEQTAAPI { function createSEQTAAPI(): SEQTAAPI {
return { return {
@@ -149,29 +150,27 @@ function createSettingsAPI<T extends PluginSettings>(
settingsWithMeta.loaded = loaded; settingsWithMeta.loaded = loaded;
// Listen for storage changes and update settingsWithMeta const handleSettingsChange = (newValue: unknown) => {
const handleStorageChange = ( if (!newValue || typeof newValue !== "object") return;
changes: { [key: string]: browser.Storage.StorageChange },
area: string,
) => {
if (area !== "local" || !(storageKey in changes)) return;
const newValue = changes[storageKey].newValue as const newSettings = newValue as Partial<Record<keyof T, any>>;
| Partial<Record<keyof T, any>> for (const key in newSettings) {
| undefined;
if (!newValue) return;
for (const key in newValue) {
const typedKey = key as keyof T; const typedKey = key as keyof T;
settingsWithMeta[typedKey] = newValue[typedKey]; settingsWithMeta[typedKey] = newSettings[typedKey];
listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey])); listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey]));
} }
}; };
browser.storage.onChanged.addListener(handleStorageChange); settingsState.register(
storageKey as keyof SettingsState,
handleSettingsChange,
);
const dispose = () => { const dispose = () => {
browser.storage.onChanged.removeListener(handleStorageChange); settingsState.unregister(
storageKey as keyof SettingsState,
handleSettingsChange,
);
}; };
const proxy = new Proxy(settingsWithMeta, { const proxy = new Proxy(settingsWithMeta, {
@@ -241,29 +240,22 @@ function createStorageAPI<T = any>(
} }
})(); })();
// Listen for storage changes
const handleStorageChange = ( const handleStorageChange = (
changes: { [key: string]: any }, newValue: unknown,
area: string, _oldValue: unknown,
key: string,
) => { ) => {
if (area === "local") { if (!key.startsWith(prefix)) return;
Object.entries(changes).forEach(([key, change]) => {
if (key.startsWith(prefix)) {
const shortKey = key.slice(prefix.length);
cache[shortKey] = change.newValue;
// Notify listeners const shortKey = key.slice(prefix.length);
listeners cache[shortKey] = newValue;
.get(shortKey) listeners.get(shortKey)?.forEach((callback) => callback(newValue));
?.forEach((callback) => callback(change.newValue));
}
});
}
}; };
browser.storage.onChanged.addListener(handleStorageChange);
settingsState.registerGlobal(handleStorageChange);
const dispose = () => { const dispose = () => {
browser.storage.onChanged.removeListener(handleStorageChange); settingsState.unregisterGlobal(handleStorageChange);
}; };
// Create the proxy for direct property access // Create the proxy for direct property access
+44 -11
View File
@@ -23,6 +23,23 @@ interface StorageChange<T = any> {
newValue?: T; 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. * Singleton class responsible for the entire lifecycle of plugins.
* This includes registration, starting, stopping, event dispatching, * This includes registration, starting, stopping, event dispatching,
@@ -215,25 +232,41 @@ export class PluginManager {
} }
} }
/** private async startPluginPhase(pluginIds: string[]): Promise<void> {
* Attempts to start all registered plugins. const registeredIds = new Set(this.plugins.keys());
* Errors during the start of individual plugins are caught and logged, const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
* allowing other plugins to attempt to start.
* const startPromises = idsToStart.map((id) =>
* @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) =>
this.startPlugin(id).catch((error) => { this.startPlugin(id).catch((error) => {
console.error(`Failed to start plugin "${id}":`, 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); 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. * Stops a specific plugin by its ID.
* This involves: * This involves:
-2
View File
@@ -39,8 +39,6 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
pluginManager.registerPlugin(globalSearchPluginLazy); pluginManager.registerPlugin(globalSearchPluginLazy);
pluginManager.registerPlugin(gradeAnalyticsPluginLazy); pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
export { init as Monofile } from "./monofile";
export async function initializePlugins(): Promise<void> { export async function initializePlugins(): Promise<void> {
await pluginManager.startAllPlugins(); await pluginManager.startAllPlugins();
} }
+30 -15
View File
@@ -26,7 +26,6 @@ import {
updateEngageHomeMenuActive, updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage"; } from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -337,7 +336,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
break; break;
case "analytics": case "analytics":
console.info("[BetterSEQTA+] Started Init (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(); finishLoad();
break; break;
case undefined: case undefined:
@@ -488,25 +491,37 @@ async function handleReports(node: Element): Promise<void> {
} }
} }
function CheckNoticeTextColour(notice: any) { function CheckNoticeTextColour(notice: Element) {
eventManager.register( const adjustNoticeColor = (node: Element) => {
"noticeAdded", const hex = (node as HTMLElement).style.cssText.split(" ")[1];
{
elementType: "div",
className: "notice",
parentElement: notice,
},
(node) => {
var hex = (node as HTMLElement).style.cssText.split(" ")[1];
if (hex) { if (hex) {
const hex1 = hex.slice(0, -1); const hex1 = hex.slice(0, -1);
var threshold = GetThresholdOfColor(hex1); const threshold = GetThresholdOfColor(hex1);
if (settingsState.DarkMode && threshold < 100) { if (settingsState.DarkMode && threshold < 100) {
(node as HTMLElement).style.cssText = "--color: undefined;"; (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() { 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();
}
+39 -29
View File
@@ -16,10 +16,12 @@ import { updateAllColors } from "./colors/Manager";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
let cachedUserInfo: any = null; let cachedUserInfo: any = null;
let userInfoFetchPromise: Promise<any> | null = null;
let userInfoCacheListenersAttached = false; let userInfoCacheListenersAttached = false;
export function invalidateCachedUserInfo(): void { export function invalidateCachedUserInfo(): void {
cachedUserInfo = null; cachedUserInfo = null;
userInfoFetchPromise = null;
} }
function attachUserInfoCacheInvalidation(): void { function attachUserInfoCacheInvalidation(): void {
@@ -48,6 +50,11 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
return cachedUserInfo; return cachedUserInfo;
} }
if (userInfoFetchPromise && !options?.validateSession) {
return userInfoFetchPromise;
}
const fetchUserInfo = async () => {
try { try {
const response = await fetch(`${location.origin}/seqta/student/login`, { const response = await fetch(`${location.origin}/seqta/student/login`, {
method: "POST", method: "POST",
@@ -85,8 +92,20 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
} catch (error) { } catch (error) {
console.error("[BetterSEQTA+] Failed to get user info:", error); console.error("[BetterSEQTA+] Failed to get user info:", error);
throw error; throw error;
} finally {
if (!options?.validateSession) {
userInfoFetchPromise = null;
} }
} }
};
if (options?.validateSession) {
return fetchUserInfo();
}
userInfoFetchPromise = fetchUserInfo();
return userInfoFetchPromise;
}
export async function AddBetterSEQTAElements() { export async function AddBetterSEQTAElements() {
if (isSeqtaEngageExperience()) { if (isSeqtaEngageExperience()) {
@@ -115,11 +134,7 @@ export async function AddBetterSEQTAElements() {
menuList.insertBefore(fragment, menuList.firstChild); menuList.insertBefore(fragment, menuList.firstChild);
try { try {
await Promise.all([ await Promise.all([appendBackgroundToUI(), handleUserInfoAndStudentData()]);
appendBackgroundToUI(),
handleUserInfo(),
handleStudentData(),
]);
} catch (error) { } catch (error) {
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error); console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
} }
@@ -149,11 +164,26 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
); );
} }
async function handleUserInfo() { async function handleUserInfoAndStudentData() {
try { try {
updateUserInfo(await getUserInfo()); const [userInfo, studentResponse] = await Promise.all([
getUserInfo(),
fetch(`${location.origin}/seqta/student/load/message/people`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ mode: "student" }),
}),
]);
updateUserInfo(userInfo);
await updateStudentInfo((await studentResponse.json()).payload, userInfo);
} catch (error) { } catch (error) {
console.error("[BetterSEQTA+] Failed to handle user info:", error); console.error(
"[BetterSEQTA+] Failed to handle user info and student data:",
error,
);
} }
} }
@@ -209,27 +239,7 @@ function updateUserInfo(info: {
.appendChild(document.getElementsByClassName("logout")[0]); .appendChild(document.getElementsByClassName("logout")[0]);
} }
async function handleStudentData() { async function updateStudentInfo(students: any, info: Awaited<ReturnType<typeof getUserInfo>>) {
try {
const response = await fetch(
`${location.origin}/seqta/student/load/message/people`,
{
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ mode: "student" }),
},
);
await updateStudentInfo((await response.json()).payload);
} catch (error) {
console.error("[BetterSEQTA+] Failed to handle student data:", error);
}
}
async function updateStudentInfo(students: any) {
const info = await getUserInfo();
const index = students.findIndex( const index = students.findIndex(
(person: any) => (person: any) =>
person.firstname == info.userDesc.split(" ")[0] && person.firstname == info.userDesc.split(" ")[0] &&
+9 -8
View File
@@ -1,26 +1,27 @@
import renderSvelte from "@/interface/main";
import Store from "@/interface/pages/store.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
let remove: () => void; let remove: () => void;
export function OpenStorePage() { export async function OpenStorePage(): Promise<void> {
remove = renderStore(); remove = await renderStore();
} }
export function renderStore() { export async function renderStore() {
const [{ default: renderSvelte }, { default: Store }] = await Promise.all([
import("@/interface/main"),
import("@/interface/pages/store.svelte"),
]);
const container = document.querySelector("#container"); const container = document.querySelector("#container");
if (!container) { if (!container) {
throw new Error("Container not found"); throw new Error("Container not found");
} }
// Avoid stacking multiple store roots if opened repeatedly without close.
document.getElementById("store")?.remove(); document.getElementById("store")?.remove();
const child = document.createElement("div"); const child = document.createElement("div");
child.id = "store"; child.id = "store";
container!.appendChild(child); container.appendChild(child);
const shadow = child.attachShadow({ mode: "open" }); const shadow = child.attachShadow({ mode: "open" });
const app = renderSvelte(Store, shadow); const app = renderSvelte(Store, shadow);
+29 -12
View File
@@ -3,11 +3,10 @@ import {
closeExtensionPopup, closeExtensionPopup,
SettingsClicked, SettingsClicked,
} from "../Closers/closeExtensionPopup"; } from "../Closers/closeExtensionPopup";
import renderSvelte from "@/interface/main";
import { SettingsResizer } from "@/seqta/ui/SettingsResizer"; import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false; let isSettingsRendered = false;
let settingsLoadPromise: Promise<void> | null = null;
function extensionOutsideClickHandler(extensionPopup: HTMLElement) { function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
return (event: MouseEvent) => { return (event: MouseEvent) => {
@@ -38,21 +37,39 @@ export function addExtensionSettings() {
(extensionContainer ?? document.body).addEventListener("click", handler, false); (extensionContainer ?? document.body).addEventListener("click", handler, false);
} }
export function renderSettingsIfNeeded() { async function loadSettingsUi(extensionPopup: HTMLElement): Promise<void> {
if (isSettingsRendered) return;
const [{ default: renderSvelte }, { default: Settings }] = await Promise.all([
import("@/interface/main"),
import("@/interface/pages/settings.svelte"),
]);
const shadow = extensionPopup.attachShadow({ mode: "open" });
const mount = () => renderSvelte(Settings, shadow);
if ("requestIdleCallback" in window) {
requestIdleCallback(mount);
} else {
mount();
}
isSettingsRendered = true;
}
export async function renderSettingsIfNeeded(): Promise<void> {
if (isSettingsRendered) return; if (isSettingsRendered) return;
const extensionPopup = document.getElementById("ExtensionPopup"); const extensionPopup = document.getElementById("ExtensionPopup");
if (!extensionPopup) return; if (!extensionPopup) return;
try { if (!settingsLoadPromise) {
const shadow = extensionPopup.attachShadow({ mode: "open" }); settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => {
if ('requestIdleCallback' in window) { settingsLoadPromise = null;
requestIdleCallback(() => renderSvelte(Settings, shadow));
} else {
renderSvelte(Settings, shadow);
}
isSettingsRendered = true;
} catch (err) {
console.error(err); console.error(err);
throw err;
});
} }
await settingsLoadPromise;
} }
@@ -1,7 +1,7 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { animate } from "motion"; import { animate } from "motion";
import { settingsPopup } from "@/interface/hooks/SettingsPopup"; import { settingsPopup } from "@/seqta/utils/settingsPopup";
export let SettingsClicked = false; export let SettingsClicked = false;
@@ -785,7 +785,7 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month; settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismissWithCleanup(); dismissWithCleanup();
openThemeStoreWithHighlight(linkedThemeId!); void openThemeStoreWithHighlight(linkedThemeId!);
}); });
const openDontShowConfirm = () => { const openDontShowConfirm = () => {
@@ -37,6 +37,8 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
"profile_picture_revision", "profile_picture_revision",
] as const; ] as const;
let defaultsEnsured = false;
/** /**
* Flat default map in upload shape (plugin-format only; no legacy keys). * Flat default map in upload shape (plugin-format only; no legacy keys).
*/ */
@@ -76,6 +78,8 @@ function mergePluginSettingsDefaults(
* Never overwrites existing values. Missing plugin settings respect legacy keys. * Never overwrites existing values. Missing plugin settings respect legacy keys.
*/ */
export async function ensureSyncableStorageDefaults(): Promise<void> { export async function ensureSyncableStorageDefaults(): Promise<void> {
if (defaultsEnsured) return;
const existing = await browser.storage.local.get(); const existing = await browser.storage.local.get();
const migratedFromExisting = migrateLegacyToPluginSettings({ const migratedFromExisting = migrateLegacyToPluginSettings({
...existing, ...existing,
@@ -101,4 +105,6 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await browser.storage.local.set(patch); await browser.storage.local.set(patch);
} }
defaultsEnsured = true;
} }
+29 -2
View File
@@ -3,6 +3,7 @@ interface EventListenerOptions {
textContent?: string; textContent?: string;
className?: string; className?: string;
id?: string; id?: string;
selector?: string;
customCheck?: (element: Element) => boolean; customCheck?: (element: Element) => boolean;
once?: boolean; once?: boolean;
parentElement?: Element; parentElement?: Element;
@@ -20,6 +21,7 @@ class EventManager {
private listeners: Map<string, EventListener[]> = new Map(); private listeners: Map<string, EventListener[]> = new Map();
private mutationObservers: Map<Element, MutationObserver> = new Map(); private mutationObservers: Map<Element, MutationObserver> = new Map();
private pendingElements: Set<Element> = new Set(); private pendingElements: Set<Element> = new Set();
private firedOnceIds: Set<string> = new Set();
private throttleTimeout: number = 5; // 5ms throttle private throttleTimeout: number = 5; // 5ms throttle
private throttleTimer: number | undefined; private throttleTimer: number | undefined;
private chunkSize: number = 50; // Process 50 elements per chunk private chunkSize: number = 50; // Process 50 elements per chunk
@@ -58,6 +60,7 @@ class EventManager {
} }
private buildSelector(options: EventListenerOptions): string | null { private buildSelector(options: EventListenerOptions): string | null {
if (options.selector) return options.selector;
if (options.textContent || options.customCheck) return null; if (options.textContent || options.customCheck) return null;
let selector = options.elementType || ""; let selector = options.elementType || "";
@@ -71,6 +74,23 @@ class EventManager {
return selector.trim() || null; return selector.trim() || null;
} }
private getElementsToCheck(
element: Element,
options: EventListenerOptions,
): Element[] {
const selector = this.buildSelector(options);
if (!selector) return [element];
const targets = new Set<Element>();
if (element.matches(selector)) {
targets.add(element);
}
for (const match of element.querySelectorAll(selector)) {
targets.add(match);
}
return Array.from(targets);
}
private async scanExistingElements( private async scanExistingElements(
options: EventListenerOptions, options: EventListenerOptions,
callback: (element: Element) => void, callback: (element: Element) => void,
@@ -174,10 +194,17 @@ class EventManager {
private async checkElement(element: Element): Promise<void> { private async checkElement(element: Element): Promise<void> {
for (const [event, listeners] of this.listeners.entries()) { for (const [event, listeners] of this.listeners.entries()) {
for (const { id, options, callback } of listeners) { for (const { id, options, callback } of listeners) {
if (this.matchesOptions(element, options)) { if (options.once && this.firedOnceIds.has(id)) continue;
callback(element);
const targets = this.getElementsToCheck(element, options);
for (const target of targets) {
if (!this.matchesOptions(target, options)) continue;
callback(target);
if (options.once) { if (options.once) {
this.firedOnceIds.add(id);
this.unregisterById(event, id); this.unregisterById(event, id);
break;
} }
} }
} }
+39 -10
View File
@@ -6,15 +6,20 @@ import {
OpenMenuOptions, OpenMenuOptions,
} from "@/seqta/utils/Openers/OpenMenuOptions"; } from "@/seqta/utils/Openers/OpenMenuOptions";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import {
CloseThemeCreator,
OpenThemeCreator,
} from "@/plugins/built-in/themes/ThemeCreator";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate"; import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"; import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
import type { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
const themeManager = ThemeManager.getInstance(); let themeManagerPromise: Promise<ThemeManager> | null = null;
function getThemeManager(): Promise<ThemeManager> {
if (!themeManagerPromise) {
themeManagerPromise = import("@/plugins/built-in/themes/theme-manager").then(
({ ThemeManager }) => ThemeManager.getInstance(),
);
}
return themeManagerPromise;
}
export class MessageHandler { export class MessageHandler {
constructor() { constructor() {
@@ -34,6 +39,7 @@ export class MessageHandler {
case "UpdateThemePreview": case "UpdateThemePreview":
if (request?.save == true) { if (request?.save == true) {
const save = async () => { const save = async () => {
const themeManager = await getThemeManager();
await themeManager.saveTheme({ await themeManager.saveTheme({
...request.body, ...request.body,
userEdited: true, userEdited: true,
@@ -44,57 +50,78 @@ export class MessageHandler {
sendResponse({ status: "success" }); sendResponse({ status: "success" });
sendThemeUpdate(); sendThemeUpdate();
}; };
save(); void save();
} else { } else {
void getThemeManager().then((themeManager) => {
themeManager.updatePreview(request.body); themeManager.updatePreview(request.body);
sendResponse({ status: "success" }); sendResponse({ status: "success" });
});
} }
return true; return true;
case "GetTheme": case "GetTheme":
void getThemeManager().then((themeManager) => {
themeManager.getTheme(request.body.themeID).then((theme) => { themeManager.getTheme(request.body.themeID).then((theme) => {
sendResponse(theme); sendResponse(theme);
}); });
});
return true; return true;
case "SetTheme": case "SetTheme":
void getThemeManager().then((themeManager) => {
themeManager.setTheme(request.body.themeID).then(() => { themeManager.setTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" }); sendResponse({ status: "success" });
}); });
});
return true; return true;
case "DisableTheme": case "DisableTheme":
void getThemeManager().then((themeManager) => {
themeManager.disableTheme().then(() => { themeManager.disableTheme().then(() => {
sendResponse({ status: "success" }); sendResponse({ status: "success" });
}); });
});
return true; return true;
case "DeleteTheme": case "DeleteTheme":
void getThemeManager().then((themeManager) => {
themeManager.deleteTheme(request.body.themeID).then(() => { themeManager.deleteTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" }); sendResponse({ status: "success" });
}); });
});
return true; return true;
case "ListThemes": case "ListThemes":
void getThemeManager().then((themeManager) => {
themeManager.getAvailableThemes().then((themes) => { themeManager.getAvailableThemes().then((themes) => {
sendResponse(themes); sendResponse(themes);
}); });
});
return true; return true;
case "OpenThemeCreator": case "OpenThemeCreator": {
const themeID = request?.body?.themeID; const themeID = request?.body?.themeID;
OpenThemeCreator(themeID ? themeID : ""); void import("@/plugins/built-in/themes/ThemeCreator").then(
({ OpenThemeCreator }) => {
void OpenThemeCreator(themeID ? themeID : "");
},
);
closeExtensionPopup(); closeExtensionPopup();
sendResponse({ status: "success" }); sendResponse({ status: "success" });
break; break;
}
case "ShareTheme": case "ShareTheme":
void getThemeManager().then((themeManager) => {
themeManager.shareTheme(request.body.themeID).then((id) => { themeManager.shareTheme(request.body.themeID).then((id) => {
sendResponse({ status: "success", id }); sendResponse({ status: "success", id });
}); });
});
return true; return true;
case "CloseThemeCreator": case "CloseThemeCreator":
void import("@/plugins/built-in/themes/ThemeCreator").then(
({ CloseThemeCreator }) => {
try { try {
CloseThemeCreator(); CloseThemeCreator();
sendResponse({ status: "success" }); sendResponse({ status: "success" });
@@ -102,7 +129,9 @@ export class MessageHandler {
console.error("Error closing theme creator:", error); console.error("Error closing theme creator:", error);
sendResponse({ status: "error" }); sendResponse({ status: "error" });
} }
break; },
);
return true;
case "HideSensitive": case "HideSensitive":
hideSensitiveContent(); hideSensitiveContent();
+59 -10
View File
@@ -16,6 +16,23 @@ function isExcludedSettingsKey(key: string): boolean {
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key); return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
} }
const SAVE_DEBOUNCE_MS = 200;
function storageChangeIsNoop(oldValue: unknown, newValue: unknown): boolean {
if (oldValue === newValue) return true;
if (
oldValue === undefined ||
newValue === undefined ||
typeof oldValue !== "object" ||
typeof newValue !== "object" ||
oldValue === null ||
newValue === null
) {
return false;
}
return JSON.stringify(oldValue) === JSON.stringify(newValue);
}
type ChangeListener = (newValue: any, oldValue: any) => void; type ChangeListener = (newValue: any, oldValue: any) => void;
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void; type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
@@ -25,9 +42,11 @@ class StorageManager {
private listeners: Map<string, Set<ChangeListener>>; private listeners: Map<string, Set<ChangeListener>>;
private globalListeners: Set<GlobalChangeListener>; private globalListeners: Set<GlobalChangeListener>;
private subscribers: Set<Subscriber<SettingsState>> = new Set(); private subscribers: Set<Subscriber<SettingsState>> = new Set();
private saveTimeout: NodeJS.Timeout | null = null; private saveTimeout: ReturnType<typeof setTimeout> | null = null;
private pendingPatch: Record<string, unknown> = {};
private initialized = false; private initialized = false;
private bootstrapping = false; private bootstrapping = false;
private suppressWrites = false;
private constructor() { private constructor() {
this.data = {} as SettingsState; this.data = {} as SettingsState;
@@ -151,12 +170,14 @@ class StorageManager {
}); });
} }
public async saveToStorage(changedKeys?: string[]): Promise<void> { public setSuppressWrites(suppress: boolean): void {
if (this.saveTimeout) { this.suppressWrites = suppress;
clearTimeout(this.saveTimeout); if (!suppress) {
this.saveTimeout = null; this.scheduleDebouncedSave();
} }
const payload: Record<string, unknown> = {}; }
private queueStoragePatch(changedKeys?: string[]): void {
const keys = const keys =
changedKeys && changedKeys.length > 0 changedKeys && changedKeys.length > 0
? changedKeys ? changedKeys
@@ -166,18 +187,42 @@ class StorageManager {
if (isExcludedSettingsKey(key)) continue; if (isExcludedSettingsKey(key)) continue;
const value = (this.data as Record<string, unknown>)[key]; const value = (this.data as Record<string, unknown>)[key];
if (value !== undefined) { if (value !== undefined) {
payload[key] = value; this.pendingPatch[key] = value;
}
} }
} }
if (Object.keys(payload).length === 0) return; private scheduleDebouncedSave(): void {
if (this.bootstrapping || this.suppressWrites) return;
if (Object.keys(this.pendingPatch).length === 0) return;
await browser.storage.local.set(payload); if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
void this.flushPendingPatch();
}, SAVE_DEBOUNCE_MS);
}
private async flushPendingPatch(): Promise<void> {
this.saveTimeout = null;
if (this.bootstrapping || this.suppressWrites) return;
const patch = { ...this.pendingPatch };
this.pendingPatch = {};
if (Object.keys(patch).length === 0) return;
await browser.storage.local.set(patch);
if (!this.bootstrapping) { if (!this.bootstrapping) {
this.notifySubscribers(); this.notifySubscribers();
} }
} }
public saveToStorage(changedKeys?: string[]): void {
this.queueStoragePatch(changedKeys);
this.scheduleDebouncedSave();
}
private async removeFromStorage(key: string): Promise<void> { private async removeFromStorage(key: string): Promise<void> {
await browser.storage.local.remove(key); await browser.storage.local.remove(key);
} }
@@ -189,7 +234,7 @@ class StorageManager {
const actualChanges: string[] = []; const actualChanges: string[] = [];
for (const [key, { oldValue, newValue }] of Object.entries(changes)) { for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue; if (storageChangeIsNoop(oldValue, newValue)) continue;
if (isExcludedSettingsKey(key)) continue; if (isExcludedSettingsKey(key)) continue;
if (newValue !== undefined) { if (newValue !== undefined) {
@@ -292,3 +337,7 @@ class StorageManager {
export const settingsState = StorageManager.getInstance(); export const settingsState = StorageManager.getInstance();
export const initializeSettingsState = async () => export const initializeSettingsState = async () =>
await StorageManager.initialize(); await StorageManager.initialize();
export function setSettingsStateSuppressWrites(suppress: boolean): void {
settingsState.setSuppressWrites(suppress);
}
+3 -17
View File
@@ -1,30 +1,15 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/** /**
* Module-level handoff for "open the theme store and highlight this theme". * Module-level handoff for "open the theme store and highlight this theme".
*
* The store page is mounted lazily inside a Shadow DOM the first time it
* opens, so a `CustomEvent` listener would have to be wired up before mount
* (causing a race). Using a shared cell keeps the producer (popup button) and
* consumer (store `onMount`) decoupled without that timing constraint.
*
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
*/ */
let pendingHighlightThemeId: string | null = null; let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null { export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId; const id = pendingHighlightThemeId;
pendingHighlightThemeId = null; pendingHighlightThemeId = null;
return id; return id;
} }
/** export async function openThemeStoreWithHighlight(themeId: string): Promise<void> {
* Opens the theme store and asks it to focus / highlight the given theme.
* If the store is already mounted we dispatch a DOM event so it can react
* without remounting; otherwise the store consumes the pending id on mount.
*/
export function openThemeStoreWithHighlight(themeId: string): void {
pendingHighlightThemeId = themeId; pendingHighlightThemeId = themeId;
const existing = document.getElementById("store"); const existing = document.getElementById("store");
@@ -35,5 +20,6 @@ export function openThemeStoreWithHighlight(themeId: string): void {
return; return;
} }
OpenStorePage(); const { OpenStorePage } = await import("@/seqta/ui/renderStore");
await OpenStorePage();
} }
+33
View File
@@ -0,0 +1,33 @@
type SettingsPopupCallback = () => void;
/**
* Singleton that notifies listeners when the in-page settings popup closes.
* Used by the colour picker and other overlays tied to ExtensionPopup.
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
private constructor() {}
public static getInstance(): SettingsPopup {
if (!SettingsPopup.instance) {
SettingsPopup.instance = new SettingsPopup();
}
return SettingsPopup.instance;
}
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
}
export const settingsPopup = SettingsPopup.getInstance();
+1 -1
View File
@@ -17,7 +17,7 @@ export function setupSettingsButton() {
if (SettingsClicked) { if (SettingsClicked) {
closeExtensionPopup(extensionPopup as HTMLElement); closeExtensionPopup(extensionPopup as HTMLElement);
} else { } else {
renderSettingsIfNeeded(); await renderSettingsIfNeeded();
await delay(30); await delay(30);
+32 -38
View File
@@ -1,12 +1,9 @@
import { eventManager } from "@/seqta/utils/listeners/EventManager";
import { delay } from "@/seqta/utils/delay";
/** /**
* Asynchronously waits for an element to be present in the DOM. * Asynchronously waits for an element to be present in the DOM.
* *
* This function can use either a polling mechanism (via `setTimeout`) or * By default uses direct `querySelector` plus a targeted `MutationObserver`
* a `MutationObserver` (via `eventManager.register`) to detect the element. * on `document.documentElement`. Polling via `setTimeout` is available as a
* By default, it uses the `eventManager` which is more efficient. * fallback when `usePolling` is true.
* *
* @param {string} selector The CSS selector for the target element. * @param {string} selector The CSS selector for the target element.
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling. * @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
@@ -24,9 +21,6 @@ export async function waitForElm(
if (usePolling) { if (usePolling) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let iterations = 0; let iterations = 0;
if (maxIterations) {
iterations = 0;
}
const checkForElement = () => { const checkForElement = () => {
const element = document.querySelector(selector); const element = document.querySelector(selector);
if (element) { if (element) {
@@ -36,6 +30,7 @@ export async function waitForElm(
iterations++; iterations++;
if (iterations >= maxIterations) { if (iterations >= maxIterations) {
reject(new Error("Element not found")); reject(new Error("Element not found"));
return;
} }
} }
setTimeout(checkForElement, interval); setTimeout(checkForElement, interval);
@@ -43,47 +38,46 @@ export async function waitForElm(
}; };
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", checkForElement); document.addEventListener("DOMContentLoaded", checkForElement, {
once: true,
});
} else { } else {
checkForElement(); checkForElement();
} }
}); });
} else { }
return new Promise((resolve) => { return new Promise((resolve) => {
const registerObserver = () => { const tryResolve = (): boolean => {
const { unregister } = eventManager.register( const element = document.querySelector(selector);
`${selector}`, if (element) {
{
customCheck: (element) => element.matches(selector),
},
async (element) => {
resolve(element); resolve(element);
await delay(1); return true;
unregister(); // Remove the listener once the element is found }
}, return false;
);
return unregister;
}; };
let unregister = null; const startObserver = () => {
if (tryResolve()) return;
const observer = new MutationObserver(() => {
if (tryResolve()) {
observer.disconnect();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
};
if (document.readyState === "loading") { if (document.readyState === "loading") {
// DOM is still loading, wait for it to be ready document.addEventListener("DOMContentLoaded", startObserver, {
document.addEventListener("DOMContentLoaded", () => { once: true,
unregister = registerObserver();
}); });
} else { } else {
unregister = registerObserver(); startObserver();
}
const querySelector = () => document.querySelector(selector);
const element = querySelector();
if (element) {
if (unregister) unregister();
resolve(element);
return;
} }
}); });
} }
}