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 browser from "webextension-polyfill";
import * as plugins from "@/plugins";
import { init as Monofile } from "@/plugins/monofile";
import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
@@ -104,10 +104,11 @@ async function init() {
}
await main();
plugins.Monofile();
Monofile();
if (settingsState.onoff) {
await plugins.initializePlugins();
const { initializePlugins } = await import("@/plugins/runtime");
await initializePlugins();
}
if (settingsState.devMode) {
+5 -4
View File
@@ -10,6 +10,7 @@ import {
performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll,
withSuppressedCloudAutoUpload,
} from "./background/cloudSettingsAutoSync";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
@@ -573,10 +574,10 @@ function getDefaultValues(): SettingsState {
return getDefaultSettingsState();
}
function SetStorageValue(object: any) {
for (var i in object) {
browser.storage.local.set({ [i]: object[i] });
}
function SetStorageValue(object: SettingsState) {
void withSuppressedCloudAutoUpload(() =>
browser.storage.local.set(object as Record<string, unknown>),
);
}
/** 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 {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
if (autoSyncInitialized) return;
+23 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import ColourPicker from './ColourPicker.tsx';
import ReactAdapter from './utils/ReactAdapter.svelte';
import type { Component } from 'svelte'
import { animate } from 'motion';
import { delay } from '@/seqta/utils/delay.ts'
@@ -15,6 +14,19 @@
let background = $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 () => {
if (standalone) return;
@@ -37,10 +49,11 @@
);
await delay(400);
hidePicker();
hidePicker?.();
}
onMount(() => {
void loadPicker().then(() => {
if (standalone) return;
if (!background || !content) return;
@@ -59,6 +72,7 @@
damping: 30
}
);
});
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -82,7 +96,9 @@
{#if standalone}
<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>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -96,7 +112,9 @@
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"
>
<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>
{/if}
@@ -59,23 +59,16 @@
</div>
</div>
<div class="overflow-hidden px-4 h-full">
<MotionDiv
class="h-full"
animate={{ x: `${-activeTab * 100}%` }}
transition={springTransition}
>
<div class="flex">
{#each tabs as { Content, props }, index}
{#each tabs as { Content, props }, index (index)}
{#if activeTab === index}
<div
role="tabpanel"
aria-hidden={activeTab !== index}
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 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>
class="focus:outline-none w-full pt-2 overflow-y-scroll no-scrollbar pb-2 h-full tab active"
>
<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>
<Content {...props} />
</div>
{/if}
{/each}
</div>
</MotionDiv>
</div>
</div>
+5 -2
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import logo from '@/resources/icons/betterseqta-dark-full.png';
import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
const handleCloseStore = () => {
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
};
// Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
searchTerm: string,
@@ -64,7 +67,7 @@
<!-- Close Button -->
<button
onclick={closeStore}
onclick={handleCloseStore}
class="p-1 px-3"
>
<span class="text-2xl font-IconFamily">&#xed8a;</span>
@@ -2,8 +2,6 @@
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte'
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 { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
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) => {
e.stopPropagation();
if (!cloudLoggedIn) {
@@ -212,8 +221,8 @@
</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"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }}
role="button"
tabindex="-1"
>
@@ -261,7 +270,7 @@
{/if}
<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"
>
<span class="text-xl font-IconFamily">&#xecc5;</span>
@@ -269,7 +278,7 @@
</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"
>
<span class="text-xl font-IconFamily">&#xec60;</span>
+1 -53
View File
@@ -1,53 +1 @@
type SettingsPopupCallback = () => void;
/**
* 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();
export { settingsPopup } from "@/seqta/utils/settingsPopup";
+10 -5
View File
@@ -14,11 +14,11 @@
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//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 CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
import { settingsPopup } from "@/seqta/utils/settingsPopup";
import {
checkGithubReleaseUpdate,
dismissNightlyUpdate,
@@ -64,7 +64,12 @@
}, 10000);
};
const openColourPicker = () => {
let ColourPickerComponent = $state<Component | null>(null);
const openColourPicker = async () => {
if (!ColourPickerComponent) {
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
}
showColourPicker = true;
};
@@ -363,8 +368,8 @@
/>
</div>
{#if showColourPicker}
<ColourPicker
{#if showColourPicker && ColourPickerComponent}
<ColourPickerComponent
hidePicker={() => {
showColourPicker = false;
}}
@@ -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,36 +101,12 @@ if (typeof window !== 'undefined') {
});
}
export function createSearchIndexes() {
clearSearchCache();
const commands = getStaticCommands();
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
/** Rebuild Fuse when incremental delta exceeds this count. */
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
// 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
};
// 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 = {
export const DYNAMIC_FUSE_OPTIONS = {
keys: [
{ name: "text", weight: 3 }, // Title is king
{ name: "text", weight: 3 },
{ name: "content", weight: 1 },
{ name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.6 },
@@ -153,14 +129,86 @@ export function createSearchIndexes() {
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();
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 {
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();
}
+30 -15
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];
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);
var threshold = GetThresholdOfColor(hex1);
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();
}
+39 -29
View File
@@ -16,10 +16,12 @@ import { updateAllColors } from "./colors/Manager";
import { delay } from "@/seqta/utils/delay";
let cachedUserInfo: any = null;
let userInfoFetchPromise: Promise<any> | null = null;
let userInfoCacheListenersAttached = false;
export function invalidateCachedUserInfo(): void {
cachedUserInfo = null;
userInfoFetchPromise = null;
}
function attachUserInfoCacheInvalidation(): void {
@@ -48,6 +50,11 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
return cachedUserInfo;
}
if (userInfoFetchPromise && !options?.validateSession) {
return userInfoFetchPromise;
}
const fetchUserInfo = async () => {
try {
const response = await fetch(`${location.origin}/seqta/student/login`, {
method: "POST",
@@ -85,8 +92,20 @@ export async function getUserInfo(options?: { validateSession?: boolean }) {
} catch (error) {
console.error("[BetterSEQTA+] Failed to get user info:", error);
throw error;
} finally {
if (!options?.validateSession) {
userInfoFetchPromise = null;
}
}
};
if (options?.validateSession) {
return fetchUserInfo();
}
userInfoFetchPromise = fetchUserInfo();
return userInfoFetchPromise;
}
export async function AddBetterSEQTAElements() {
if (isSeqtaEngageExperience()) {
@@ -115,11 +134,7 @@ export async function AddBetterSEQTAElements() {
menuList.insertBefore(fragment, menuList.firstChild);
try {
await Promise.all([
appendBackgroundToUI(),
handleUserInfo(),
handleStudentData(),
]);
await Promise.all([appendBackgroundToUI(), handleUserInfoAndStudentData()]);
} catch (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 {
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) {
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]);
}
async function handleStudentData() {
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();
async function updateStudentInfo(students: any, info: Awaited<ReturnType<typeof getUserInfo>>) {
const index = students.findIndex(
(person: any) =>
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";
let remove: () => void;
export function OpenStorePage() {
remove = renderStore();
export async function OpenStorePage(): Promise<void> {
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");
if (!container) {
throw new Error("Container not found");
}
// Avoid stacking multiple store roots if opened repeatedly without close.
document.getElementById("store")?.remove();
const child = document.createElement("div");
child.id = "store";
container!.appendChild(child);
container.appendChild(child);
const shadow = child.attachShadow({ mode: "open" });
const app = renderSvelte(Store, shadow);
+29 -12
View File
@@ -3,11 +3,10 @@ import {
closeExtensionPopup,
SettingsClicked,
} from "../Closers/closeExtensionPopup";
import renderSvelte from "@/interface/main";
import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false;
let settingsLoadPromise: Promise<void> | null = null;
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
return (event: MouseEvent) => {
@@ -38,21 +37,39 @@ export function addExtensionSettings() {
(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;
const extensionPopup = document.getElementById("ExtensionPopup");
if (!extensionPopup) return;
try {
const shadow = extensionPopup.attachShadow({ mode: "open" });
if ('requestIdleCallback' in window) {
requestIdleCallback(() => renderSvelte(Settings, shadow));
} else {
renderSvelte(Settings, shadow);
}
isSettingsRendered = true;
} catch (err) {
if (!settingsLoadPromise) {
settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => {
settingsLoadPromise = null;
console.error(err);
throw err;
});
}
await settingsLoadPromise;
}
@@ -1,7 +1,7 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { animate } from "motion";
import { settingsPopup } from "@/interface/hooks/SettingsPopup";
import { settingsPopup } from "@/seqta/utils/settingsPopup";
export let SettingsClicked = false;
@@ -785,7 +785,7 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismissWithCleanup();
openThemeStoreWithHighlight(linkedThemeId!);
void openThemeStoreWithHighlight(linkedThemeId!);
});
const openDontShowConfirm = () => {
@@ -37,6 +37,8 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
"profile_picture_revision",
] as const;
let defaultsEnsured = false;
/**
* 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.
*/
export async function ensureSyncableStorageDefaults(): Promise<void> {
if (defaultsEnsured) return;
const existing = await browser.storage.local.get();
const migratedFromExisting = migrateLegacyToPluginSettings({
...existing,
@@ -101,4 +105,6 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
if (Object.keys(patch).length > 0) {
await browser.storage.local.set(patch);
}
defaultsEnsured = true;
}
+29 -2
View File
@@ -3,6 +3,7 @@ interface EventListenerOptions {
textContent?: string;
className?: string;
id?: string;
selector?: string;
customCheck?: (element: Element) => boolean;
once?: boolean;
parentElement?: Element;
@@ -20,6 +21,7 @@ class EventManager {
private listeners: Map<string, EventListener[]> = new Map();
private mutationObservers: Map<Element, MutationObserver> = new Map();
private pendingElements: Set<Element> = new Set();
private firedOnceIds: Set<string> = new Set();
private throttleTimeout: number = 5; // 5ms throttle
private throttleTimer: number | undefined;
private chunkSize: number = 50; // Process 50 elements per chunk
@@ -58,6 +60,7 @@ class EventManager {
}
private buildSelector(options: EventListenerOptions): string | null {
if (options.selector) return options.selector;
if (options.textContent || options.customCheck) return null;
let selector = options.elementType || "";
@@ -71,6 +74,23 @@ class EventManager {
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(
options: EventListenerOptions,
callback: (element: Element) => void,
@@ -174,10 +194,17 @@ class EventManager {
private async checkElement(element: Element): Promise<void> {
for (const [event, listeners] of this.listeners.entries()) {
for (const { id, options, callback } of listeners) {
if (this.matchesOptions(element, options)) {
callback(element);
if (options.once && this.firedOnceIds.has(id)) continue;
const targets = this.getElementsToCheck(element, options);
for (const target of targets) {
if (!this.matchesOptions(target, options)) continue;
callback(target);
if (options.once) {
this.firedOnceIds.add(id);
this.unregisterById(event, id);
break;
}
}
}
+39 -10
View File
@@ -6,15 +6,20 @@ import {
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 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 {
constructor() {
@@ -34,6 +39,7 @@ export class MessageHandler {
case "UpdateThemePreview":
if (request?.save == true) {
const save = async () => {
const themeManager = await getThemeManager();
await themeManager.saveTheme({
...request.body,
userEdited: true,
@@ -44,57 +50,78 @@ export class MessageHandler {
sendResponse({ status: "success" });
sendThemeUpdate();
};
save();
void save();
} else {
void getThemeManager().then((themeManager) => {
themeManager.updatePreview(request.body);
sendResponse({ status: "success" });
});
}
return true;
case "GetTheme":
void getThemeManager().then((themeManager) => {
themeManager.getTheme(request.body.themeID).then((theme) => {
sendResponse(theme);
});
});
return true;
case "SetTheme":
void getThemeManager().then((themeManager) => {
themeManager.setTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
});
return true;
case "DisableTheme":
void getThemeManager().then((themeManager) => {
themeManager.disableTheme().then(() => {
sendResponse({ status: "success" });
});
});
return true;
case "DeleteTheme":
void getThemeManager().then((themeManager) => {
themeManager.deleteTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
});
return true;
case "ListThemes":
void getThemeManager().then((themeManager) => {
themeManager.getAvailableThemes().then((themes) => {
sendResponse(themes);
});
});
return true;
case "OpenThemeCreator":
case "OpenThemeCreator": {
const themeID = request?.body?.themeID;
OpenThemeCreator(themeID ? themeID : "");
void import("@/plugins/built-in/themes/ThemeCreator").then(
({ OpenThemeCreator }) => {
void OpenThemeCreator(themeID ? themeID : "");
},
);
closeExtensionPopup();
sendResponse({ status: "success" });
break;
}
case "ShareTheme":
void getThemeManager().then((themeManager) => {
themeManager.shareTheme(request.body.themeID).then((id) => {
sendResponse({ status: "success", id });
});
});
return true;
case "CloseThemeCreator":
void import("@/plugins/built-in/themes/ThemeCreator").then(
({ CloseThemeCreator }) => {
try {
CloseThemeCreator();
sendResponse({ status: "success" });
@@ -102,7 +129,9 @@ export class MessageHandler {
console.error("Error closing theme creator:", error);
sendResponse({ status: "error" });
}
break;
},
);
return true;
case "HideSensitive":
hideSensitiveContent();
+59 -10
View File
@@ -16,6 +16,23 @@ function isExcludedSettingsKey(key: string): boolean {
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 GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
@@ -25,9 +42,11 @@ class StorageManager {
private listeners: Map<string, Set<ChangeListener>>;
private globalListeners: Set<GlobalChangeListener>;
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 bootstrapping = false;
private suppressWrites = false;
private constructor() {
this.data = {} as SettingsState;
@@ -151,12 +170,14 @@ class StorageManager {
});
}
public async saveToStorage(changedKeys?: string[]): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
public setSuppressWrites(suppress: boolean): void {
this.suppressWrites = suppress;
if (!suppress) {
this.scheduleDebouncedSave();
}
const payload: Record<string, unknown> = {};
}
private queueStoragePatch(changedKeys?: string[]): void {
const keys =
changedKeys && changedKeys.length > 0
? changedKeys
@@ -166,18 +187,42 @@ class StorageManager {
if (isExcludedSettingsKey(key)) continue;
const value = (this.data as Record<string, unknown>)[key];
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) {
this.notifySubscribers();
}
}
public saveToStorage(changedKeys?: string[]): void {
this.queueStoragePatch(changedKeys);
this.scheduleDebouncedSave();
}
private async removeFromStorage(key: string): Promise<void> {
await browser.storage.local.remove(key);
}
@@ -189,7 +234,7 @@ class StorageManager {
const actualChanges: string[] = [];
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 (newValue !== undefined) {
@@ -292,3 +337,7 @@ class StorageManager {
export const settingsState = StorageManager.getInstance();
export const initializeSettingsState = async () =>
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".
*
* 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;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId;
pendingHighlightThemeId = null;
return id;
}
/**
* 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 {
export async function openThemeStoreWithHighlight(themeId: string): Promise<void> {
pendingHighlightThemeId = themeId;
const existing = document.getElementById("store");
@@ -35,5 +20,6 @@ export function openThemeStoreWithHighlight(themeId: string): void {
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) {
closeExtensionPopup(extensionPopup as HTMLElement);
} else {
renderSettingsIfNeeded();
await renderSettingsIfNeeded();
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.
*
* This function can use either a polling mechanism (via `setTimeout`) or
* a `MutationObserver` (via `eventManager.register`) to detect the element.
* By default, it uses the `eventManager` which is more efficient.
* By default uses direct `querySelector` plus a targeted `MutationObserver`
* on `document.documentElement`. Polling via `setTimeout` is available as a
* fallback when `usePolling` is true.
*
* @param {string} selector The CSS selector for the target element.
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
@@ -24,9 +21,6 @@ export async function waitForElm(
if (usePolling) {
return new Promise((resolve, reject) => {
let iterations = 0;
if (maxIterations) {
iterations = 0;
}
const checkForElement = () => {
const element = document.querySelector(selector);
if (element) {
@@ -36,6 +30,7 @@ export async function waitForElm(
iterations++;
if (iterations >= maxIterations) {
reject(new Error("Element not found"));
return;
}
}
setTimeout(checkForElement, interval);
@@ -43,47 +38,46 @@ export async function waitForElm(
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", checkForElement);
document.addEventListener("DOMContentLoaded", checkForElement, {
once: true,
});
} else {
checkForElement();
}
});
} else {
}
return new Promise((resolve) => {
const registerObserver = () => {
const { unregister } = eventManager.register(
`${selector}`,
{
customCheck: (element) => element.matches(selector),
},
async (element) => {
const tryResolve = (): boolean => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
await delay(1);
unregister(); // Remove the listener once the element is found
},
);
return unregister;
return true;
}
return false;
};
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") {
// DOM is still loading, wait for it to be ready
document.addEventListener("DOMContentLoaded", () => {
unregister = registerObserver();
document.addEventListener("DOMContentLoaded", startObserver, {
once: true,
});
} else {
unregister = registerObserver();
}
const querySelector = () => document.querySelector(selector);
const element = querySelector();
if (element) {
if (unregister) unregister();
resolve(element);
return;
startObserver();
}
});
}
}