From f55caf4e0b8be3dffcaa79e1350b77f835a5e7f2 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 09:45:58 +0100 Subject: [PATCH 1/4] refactor: extract suggestion session helpers --- .../suggestions/SuggestionAcceptedState.ts | 186 ++++++++++ .../SuggestionEntryPredictionContext.ts | 298 ++++++++++++++++ .../suggestions/SuggestionEntrySession.ts | 333 +++--------------- tests/SuggestionAcceptedState.test.ts | 273 ++++++++++++++ .../SuggestionEntryPredictionContext.test.ts | 219 ++++++++++++ 5 files changed, 1020 insertions(+), 289 deletions(-) create mode 100644 src/adapters/chrome/content-script/suggestions/SuggestionAcceptedState.ts create mode 100644 src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts create mode 100644 tests/SuggestionAcceptedState.test.ts create mode 100644 tests/SuggestionEntryPredictionContext.test.ts diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionAcceptedState.ts b/src/adapters/chrome/content-script/suggestions/SuggestionAcceptedState.ts new file mode 100644 index 00000000..17f9abc8 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionAcceptedState.ts @@ -0,0 +1,186 @@ +import type { ContentEditableAdapter } from "./ContentEditableAdapter"; +import { isNativeUndoChord } from "./keyboardShortcuts"; +import { TextTargetAdapter } from "./TextTargetAdapter"; +import type { SuggestionEntry } from "./types"; + +/** + * Narrow contenteditable surface used to validate accepted-suggestion trailing-space state. + */ +export type SuggestionEntrySessionContentEditableAdapter = Pick< + ContentEditableAdapter, + "getActiveBlockElement" | "getBlockContext" +>; + +type AcceptedSuggestionTransientState = Pick< + SuggestionEntry, + | "pendingExtensionEdit" + | "missingTrailingSpace" + | "expectedCursorPos" + | "expectedCursorPosIsBlockLocal" + | "expectedCursorPosBlockElement" + | "expectedCursorPosBlockText" +>; + +type AcceptedSuggestionSpaceState = Pick< + SuggestionEntry, + | "missingTrailingSpace" + | "expectedCursorPos" + | "expectedCursorPosIsBlockLocal" + | "expectedCursorPosBlockElement" + | "expectedCursorPosBlockText" +>; + +/** + * Clears the transient state armed after a suggestion accept. + */ +export function clearAcceptedSuggestionTransientState( + state: AcceptedSuggestionTransientState, +): void { + state.pendingExtensionEdit = null; + state.missingTrailingSpace = false; + state.expectedCursorPos = 0; + state.expectedCursorPosIsBlockLocal = false; + state.expectedCursorPosBlockElement = null; + state.expectedCursorPosBlockText = null; +} + +/** + * Resolves the accepted-suggestion trailing-space expectation from the inserted text and the + * character that follows the accepted edit in the host document. + */ +export function resolveAcceptedSuggestionSpaceState(args: { + entry: Pick; + insertSpaceAfterAutocomplete: boolean; + insertedText: string; + cursorAfter: number; + cursorAfterIsBlockLocal: boolean; +}): AcceptedSuggestionSpaceState { + const trailingCharAfterAccept = resolveTrailingCharAfterAcceptedSuggestion( + args.cursorAfter, + args.cursorAfterIsBlockLocal, + args.entry.pendingExtensionEdit, + ); + const shouldExpectTrailingSpace = + args.insertSpaceAfterAutocomplete && + !/[ \xA0]$/.test(args.insertedText) && + !/[ \xA0]/.test(trailingCharAfterAccept); + + return { + missingTrailingSpace: shouldExpectTrailingSpace, + expectedCursorPos: shouldExpectTrailingSpace ? args.cursorAfter : 0, + expectedCursorPosIsBlockLocal: shouldExpectTrailingSpace && args.cursorAfterIsBlockLocal, + expectedCursorPosBlockElement: + shouldExpectTrailingSpace && args.cursorAfterIsBlockLocal + ? (args.entry.pendingExtensionEdit?.blockElement ?? null) + : null, + expectedCursorPosBlockText: + shouldExpectTrailingSpace && args.cursorAfterIsBlockLocal + ? (args.entry.pendingExtensionEdit?.postEditBlockText ?? null) + : null, + }; +} + +/** + * Returns true for keys that should dismiss the suggestion popup. + */ +export function shouldDismissSuggestionsOnKeydown( + event: Pick, +): boolean { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { + return true; + } + return ["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key); +} + +/** + * Returns true when the pending extension edit should be invalidated by the keydown. + */ +export function shouldInvalidatePendingExtensionEditOnKeydown( + event: Pick< + KeyboardEvent, + "defaultPrevented" | "altKey" | "shiftKey" | "metaKey" | "ctrlKey" | "key" + >, +): boolean { + if (isNativeUndoChord(event)) { + return false; + } + if ( + [ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", + ].includes(event.key) + ) { + return true; + } + return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a"; +} + +/** + * Returns true when the post-accept suppression can be released by a literal whitespace key. + */ +export function shouldReleaseAcceptedSuggestionSuppressionOnKeydown(args: { + event: Pick; + suppressNextSuggestionInputPrediction: boolean; + missingTrailingSpace: boolean; + awaitingHostInputEcho: boolean; +}): boolean { + if ( + !args.suppressNextSuggestionInputPrediction || + !args.missingTrailingSpace || + args.awaitingHostInputEcho + ) { + return false; + } + if (args.event.metaKey || args.event.ctrlKey || args.event.altKey || args.event.isComposing) { + return false; + } + return args.event.key.length === 1 && /^\s$/u.test(args.event.key); +} + +/** + * Clears block-local trailing-space state once the active block/caret no longer matches. + */ +export function syncAcceptedSuggestionTrailingSpaceState( + entry: SuggestionEntry, + contentEditableAdapter: SuggestionEntrySessionContentEditableAdapter, +): void { + if (!entry.missingTrailingSpace || !entry.expectedCursorPosIsBlockLocal) { + return; + } + if (TextTargetAdapter.isTextValue(entry.elem)) { + return; + } + + const activeBlock = contentEditableAdapter.getActiveBlockElement(entry.elem); + const blockContext = contentEditableAdapter.getBlockContext(entry.elem); + if ( + !activeBlock || + !blockContext || + activeBlock !== entry.expectedCursorPosBlockElement || + `${blockContext.beforeCursor}${blockContext.afterCursor}` !== + (entry.expectedCursorPosBlockText ?? "") || + blockContext.beforeCursor.length !== entry.expectedCursorPos + ) { + clearAcceptedSuggestionTransientState(entry); + } +} + +function resolveTrailingCharAfterAcceptedSuggestion( + cursorAfter: number, + cursorAfterIsBlockLocal: boolean, + pendingExtensionEdit: SuggestionEntry["pendingExtensionEdit"], +): string { + if (!pendingExtensionEdit) { + return ""; + } + if (cursorAfterIsBlockLocal && pendingExtensionEdit.blockScoped) { + return (pendingExtensionEdit.postEditBlockText ?? "").charAt(cursorAfter); + } + return pendingExtensionEdit.postEditFingerprint.fullText.charAt(cursorAfter); +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts new file mode 100644 index 00000000..e6b0af72 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts @@ -0,0 +1,298 @@ +import type { PredictionInputAction } from "@core/domain/messageTypes"; +import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; +import type { SuggestionEntry, SuggestionSnapshot } from "./types"; + +type SuggestionEntryCursorContextSource = Pick< + SuggestionEntry, + "elem" | "pendingExtensionEdit" | "hasMultipleBlockDescendants" +>; + +type CursorContextBlock = { + beforeCursor: string; + afterCursor: string; +}; + +interface EditableCursorContextApplyContext extends CursorContextBlock { + useFullTextOffsets: boolean; +} + +interface EditableCursorContext { + beforeCursor: string; + afterCursor: string; + snapshot: SuggestionSnapshot; + applyContext: EditableCursorContextApplyContext | null; + safeForGrammar: boolean; +} + +/** + * Minimal contenteditable adapter surface needed by cursor-context resolution. + * The helper keeps this separate from the full adapter class so callers can + * pass a narrow mock in tests or reuse existing adapters without extra wiring. + */ +export interface SuggestionEntrySessionContentEditableAdapter { + getBlockContext(elem: HTMLElement): CursorContextBlock | null; + getBlockContextBySelection(elem: HTMLElement): CursorContextBlock | null; + isCollapsedSelectionBeforeBlockBoundary(elem: HTMLElement): boolean; + getPreviousBlockTextBySelection(elem: HTMLElement): string | null; +} + +function createEmptySnapshot(): SuggestionSnapshot { + return { + beforeCursor: "", + afterCursor: "", + cursorOffset: 0, + }; +} + +function resolveBlockContext( + entry: SuggestionEntryCursorContextSource, + contentEditableAdapter: SuggestionEntrySessionContentEditableAdapter, +): CursorContextBlock | null { + const blockContext = contentEditableAdapter.getBlockContext(entry.elem as HTMLElement); + return blockContext ?? contentEditableAdapter.getBlockContextBySelection(entry.elem as HTMLElement); +} + +/** + * Resolves the cursor context used for prediction and grammar processing. + * + * The result mirrors SuggestionEntrySession behavior: + * - text-value snapshots pass through unchanged + * - empty contenteditable blocks can fall back to the previous block text + * while preserving full-text offsets for edits + * - typed keys can seed empty contenteditable blocks when the leading + * character matches the typed character + * - pending grammar replacements can seed contenteditable contexts + */ +export function resolveEditableCursorContext( + { + entry, + contentEditableAdapter, + snapshot, + hasMultipleBlockDescendants, + inputAction, + typedKey, + }: { + entry: SuggestionEntryCursorContextSource; + contentEditableAdapter: SuggestionEntrySessionContentEditableAdapter; + snapshot: SuggestionSnapshot | null; + hasMultipleBlockDescendants: boolean; + inputAction?: PredictionInputAction; + typedKey?: string | null; + }, +): EditableCursorContext { + if (TextTargetAdapter.isTextValue(entry.elem)) { + const resolvedSnapshot = snapshot ?? TextTargetAdapter.snapshot(entry.elem as TextTarget); + return { + beforeCursor: resolvedSnapshot.beforeCursor, + afterCursor: resolvedSnapshot.afterCursor, + snapshot: resolvedSnapshot, + applyContext: null, + safeForGrammar: true, + }; + } + + const blockContext = resolveBlockContext(entry, contentEditableAdapter); + if (!blockContext) { + return { + beforeCursor: "", + afterCursor: "", + snapshot: snapshot ?? createEmptySnapshot(), + applyContext: { + beforeCursor: snapshot?.beforeCursor ?? "", + afterCursor: snapshot?.afterCursor ?? "", + useFullTextOffsets: true, + }, + safeForGrammar: false, + }; + } + + const beforeBlockBoundary = contentEditableAdapter.isCollapsedSelectionBeforeBlockBoundary( + entry.elem as HTMLElement, + ); + const useFullTextOffsets = + blockContext.beforeCursor.length === 0 && + blockContext.afterCursor.length === 0 && + beforeBlockBoundary; + if (useFullTextOffsets) { + const previousBlockFallback = hasMultipleBlockDescendants + ? contentEditableAdapter.getPreviousBlockTextBySelection(entry.elem as HTMLElement) + : null; + return { + beforeCursor: previousBlockFallback ?? "", + afterCursor: "", + snapshot: snapshot ?? createEmptySnapshot(), + applyContext: { + beforeCursor: snapshot?.beforeCursor ?? "", + afterCursor: snapshot?.afterCursor ?? "", + useFullTextOffsets: true, + }, + safeForGrammar: false, + }; + } + + const rawAfterCursor = blockContext.afterCursor; + const resolvedAfterCursor = beforeBlockBoundary ? "" : rawAfterCursor; + const resolvedSnapshot = + snapshot ?? + ({ + beforeCursor: blockContext.beforeCursor, + afterCursor: resolvedAfterCursor, + cursorOffset: blockContext.beforeCursor.length, + } satisfies SuggestionSnapshot); + const resolvedLeadingChar = rawAfterCursor.charAt(0); + const snapshotLeadingChar = resolvedSnapshot.afterCursor.charAt(0); + const typedKeyIsLower = + typeof typedKey === "string" && + typedKey.length === 1 && + typedKey !== typedKey.toLocaleUpperCase() && + typedKey === typedKey.toLocaleLowerCase(); + const exactKeyMatch = resolvedLeadingChar === typedKey && snapshotLeadingChar === typedKey; + const capitalizedKeyMatch = + typedKeyIsLower && + resolvedLeadingChar === typedKey.toLocaleUpperCase() && + snapshotLeadingChar === typedKey.toLocaleUpperCase(); + const shouldSeedTypedKey = + inputAction !== "delete" && + blockContext.beforeCursor.length === 0 && + typeof typedKey === "string" && + typedKey.length === 1 && + typedKey.trim().length > 0 && + resolvedLeadingChar.length === 1 && + snapshotLeadingChar.length === 1 && + (exactKeyMatch || capitalizedKeyMatch); + if (shouldSeedTypedKey) { + return { + beforeCursor: resolvedLeadingChar, + afterCursor: rawAfterCursor.slice(resolvedLeadingChar.length), + snapshot: { + beforeCursor: `${resolvedSnapshot.beforeCursor}${resolvedLeadingChar}`, + afterCursor: resolvedSnapshot.afterCursor.slice(snapshotLeadingChar.length), + cursorOffset: resolvedSnapshot.cursorOffset + resolvedLeadingChar.length, + }, + applyContext: { + beforeCursor: resolvedLeadingChar, + afterCursor: rawAfterCursor.slice(resolvedLeadingChar.length), + useFullTextOffsets: false, + }, + safeForGrammar: true, + }; + } + + const pendingEdit = entry.pendingExtensionEdit; + const shouldSeedPendingGrammarEdit = + inputAction !== "delete" && + typeof typedKey !== "string" && + pendingEdit?.source === "grammar" && + blockContext.beforeCursor.length === 0 && + pendingEdit.replaceStart === resolvedSnapshot.beforeCursor.length && + pendingEdit.replacementText.length > 0 && + resolvedAfterCursor.startsWith(pendingEdit.replacementText) && + resolvedSnapshot.afterCursor.startsWith(pendingEdit.replacementText); + const shouldSeedPendingGrammarEditFromMergedSnapshot = + inputAction !== "delete" && + pendingEdit?.source === "grammar" && + pendingEdit.replacementText.length > 0 && + beforeBlockBoundary && + blockContext.beforeCursor === resolvedSnapshot.beforeCursor && + resolvedSnapshot.beforeCursor.endsWith(pendingEdit.replacementText); + if (shouldSeedPendingGrammarEdit || shouldSeedPendingGrammarEditFromMergedSnapshot) { + return { + beforeCursor: pendingEdit.replacementText, + afterCursor: rawAfterCursor.startsWith(pendingEdit.replacementText) + ? rawAfterCursor.slice(pendingEdit.replacementText.length) + : rawAfterCursor.length > 0 + ? rawAfterCursor + : resolvedAfterCursor, + snapshot: { + beforeCursor: `${resolvedSnapshot.beforeCursor}${pendingEdit.replacementText}`, + afterCursor: resolvedSnapshot.afterCursor.slice(pendingEdit.replacementText.length), + cursorOffset: resolvedSnapshot.cursorOffset + pendingEdit.replacementText.length, + }, + applyContext: { + beforeCursor: pendingEdit.replacementText, + afterCursor: rawAfterCursor.slice(pendingEdit.replacementText.length), + useFullTextOffsets: false, + }, + safeForGrammar: true, + }; + } + + const typedKeyLooksMergedIntoPreviousBlock = + inputAction !== "delete" && + hasMultipleBlockDescendants && + beforeBlockBoundary && + typeof typedKey === "string" && + typedKey.length === 1 && + blockContext.beforeCursor === resolvedSnapshot.beforeCursor && + (resolvedSnapshot.beforeCursor.endsWith(typedKey) || + resolvedSnapshot.beforeCursor.endsWith(typedKey.toLocaleUpperCase())); + if (typedKeyLooksMergedIntoPreviousBlock) { + const trailingChar = resolvedSnapshot.beforeCursor.charAt( + resolvedSnapshot.beforeCursor.length - 1, + ); + return { + beforeCursor: trailingChar, + afterCursor: "", + snapshot: resolvedSnapshot, + applyContext: { + beforeCursor: trailingChar, + afterCursor: "", + useFullTextOffsets: false, + }, + safeForGrammar: false, + }; + } + + return { + beforeCursor: blockContext.beforeCursor, + afterCursor: resolvedAfterCursor, + snapshot: resolvedSnapshot, + applyContext: { + beforeCursor: blockContext.beforeCursor, + afterCursor: resolvedAfterCursor, + useFullTextOffsets: false, + }, + safeForGrammar: true, + }; +} + +/** + * Resolves the input action used for prediction and grammar scheduling. + * + * Resolution order matches SuggestionEntrySession: + * - event.inputType when available + * - the last keydown intent + * - before-cursor length comparison against the previous snapshot + */ +export function resolvePredictionInputAction( + event: Event, + currentBeforeCursor: string, + { + lastKeydownKey, + lastBeforeCursorText, + }: { + lastKeydownKey: string | null; + lastBeforeCursorText: string | null; + }, +): PredictionInputAction { + const inputEvent = event as Event & { inputType?: unknown }; + const inputType = typeof inputEvent.inputType === "string" ? inputEvent.inputType : ""; + if (inputType.startsWith("delete")) { + return "delete"; + } + if (inputType.startsWith("insert")) { + return "insert"; + } + if (lastKeydownKey === "Backspace" || lastKeydownKey === "Delete") { + return "delete"; + } + if (typeof lastBeforeCursorText === "string") { + if (currentBeforeCursor.length < lastBeforeCursorText.length) { + return "delete"; + } + if (currentBeforeCursor.length > lastBeforeCursorText.length) { + return "insert"; + } + } + return "other"; +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts index 1f67896e..a1be2281 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts @@ -2,7 +2,18 @@ import { createLogger } from "@core/application/logging/Logger"; import { SUPPORTED_LANGUAGES } from "@core/domain/lang"; import type { PredictionInputAction } from "@core/domain/messageTypes"; import { SPACE_CHARS } from "@core/domain/spacingRules"; -import { isNativeUndoChord } from "./keyboardShortcuts"; +import { + resolveEditableCursorContext as resolveEditableCursorContextHelper, + resolvePredictionInputAction as resolvePredictionInputActionHelper, +} from "./SuggestionEntryPredictionContext"; +import { + clearAcceptedSuggestionTransientState as clearAcceptedSuggestionTransientEntryState, + resolveAcceptedSuggestionSpaceState, + shouldDismissSuggestionsOnKeydown as shouldDismissSuggestionsOnKeydownHelper, + shouldInvalidatePendingExtensionEditOnKeydown as shouldInvalidatePendingExtensionEditOnKeydownHelper, + shouldReleaseAcceptedSuggestionSuppressionOnKeydown as shouldReleaseAcceptedSuggestionSuppressionOnKeydownHelper, + syncAcceptedSuggestionTrailingSpaceState as syncAcceptedSuggestionTrailingSpaceStateHelper, +} from "./SuggestionAcceptedState"; import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils"; import type { @@ -1257,25 +1268,18 @@ export class SuggestionEntrySession { : null, activeBlockTrace: this.buildActiveBlockTrace(), }); - const trailingCharAfterAccept = this.resolveTrailingCharAfterAcceptedSuggestion( + const acceptedSpaceState = resolveAcceptedSuggestionSpaceState({ + entry: this.entry, + insertSpaceAfterAutocomplete: this.insertSpaceAfterAutocomplete, + insertedText, cursorAfter, cursorAfterIsBlockLocal, - ); - const shouldExpectTrailingSpace = - this.insertSpaceAfterAutocomplete && - !/[ \xA0]$/.test(insertedText) && - !/[ \xA0]/.test(trailingCharAfterAccept); - this.entry.missingTrailingSpace = shouldExpectTrailingSpace; - this.entry.expectedCursorPos = shouldExpectTrailingSpace ? cursorAfter : 0; - this.entry.expectedCursorPosIsBlockLocal = shouldExpectTrailingSpace && cursorAfterIsBlockLocal; - this.entry.expectedCursorPosBlockElement = - shouldExpectTrailingSpace && cursorAfterIsBlockLocal - ? (this.entry.pendingExtensionEdit?.blockElement ?? null) - : null; - this.entry.expectedCursorPosBlockText = - shouldExpectTrailingSpace && cursorAfterIsBlockLocal - ? (this.entry.pendingExtensionEdit?.postEditBlockText ?? null) - : null; + }); + this.entry.missingTrailingSpace = acceptedSpaceState.missingTrailingSpace; + this.entry.expectedCursorPos = acceptedSpaceState.expectedCursorPos; + this.entry.expectedCursorPosIsBlockLocal = acceptedSpaceState.expectedCursorPosIsBlockLocal; + this.entry.expectedCursorPosBlockElement = acceptedSpaceState.expectedCursorPosBlockElement; + this.entry.expectedCursorPosBlockText = acceptedSpaceState.expectedCursorPosBlockText; this.recordSuggestionAccepted({ triggerText, insertedText, @@ -1283,74 +1287,30 @@ export class SuggestionEntrySession { }); } - private resolveTrailingCharAfterAcceptedSuggestion( - cursorAfter: number, - cursorAfterIsBlockLocal: boolean, - ): string { - const pendingEdit = this.entry.pendingExtensionEdit; - if (!pendingEdit) { - return ""; - } - if (cursorAfterIsBlockLocal && pendingEdit.blockScoped) { - return (pendingEdit.postEditBlockText ?? "").charAt(cursorAfter); - } - return pendingEdit.postEditFingerprint.fullText.charAt(cursorAfter); - } - private clearPendingExtensionEdit(): void { this.entry.pendingExtensionEdit = null; } private clearAcceptedSuggestionTransientState(): void { this.lastAcceptedSuggestion = null; - this.clearPendingExtensionEdit(); - this.entry.missingTrailingSpace = false; - this.entry.expectedCursorPos = 0; - this.entry.expectedCursorPosIsBlockLocal = false; - this.entry.expectedCursorPosBlockElement = null; - this.entry.expectedCursorPosBlockText = null; + clearAcceptedSuggestionTransientEntryState(this.entry); } private shouldInvalidatePendingExtensionEditOnKeydown(event: KeyboardEvent): boolean { - if (isNativeUndoChord(event)) { - return false; - } - if ( - [ - "ArrowLeft", - "ArrowRight", - "ArrowUp", - "ArrowDown", - "Home", - "End", - "PageUp", - "PageDown", - ].includes(event.key) - ) { - return true; - } - return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a"; + return shouldInvalidatePendingExtensionEditOnKeydownHelper(event); } private shouldDismissSuggestionsOnKeydown(event: KeyboardEvent): boolean { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { - return true; - } - return ["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key); + return shouldDismissSuggestionsOnKeydownHelper(event); } private shouldReleaseAcceptedSuggestionSuppressionOnKeydown(event: KeyboardEvent): boolean { - if ( - !this.entry.suppressNextSuggestionInputPrediction || - !this.entry.missingTrailingSpace || - this.entry.pendingExtensionEdit?.awaitingHostInputEcho === true - ) { - return false; - } - if (event.metaKey || event.ctrlKey || event.altKey || event.isComposing) { - return false; - } - return event.key.length === 1 && /^\s$/u.test(event.key); + return shouldReleaseAcceptedSuggestionSuppressionOnKeydownHelper({ + event, + suppressNextSuggestionInputPrediction: this.entry.suppressNextSuggestionInputPrediction, + missingTrailingSpace: this.entry.missingTrailingSpace, + awaitingHostInputEcho: this.entry.pendingExtensionEdit?.awaitingHostInputEcho === true, + }); } private shouldScheduleInsertFallback(event: KeyboardEvent): boolean { @@ -1389,27 +1349,10 @@ export class SuggestionEntrySession { } private resolveInputAction(event: Event, currentBeforeCursor: string): PredictionInputAction { - const inputEvent = event as Event & { inputType?: unknown }; - const inputType = typeof inputEvent.inputType === "string" ? inputEvent.inputType : ""; - if (inputType.startsWith("delete")) { - return "delete"; - } - if (inputType.startsWith("insert")) { - return "insert"; - } - if (this.entry.lastKeydownKey === "Backspace" || this.entry.lastKeydownKey === "Delete") { - return "delete"; - } - const previousBeforeCursor = this.entry.lastBeforeCursorText; - if (typeof previousBeforeCursor === "string") { - if (currentBeforeCursor.length < previousBeforeCursor.length) { - return "delete"; - } - if (currentBeforeCursor.length > previousBeforeCursor.length) { - return "insert"; - } - } - return "other"; + return resolvePredictionInputActionHelper(event, currentBeforeCursor, { + lastKeydownKey: this.entry.lastKeydownKey, + lastBeforeCursorText: this.entry.lastBeforeCursorText, + }); } private resolveLocalGrammarTriggers( @@ -1496,28 +1439,7 @@ export class SuggestionEntrySession { } private syncAcceptedSuggestionTrailingSpaceState(): void { - if (!this.entry.missingTrailingSpace || !this.entry.expectedCursorPosIsBlockLocal) { - return; - } - if (TextTargetAdapter.isTextValue(this.entry.elem)) { - return; - } - const activeBlock = this.contentEditableAdapter.getActiveBlockElement( - this.entry.elem as HTMLElement, - ); - const blockContext = this.contentEditableAdapter.getBlockContext( - this.entry.elem as HTMLElement, - ); - if ( - !activeBlock || - !blockContext || - activeBlock !== this.entry.expectedCursorPosBlockElement || - `${blockContext.beforeCursor}${blockContext.afterCursor}` !== - (this.entry.expectedCursorPosBlockText ?? "") || - blockContext.beforeCursor.length !== this.entry.expectedCursorPos - ) { - this.clearAcceptedSuggestionTransientState(); - } + syncAcceptedSuggestionTrailingSpaceStateHelper(this.entry, this.contentEditableAdapter); } private shouldDeferContentEditableInputToFallback(context: { @@ -1564,183 +1486,16 @@ export class SuggestionEntrySession { applyContext: { beforeCursor: string; afterCursor: string; useFullTextOffsets: boolean } | null; safeForGrammar: boolean; } { - if (TextTargetAdapter.isTextValue(entry.elem)) { - const resolvedSnapshot = snapshot ?? TextTargetAdapter.snapshot(entry.elem as TextTarget); - return { - beforeCursor: resolvedSnapshot.beforeCursor, - afterCursor: resolvedSnapshot.afterCursor, - snapshot: resolvedSnapshot, - applyContext: null, - safeForGrammar: true, - }; - } - let blockContext = this.contentEditableAdapter.getBlockContext(entry.elem); - if (!blockContext) { - blockContext = this.contentEditableAdapter.getBlockContextBySelection(entry.elem); - } - if (!blockContext) { - return { - beforeCursor: "", - afterCursor: "", - snapshot: - snapshot ?? - ({ beforeCursor: "", afterCursor: "", cursorOffset: 0 } satisfies SuggestionSnapshot), - applyContext: { - beforeCursor: snapshot?.beforeCursor ?? "", - afterCursor: snapshot?.afterCursor ?? "", - useFullTextOffsets: true, - }, - safeForGrammar: false, - }; - } - const beforeBlockBoundary = this.contentEditableAdapter.isCollapsedSelectionBeforeBlockBoundary( - entry.elem, - ); const resolvedHasMultipleBlockDescendants = hasMultipleBlockDescendants ?? this.resolveHasMultipleBlockDescendants(); - const useFullTextOffsets = - blockContext.beforeCursor.length === 0 && - blockContext.afterCursor.length === 0 && - beforeBlockBoundary; - if (useFullTextOffsets) { - const previousBlockFallback = resolvedHasMultipleBlockDescendants - ? this.contentEditableAdapter.getPreviousBlockTextBySelection(entry.elem) - : null; - return { - beforeCursor: previousBlockFallback ?? "", - afterCursor: "", - snapshot: - snapshot ?? - ({ beforeCursor: "", afterCursor: "", cursorOffset: 0 } satisfies SuggestionSnapshot), - applyContext: { - beforeCursor: snapshot?.beforeCursor ?? "", - afterCursor: snapshot?.afterCursor ?? "", - useFullTextOffsets: true, - }, - safeForGrammar: false, - }; - } - const rawAfterCursor = blockContext.afterCursor; - const resolvedAfterCursor = beforeBlockBoundary ? "" : rawAfterCursor; - const resolvedSnapshot = - snapshot ?? - ({ - beforeCursor: blockContext.beforeCursor, - afterCursor: resolvedAfterCursor, - cursorOffset: blockContext.beforeCursor.length, - } satisfies SuggestionSnapshot); - const resolvedLeadingChar = rawAfterCursor.charAt(0); - const snapshotLeadingChar = resolvedSnapshot.afterCursor.charAt(0); - const typedKeyIsLower = - typeof typedKey === "string" && - typedKey.length === 1 && - typedKey !== typedKey.toLocaleUpperCase() && - typedKey === typedKey.toLocaleLowerCase(); - const exactKeyMatch = resolvedLeadingChar === typedKey && snapshotLeadingChar === typedKey; - const capitalizedKeyMatch = - typedKeyIsLower && - resolvedLeadingChar === typedKey.toLocaleUpperCase() && - snapshotLeadingChar === typedKey.toLocaleUpperCase(); - const shouldSeedTypedKey = - inputAction !== "delete" && - blockContext.beforeCursor.length === 0 && - typeof typedKey === "string" && - typedKey.length === 1 && - typedKey.trim().length > 0 && - resolvedLeadingChar.length === 1 && - snapshotLeadingChar.length === 1 && - (exactKeyMatch || capitalizedKeyMatch); - if (shouldSeedTypedKey) { - return { - beforeCursor: resolvedLeadingChar, - afterCursor: rawAfterCursor.slice(resolvedLeadingChar.length), - snapshot: { - beforeCursor: `${resolvedSnapshot.beforeCursor}${resolvedLeadingChar}`, - afterCursor: resolvedSnapshot.afterCursor.slice(snapshotLeadingChar.length), - cursorOffset: resolvedSnapshot.cursorOffset + resolvedLeadingChar.length, - }, - applyContext: { - beforeCursor: resolvedLeadingChar, - afterCursor: rawAfterCursor.slice(resolvedLeadingChar.length), - useFullTextOffsets: false, - }, - safeForGrammar: true, - }; - } - const pendingEdit = entry.pendingExtensionEdit; - const shouldSeedPendingGrammarEdit = - inputAction !== "delete" && - typeof typedKey !== "string" && - pendingEdit?.source === "grammar" && - blockContext.beforeCursor.length === 0 && - pendingEdit.replaceStart === resolvedSnapshot.beforeCursor.length && - pendingEdit.replacementText.length > 0 && - resolvedAfterCursor.startsWith(pendingEdit.replacementText) && - resolvedSnapshot.afterCursor.startsWith(pendingEdit.replacementText); - const shouldSeedPendingGrammarEditFromMergedSnapshot = - inputAction !== "delete" && - pendingEdit?.source === "grammar" && - pendingEdit.replacementText.length > 0 && - beforeBlockBoundary && - blockContext.beforeCursor === resolvedSnapshot.beforeCursor && - resolvedSnapshot.beforeCursor.endsWith(pendingEdit.replacementText); - if (shouldSeedPendingGrammarEdit || shouldSeedPendingGrammarEditFromMergedSnapshot) { - return { - beforeCursor: pendingEdit.replacementText, - afterCursor: rawAfterCursor.startsWith(pendingEdit.replacementText) - ? rawAfterCursor.slice(pendingEdit.replacementText.length) - : rawAfterCursor.length > 0 - ? rawAfterCursor - : resolvedAfterCursor, - snapshot: { - beforeCursor: `${resolvedSnapshot.beforeCursor}${pendingEdit.replacementText}`, - afterCursor: resolvedSnapshot.afterCursor.slice(pendingEdit.replacementText.length), - cursorOffset: resolvedSnapshot.cursorOffset + pendingEdit.replacementText.length, - }, - applyContext: { - beforeCursor: pendingEdit.replacementText, - afterCursor: rawAfterCursor.slice(pendingEdit.replacementText.length), - useFullTextOffsets: false, - }, - safeForGrammar: true, - }; - } - const typedKeyLooksMergedIntoPreviousBlock = - inputAction !== "delete" && - resolvedHasMultipleBlockDescendants && - beforeBlockBoundary && - typeof typedKey === "string" && - typedKey.length === 1 && - blockContext.beforeCursor === resolvedSnapshot.beforeCursor && - (resolvedSnapshot.beforeCursor.endsWith(typedKey) || - resolvedSnapshot.beforeCursor.endsWith(typedKey.toLocaleUpperCase())); - if (typedKeyLooksMergedIntoPreviousBlock) { - const trailingChar = resolvedSnapshot.beforeCursor.charAt( - resolvedSnapshot.beforeCursor.length - 1, - ); - return { - beforeCursor: trailingChar, - afterCursor: "", - snapshot: resolvedSnapshot, - applyContext: { - beforeCursor: trailingChar, - afterCursor: "", - useFullTextOffsets: false, - }, - safeForGrammar: false, - }; - } - return { - beforeCursor: blockContext.beforeCursor, - afterCursor: resolvedAfterCursor, - snapshot: resolvedSnapshot, - applyContext: { - beforeCursor: blockContext.beforeCursor, - afterCursor: resolvedAfterCursor, - useFullTextOffsets: false, - }, - safeForGrammar: true, - }; + return resolveEditableCursorContextHelper({ + entry, + snapshot, + contentEditableAdapter: this.contentEditableAdapter, + hasMultipleBlockDescendants: resolvedHasMultipleBlockDescendants, + inputAction, + typedKey, + }); } private scheduleIdleGrammar(): void { diff --git a/tests/SuggestionAcceptedState.test.ts b/tests/SuggestionAcceptedState.test.ts new file mode 100644 index 00000000..092fb0b9 --- /dev/null +++ b/tests/SuggestionAcceptedState.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + clearAcceptedSuggestionTransientState, + resolveAcceptedSuggestionSpaceState, + shouldDismissSuggestionsOnKeydown, + shouldInvalidatePendingExtensionEditOnKeydown, + shouldReleaseAcceptedSuggestionSuppressionOnKeydown, + syncAcceptedSuggestionTrailingSpaceState, + type SuggestionEntrySessionContentEditableAdapter, +} from "../src/adapters/chrome/content-script/suggestions/SuggestionAcceptedState"; +import type { + ExtensionEditSnapshot, + SuggestionEntry, +} from "../src/adapters/chrome/content-script/suggestions/types"; + +function createEntry( + elem: SuggestionEntry["elem"], +): SuggestionEntry & { lastAcceptedSuggestion: string | null } { + return { + id: 1, + elem, + inputEventTarget: null, + menu: document.createElement("div"), + list: document.createElement("ul"), + requestId: 0, + suggestions: [], + selectedIndex: 0, + menuHeader: null, + latestMentionText: "", + latestMentionStart: 0, + visibleSuggestionBeforeCursorText: null, + visibleSuggestionFullText: null, + inlineSuggestion: null, + pendingInlineAccept: false, + missingTrailingSpace: false, + expectedCursorPos: 0, + expectedCursorPosIsBlockLocal: false, + expectedCursorPosBlockElement: null, + expectedCursorPosBlockText: null, + pendingExtensionEdit: null, + suppressNextSuggestionInputPrediction: false, + lastAcceptedSuggestion: null, + } as SuggestionEntry & { lastAcceptedSuggestion: string | null }; +} + +function createPendingEdit(overrides: Partial = {}) { + return { + replaceStart: 0, + originalText: "", + replacementText: "", + cursorBefore: 0, + cursorAfter: 0, + postEditFingerprint: { + fullText: "", + cursorOffset: 0, + selectionCollapsed: true, + }, + source: "suggestion" as const, + ...overrides, + }; +} + +function createContentEditableAdapter( + overrides: Partial = {}, +): SuggestionEntrySessionContentEditableAdapter { + return { + getActiveBlockElement: () => null, + getBlockContext: () => null, + ...overrides, + }; +} + +describe("SuggestionAcceptedState", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + describe("transient state", () => { + test("tracks a missing trailing space from block-local accepted text", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + const block = document.createElement("p"); + editable.appendChild(block); + const entry = createEntry(editable); + entry.pendingExtensionEdit = createPendingEdit({ + blockScoped: true, + blockElement: block, + postEditBlockText: "hello!", + postEditFingerprint: { + fullText: "hello!", + cursorOffset: 5, + selectionCollapsed: true, + }, + }); + + const state = resolveAcceptedSuggestionSpaceState({ + entry, + insertSpaceAfterAutocomplete: true, + insertedText: "hello", + cursorAfter: 5, + cursorAfterIsBlockLocal: true, + }); + + expect(state).toEqual({ + missingTrailingSpace: true, + expectedCursorPos: 5, + expectedCursorPosIsBlockLocal: true, + expectedCursorPosBlockElement: block, + expectedCursorPosBlockText: "hello!", + }); + }); + + test("does not expect another space when the following character is already whitespace", () => { + const input = document.createElement("input"); + const entry = createEntry(input); + entry.pendingExtensionEdit = createPendingEdit({ + postEditFingerprint: { + fullText: "hello world", + cursorOffset: 5, + selectionCollapsed: true, + }, + }); + + const state = resolveAcceptedSuggestionSpaceState({ + entry, + insertSpaceAfterAutocomplete: true, + insertedText: "hello", + cursorAfter: 5, + cursorAfterIsBlockLocal: false, + }); + + expect(state).toEqual({ + missingTrailingSpace: false, + expectedCursorPos: 0, + expectedCursorPosIsBlockLocal: false, + expectedCursorPosBlockElement: null, + expectedCursorPosBlockText: null, + }); + }); + + test("clears block-local trailing-space state when the caret leaves the expected block", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + const expectedBlock = document.createElement("p"); + editable.appendChild(expectedBlock); + const entry = createEntry(editable); + entry.missingTrailingSpace = true; + entry.expectedCursorPos = 5; + entry.expectedCursorPosIsBlockLocal = true; + entry.expectedCursorPosBlockElement = expectedBlock; + entry.expectedCursorPosBlockText = "hello!"; + + syncAcceptedSuggestionTrailingSpaceState( + entry, + createContentEditableAdapter({ + getActiveBlockElement: () => document.createElement("p"), + getBlockContext: () => ({ + beforeCursor: "hello", + afterCursor: "!", + }), + }), + ); + + expect(entry.missingTrailingSpace).toBe(false); + expect(entry.expectedCursorPos).toBe(0); + expect(entry.expectedCursorPosBlockElement).toBeNull(); + expect(entry.expectedCursorPosBlockText).toBeNull(); + }); + + test("resets all accepted-suggestion transient fields in one place", () => { + const input = document.createElement("input"); + const entry = createEntry(input); + entry.pendingExtensionEdit = createPendingEdit(); + entry.missingTrailingSpace = true; + entry.expectedCursorPos = 4; + entry.expectedCursorPosIsBlockLocal = true; + entry.expectedCursorPosBlockElement = document.createElement("div"); + entry.expectedCursorPosBlockText = "text"; + + clearAcceptedSuggestionTransientState(entry); + + expect(entry.pendingExtensionEdit).toBeNull(); + expect(entry.missingTrailingSpace).toBe(false); + expect(entry.expectedCursorPos).toBe(0); + expect(entry.expectedCursorPosIsBlockLocal).toBe(false); + expect(entry.expectedCursorPosBlockElement).toBeNull(); + expect(entry.expectedCursorPosBlockText).toBeNull(); + }); + }); + + describe("keyboard policy", () => { + test("releases the accepted-suggestion suppression only for literal whitespace input", () => { + const event: Pick< + KeyboardEvent, + "key" | "metaKey" | "ctrlKey" | "altKey" | "isComposing" + > = { + key: " ", + metaKey: false, + ctrlKey: false, + altKey: false, + isComposing: false, + }; + + expect( + shouldReleaseAcceptedSuggestionSuppressionOnKeydown({ + suppressNextSuggestionInputPrediction: true, + missingTrailingSpace: true, + awaitingHostInputEcho: false, + event, + }), + ).toBe(true); + expect( + shouldReleaseAcceptedSuggestionSuppressionOnKeydown({ + suppressNextSuggestionInputPrediction: true, + missingTrailingSpace: true, + awaitingHostInputEcho: false, + event: { + ...event, + ctrlKey: true, + }, + }), + ).toBe(false); + expect( + shouldReleaseAcceptedSuggestionSuppressionOnKeydown({ + suppressNextSuggestionInputPrediction: true, + missingTrailingSpace: true, + awaitingHostInputEcho: true, + event, + }), + ).toBe(false); + }); + + test("keeps native undo chords but invalidates navigation-based pending edits", () => { + const undoChord: Pick< + KeyboardEvent, + "defaultPrevented" | "altKey" | "shiftKey" | "metaKey" | "ctrlKey" | "key" + > = { + key: "z", + metaKey: false, + ctrlKey: true, + altKey: false, + shiftKey: false, + defaultPrevented: false, + }; + const arrowLeft: Pick< + KeyboardEvent, + "defaultPrevented" | "altKey" | "shiftKey" | "metaKey" | "ctrlKey" | "key" + > = { + key: "ArrowLeft", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + defaultPrevented: false, + }; + const home: Pick = { + key: "Home", + metaKey: false, + ctrlKey: false, + }; + + expect( + shouldInvalidatePendingExtensionEditOnKeydown(undoChord), + ).toBe(false); + expect( + shouldInvalidatePendingExtensionEditOnKeydown(arrowLeft), + ).toBe(true); + expect(shouldDismissSuggestionsOnKeydown(home)).toBe(true); + }); + }); +}); diff --git a/tests/SuggestionEntryPredictionContext.test.ts b/tests/SuggestionEntryPredictionContext.test.ts new file mode 100644 index 00000000..3c171ab7 --- /dev/null +++ b/tests/SuggestionEntryPredictionContext.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import { + resolveEditableCursorContext, + resolvePredictionInputAction, + type SuggestionEntrySessionContentEditableAdapter, +} from "../src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext"; +import { createSuggestionEntry } from "./suggestionTestUtils"; +import type { + ExtensionEditSnapshot, + SuggestionSnapshot, +} from "../src/adapters/chrome/content-script/suggestions/types"; + +const defaultContentEditableAdapter: SuggestionEntrySessionContentEditableAdapter = { + getBlockContext: () => null, + getBlockContextBySelection: () => null, + isCollapsedSelectionBeforeBlockBoundary: () => false, + getPreviousBlockTextBySelection: () => null, +}; + +function createGrammarPendingEdit(overrides: Partial = {}) { + return { + replaceStart: 0, + originalText: "", + replacementText: "", + cursorBefore: 0, + cursorAfter: 0, + postEditFingerprint: { + fullText: "", + cursorOffset: 0, + selectionCollapsed: true, + }, + source: "grammar" as const, + ...overrides, + }; +} + +function createContentEditableElement(): HTMLDivElement { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + return editable; +} + +function createContentEditableAdapter( + overrides: Partial = {}, +): SuggestionEntrySessionContentEditableAdapter { + return { + ...defaultContentEditableAdapter, + ...overrides, + }; +} + +describe("resolveEditableCursorContext", () => { + test("uses the provided text-value snapshot without contenteditable lookups", () => { + const entry = createSuggestionEntry({ elem: document.createElement("input") }); + const snapshot: SuggestionSnapshot = { + beforeCursor: "hello", + afterCursor: "", + cursorOffset: 5, + }; + + const context = resolveEditableCursorContext({ + entry, + snapshot, + contentEditableAdapter: createContentEditableAdapter(), + hasMultipleBlockDescendants: false, + }); + + expect(context).toEqual({ + beforeCursor: "hello", + afterCursor: "", + snapshot, + applyContext: null, + safeForGrammar: true, + }); + }); + + test("falls back to previous block text when caret sits on an empty block boundary", () => { + const entry = createSuggestionEntry({ elem: createContentEditableElement() }); + const snapshot: SuggestionSnapshot = { + beforeCursor: "Alpha", + afterCursor: "", + cursorOffset: 5, + }; + + const context = resolveEditableCursorContext({ + entry, + snapshot, + contentEditableAdapter: createContentEditableAdapter({ + getBlockContext: () => ({ + beforeCursor: "", + afterCursor: "", + }), + isCollapsedSelectionBeforeBlockBoundary: () => true, + getPreviousBlockTextBySelection: () => "Alpha", + }), + hasMultipleBlockDescendants: true, + }); + + expect(context).toEqual({ + beforeCursor: "Alpha", + afterCursor: "", + snapshot, + applyContext: { + beforeCursor: "Alpha", + afterCursor: "", + useFullTextOffsets: true, + }, + safeForGrammar: false, + }); + }); + + test("seeds a typed key into an empty block when the host reports it only after the caret", () => { + const entry = createSuggestionEntry({ elem: createContentEditableElement() }); + const snapshot: SuggestionSnapshot = { + beforeCursor: "", + afterCursor: "A", + cursorOffset: 0, + }; + + const context = resolveEditableCursorContext({ + entry, + snapshot, + contentEditableAdapter: createContentEditableAdapter({ + getBlockContext: () => ({ + beforeCursor: "", + afterCursor: "A", + }), + }), + hasMultipleBlockDescendants: false, + inputAction: "insert", + typedKey: "a", + }); + + expect(context).toEqual({ + beforeCursor: "A", + afterCursor: "", + snapshot: { + beforeCursor: "A", + afterCursor: "", + cursorOffset: 1, + }, + applyContext: { + beforeCursor: "A", + afterCursor: "", + useFullTextOffsets: false, + }, + safeForGrammar: true, + }); + }); + + test("seeds a pending grammar replacement into block-local prediction context", () => { + const entry = createSuggestionEntry({ elem: createContentEditableElement() }); + entry.pendingExtensionEdit = createGrammarPendingEdit({ + replaceStart: 6, + replacementText: "world", + }); + const snapshot: SuggestionSnapshot = { + beforeCursor: "hello ", + afterCursor: "world!", + cursorOffset: 6, + }; + + const context = resolveEditableCursorContext({ + entry, + snapshot, + contentEditableAdapter: createContentEditableAdapter({ + getBlockContext: () => ({ + beforeCursor: "", + afterCursor: "world!", + }), + }), + hasMultipleBlockDescendants: false, + inputAction: "insert", + }); + + expect(context).toEqual({ + beforeCursor: "world", + afterCursor: "!", + snapshot: { + beforeCursor: "hello world", + afterCursor: "!", + cursorOffset: 11, + }, + applyContext: { + beforeCursor: "world", + afterCursor: "!", + useFullTextOffsets: false, + }, + safeForGrammar: true, + }); + }); +}); + +describe("resolvePredictionInputAction", () => { + test("prefers event inputType when resolving the prediction action", () => { + const inputEvent = new Event("input") as Event & { inputType?: string }; + inputEvent.inputType = "deleteContentBackward"; + const action = resolvePredictionInputAction( + inputEvent, + "hell", + { + lastKeydownKey: null, + lastBeforeCursorText: "hello", + }, + ); + + expect(action).toBe("delete"); + }); + + test("falls back to before-cursor length changes when inputType is unavailable", () => { + const action = resolvePredictionInputAction(new Event("input"), "hello!", { + lastKeydownKey: null, + lastBeforeCursorText: "hello", + }); + + expect(action).toBe("insert"); + }); +}); From 12f81177a164e69156beb0d62e26b49c2a26e8b0 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 10:14:42 +0100 Subject: [PATCH 2/4] refactor: unify src module boundaries --- .../background/BackgroundServiceWorker.ts | 2 +- .../chrome/background/CapitalizationHelper.ts | 11 +- .../chrome/background/LanguageDetector.ts | 103 ++++-- src/adapters/chrome/background/Migration.ts | 69 ++-- .../background/PredictionInputProcessor.ts | 67 ++-- .../background/PredictionOrchestrator.ts | 33 +- .../chrome/background/PresageEngine.ts | 36 +- .../chrome/background/PresageHandler.ts | 55 +-- .../chrome/background/TabMessenger.ts | 97 +++-- .../chrome/background/TemplateExpander.ts | 103 +++--- .../chrome/background/TextExpansionManager.ts | 41 ++- .../background/UserDictionaryManager.ts | 26 +- .../chrome/background/WebLLMPredictor.ts | 28 +- .../bootstrap/BackgroundBootstrap.ts | 23 +- .../background/config/ConfigAssembler.ts | 39 +- .../background/config/runtimeSettings.ts | 58 +-- .../chrome/background/router/CommandRouter.ts | 2 +- .../chrome/background/router/MessageRouter.ts | 2 +- .../testing/RuntimeTestHooks.noop.ts | 7 +- .../background/testing/RuntimeTestHooks.ts | 42 +-- .../background/webllm/CandidateRanker.ts | 57 +-- .../webllm/EngineLifecycleService.ts | 94 ++--- .../webllm/GenerationCoordinator.ts | 58 +-- .../background/webllm/PredictionCache.ts | 10 +- .../chrome/background/webllm/PromptBuilder.ts | 41 ++- .../background/webllm/ResponseParser.ts | 57 ++- .../content-script/ContentMessageHandler.ts | 155 ++++---- .../ContentRuntimeController.ts | 84 ++--- .../chrome/content-script/DomObserver.ts | 14 +- .../content-script/HostChangeWatcher.ts | 48 +-- .../chrome/content-script/MutationPipeline.ts | 20 +- .../content-script/MutationScheduler.ts | 51 ++- .../content-script/ShadowRootInterceptor.ts | 9 +- .../chrome/content-script/ThemeApplicator.ts | 190 ++++------ .../chrome/content-script/content_script.ts | 65 ++-- .../chrome/content-script/predictionTrace.ts | 3 +- .../suggestions/ContentEditableAdapter.ts | 107 +++--- .../suggestions/SuggestionManagerRuntime.ts | 44 +-- .../SuggestionPositioningService.ts | 14 +- .../suggestions/SuggestionTextEditService.ts | 16 +- src/core/application/domain-utils.ts | 39 +- src/core/application/logging/Logger.ts | 21 +- .../ProductivityStatsService.ts | 101 +++--- .../productivityStats/StatsRepository.ts | 13 +- .../repositories/SettingsRepositoryBase.ts | 2 +- .../settings/SettingsMigrationV3.ts | 84 ++--- .../settings/SettingsMigrationV4.ts | 71 +--- .../settings/SettingsMigrationV5.ts | 72 +--- .../settings/SettingsMigrationV6.ts | 70 +--- .../settings/SettingsMigrationV7.ts | 40 +-- .../application/settings/settingsAccess.ts | 81 +++++ src/core/domain/autoLanguageDetection.ts | 17 +- src/core/domain/contracts/settings.ts | 18 +- .../domain/grammar/GrammarEditSequencing.ts | 4 - src/core/domain/grammar/GrammarRuleEngine.ts | 115 +++--- .../CapitalizeAfterLineBreakRule.ts | 3 +- .../CapitalizeFirstLetterRule.ts | 4 +- .../CapitalizeSentenceStartRule.ts | 3 +- .../EnglishAlotCorrectionRule.ts | 31 +- .../EnglishContractionNormalizationRule.ts | 17 +- .../EnglishModalOfCorrectionRule.ts | 26 +- .../EnglishPronounICapitalizationRule.ts | 30 +- ...nglishPronounVerbWhitelistAgreementRule.ts | 26 +- .../EnglishTheirThereBeVerbRule.ts | 26 +- .../EnglishTypoWhitelistCorrectionRule.ts | 22 +- .../EnglishYourWelcomeCorrectionRule.ts | 26 +- .../grammar/implementations/SpacingRule.ts | 333 ++---------------- .../helpers/EnglishRuleShared.ts | 44 ++- .../helpers/GenericRuleShared.ts | 18 +- .../helpers/SpacingRuleShared.ts | 7 +- src/core/domain/grammar/ruleCatalog.ts | 31 +- src/core/domain/grammar/ruleFactory.ts | 27 +- src/core/domain/messageTypes.d.ts | 34 +- .../productivityStats/DonationPromptPolicy.ts | 2 + .../domain/productivityStats/RecapPolicy.ts | 23 +- .../productivityStats/StatsAggregator.ts | 84 +++-- .../productivityStats/StatsSanitizer.ts | 62 ++-- src/core/domain/siteProfiles.ts | 15 +- src/ui/options/AboutWorkspacePanel.ts | 105 +++--- src/ui/options/AppearanceStudio.ts | 35 +- src/ui/options/DataDiagnosticsPanel.ts | 4 +- src/ui/options/EssentialsWorkspacePanel.ts | 4 +- src/ui/options/GrammarWorkspacePanel.ts | 5 +- src/ui/options/LanguageSettingsPanel.ts | 32 +- src/ui/options/ObservabilityWorkspacePanel.ts | 4 +- src/ui/options/SiteManagementPanel.ts | 48 +-- src/ui/options/TextAssetsPanel.ts | 48 ++- src/ui/options/fluenttyperI18n.ts | 39 +- src/ui/options/settings.ts | 301 +++++++++------- src/ui/options/settingsManifest.ts | 159 +++++---- src/ui/options/siteProfiles.ts | 25 +- src/ui/options/textExpander.ts | 40 ++- src/ui/options/workspacePanelUtils.ts | 50 +++ src/ui/popup/popup.ts | 196 ++++++----- src/ui/settings-engine/SettingsEngine.ts | 21 +- .../settings-engine/controls/ButtonControl.ts | 31 +- .../controls/CheckboxControl.ts | 35 +- .../settings-engine/controls/FieldControl.ts | 56 +++ .../controls/ListBoxControl.ts | 54 ++- .../controls/ListBoxMultiSelectControl.ts | 26 +- .../controls/ModalButtonControl.ts | 34 +- .../settings-engine/controls/RadioControl.ts | 28 +- .../controls/RuleToggleCardsControl.ts | 4 - .../settings-engine/controls/SelectControl.ts | 37 +- .../settings-engine/controls/SliderControl.ts | 28 +- .../settings-engine/controls/TextControl.ts | 38 +- .../controls/TextareaControl.ts | 24 +- .../controls/ValueOnlyControl.ts | 9 +- src/ui/settings-engine/i18n/I18n.ts | 14 +- src/ui/settings-engine/index.ts | 2 - src/ui/settings-engine/layout/GroupManager.ts | 2 +- src/ui/settings-engine/layout/TabManager.ts | 13 +- src/ui/settings-engine/types.ts | 2 - 113 files changed, 2427 insertions(+), 2749 deletions(-) create mode 100644 src/core/application/settings/settingsAccess.ts diff --git a/src/adapters/chrome/background/BackgroundServiceWorker.ts b/src/adapters/chrome/background/BackgroundServiceWorker.ts index 18c01bee..c6e751c8 100644 --- a/src/adapters/chrome/background/BackgroundServiceWorker.ts +++ b/src/adapters/chrome/background/BackgroundServiceWorker.ts @@ -120,7 +120,7 @@ export class BackgroundServiceWorker { "background.prediction.completed", `${predictions.length} predictions`, ); - if (!Array.isArray(predictions) || predictions.length === 0) { + if (predictions.length === 0) { this.predictionManager.recordTraceTimelineEvent( traceMeta, "background.response.empty", diff --git a/src/adapters/chrome/background/CapitalizationHelper.ts b/src/adapters/chrome/background/CapitalizationHelper.ts index 90836e6f..effd73b6 100644 --- a/src/adapters/chrome/background/CapitalizationHelper.ts +++ b/src/adapters/chrome/background/CapitalizationHelper.ts @@ -14,11 +14,6 @@ export interface CheckAutoCapitalizeParams { autoCapitalize: boolean; } -/** - * Checks if auto capitalization should be applied based on the input tokens and punctuation marks. - * @param params - Parameters for capitalization check - * @returns {Capitalization} The type of capitalization to be applied. - */ export function checkAutoCapitalize({ lastWord, wordCount, @@ -26,14 +21,11 @@ export function checkAutoCapitalize({ endsWithSpace, autoCapitalize, }: CheckAutoCapitalizeParams): Capitalization { - const firstCharacterOfLastWord = lastWord.slice(0, 1); - - // Whole word capitalization: " XYZ" if (!endsWithSpace && lastWord && lastWord.length > 1 && lastWord === lastWord.toUpperCase()) { return Capitalization.WholeWord; } - // First letter capitalization: " Xyz" + const firstCharacterOfLastWord = lastWord.slice(0, 1); if ( !endsWithSpace && isLetter(firstCharacterOfLastWord) && @@ -42,7 +34,6 @@ export function checkAutoCapitalize({ return Capitalization.FirstLetter; } - // Auto capitalization after sentence-ending punctuation if ( autoCapitalize && newSentence && diff --git a/src/adapters/chrome/background/LanguageDetector.ts b/src/adapters/chrome/background/LanguageDetector.ts index 5d082bd5..4acd84a2 100644 --- a/src/adapters/chrome/background/LanguageDetector.ts +++ b/src/adapters/chrome/background/LanguageDetector.ts @@ -126,47 +126,12 @@ export class LanguageDetector { const priors = sanitizeAutoLanguageSitePriors(priorsRaw, allowedLanguages); const sitePrior = getAutoLanguageSitePrior(priors, domain || undefined, allowedLanguages); const key = this.getSessionKey(request); - const nextRuntimeGeneration = - typeof request.runtimeGeneration === "number" && Number.isFinite(request.runtimeGeneration) - ? request.runtimeGeneration - : 0; + const nextRuntimeGeneration = this.resolveRuntimeGeneration(request.runtimeGeneration); const session = this.sessions.get(key) || - ({ - key, - tabId: request.tabId, - frameId: request.frameId, - suggestionId: request.suggestionId, - runtimeGeneration: nextRuntimeGeneration, - domain, - enabledLanguages: allowedLanguages.slice(), - stableLanguage: null, - resolvedLanguage: fallbackLanguage, - rollingSample: "", - pageLanguageHint: null, - pageLanguageHintResolved: false, - pendingLanguage: null, - pendingConfirmations: 0, - manualLockLanguage: null, - switchSuppressedUntilBoundary: false, - source: "fallback", - lastSeenAt: now, - priorEligible: false, - } as AutoLanguageSessionState); - - const pageScopeChanged = - session.runtimeGeneration !== nextRuntimeGeneration || session.domain !== domain; - session.tabId = request.tabId; - session.frameId = request.frameId; - session.suggestionId = request.suggestionId; - session.runtimeGeneration = nextRuntimeGeneration; - session.domain = domain; - session.enabledLanguages = allowedLanguages.slice(); - session.lastSeenAt = now; - if (pageScopeChanged) { - session.pageLanguageHint = null; - session.pageLanguageHintResolved = false; - } + this.createSessionState(key, request, nextRuntimeGeneration, domain, allowedLanguages, fallbackLanguage, now); + + this.syncSessionScope(session, request, nextRuntimeGeneration, domain, allowedLanguages, now); session.rollingSample = updateAutoLanguageRollingSample(session.rollingSample, request.text); const runtime = this.trackLiveRuntime({ tabId: request.tabId, @@ -224,6 +189,66 @@ export class LanguageDetector { }; } + private resolveRuntimeGeneration(runtimeGeneration: unknown): number { + return typeof runtimeGeneration === "number" && Number.isFinite(runtimeGeneration) + ? runtimeGeneration + : 0; + } + + private createSessionState( + key: string, + request: AutoLanguageRequest, + runtimeGeneration: number, + domain: string | null, + allowedLanguages: string[], + fallbackLanguage: string, + now: number, + ): AutoLanguageSessionState { + return { + key, + tabId: request.tabId, + frameId: request.frameId, + suggestionId: request.suggestionId, + runtimeGeneration, + domain, + enabledLanguages: allowedLanguages.slice(), + stableLanguage: null, + resolvedLanguage: fallbackLanguage, + rollingSample: "", + pageLanguageHint: null, + pageLanguageHintResolved: false, + pendingLanguage: null, + pendingConfirmations: 0, + manualLockLanguage: null, + switchSuppressedUntilBoundary: false, + source: "fallback", + lastSeenAt: now, + priorEligible: false, + }; + } + + private syncSessionScope( + session: AutoLanguageSessionState, + request: AutoLanguageRequest, + runtimeGeneration: number, + domain: string | null, + allowedLanguages: string[], + now: number, + ): void { + const scopeChanged = session.runtimeGeneration !== runtimeGeneration || session.domain !== domain; + session.tabId = request.tabId; + session.frameId = request.frameId; + session.suggestionId = request.suggestionId; + session.runtimeGeneration = runtimeGeneration; + session.domain = domain; + session.enabledLanguages = allowedLanguages.slice(); + session.lastSeenAt = now; + if (scopeChanged) { + session.pageLanguageHint = null; + session.pageLanguageHintResolved = false; + } + } + reportRuntimeActivity(scope: AutoLanguageSessionLookup): void { logger.debug("Recording runtime activity", { tabId: scope.tabId, diff --git a/src/adapters/chrome/background/Migration.ts b/src/adapters/chrome/background/Migration.ts index b8855bfd..e09804a2 100644 --- a/src/adapters/chrome/background/Migration.ts +++ b/src/adapters/chrome/background/Migration.ts @@ -1,4 +1,3 @@ -// Handles migration/version logic for FluentTyper extension import { SUPPORTED_LANGUAGES } from "@core/domain/lang"; import { SettingsManager } from "@core/application/settingsManager"; import { getSettingStorageKey } from "@core/domain/contracts/settings"; @@ -7,61 +6,57 @@ import { CoreSettingsRepository } from "@core/application/repositories/CoreSetti import { SiteProfileRepository } from "@core/application/repositories/SiteProfileRepository"; const LEGACY_REVERT_ON_BACKSPACE_KEY = "revertOnBackspace"; +const LAST_VERSION_CUTOFF_STORE = "2023.09.30"; +const LAST_VERSION_CUTOFF_LANGUAGE = "2024.04.21"; -/** - * Migrates storage and language settings to the latest version. - * @param lastVersion - The previous version string. - */ export async function migrateToLocalStore(lastVersion?: string): Promise { const currentVersion = chrome.runtime.getManifest().version; - const migrateStore = - !lastVersion || - lastVersion.localeCompare("2023.09.30", undefined, { - numeric: true, - sensitivity: "base", - }) <= 0; + const settingsManager = new SettingsManager(); - const updateLang = - !lastVersion || - lastVersion.localeCompare("2024.04.21", undefined, { - numeric: true, - sensitivity: "base", - }) <= 0; - - let settingsManager: SettingsManager | null = null; - - if (migrateStore) { + if (shouldMigrate(lastVersion, LAST_VERSION_CUTOFF_STORE)) { chrome.storage.sync.get(null, (result: { [key: string]: unknown }) => { void chrome.storage.local.set(result); void chrome.storage.local.set({ lastVersion: currentVersion }); }); } - if (updateLang) { - settingsManager = settingsManager || new SettingsManager(); - const langProps: Array<"language" | "fallbackLanguage"> = ["language", "fallbackLanguage"]; - for (const langProp of langProps) { - const storageKey = getSettingStorageKey(langProp); - const language = await settingsManager.get(storageKey); - for (const key of Object.keys(SUPPORTED_LANGUAGES)) { - if (typeof language === "string" && key.startsWith(language)) { - await settingsManager.set(storageKey, key); - break; - } - } - } + if (shouldMigrate(lastVersion, LAST_VERSION_CUTOFF_LANGUAGE)) { + await migrateLanguageSettings(settingsManager); } - settingsManager = settingsManager || new SettingsManager(); if (typeof settingsManager.removeRaw === "function") { await settingsManager.removeRaw(LEGACY_REVERT_ON_BACKSPACE_KEY); } + const coreSettings = new CoreSettingsRepository(settingsManager); const siteProfileRepository = new SiteProfileRepository(settingsManager); const enabledLanguages = await coreSettings.getEnabledLanguages(); const rawSiteProfiles = await siteProfileRepository.getRawSiteProfiles(); - const siteProfiles = resolveSiteProfiles(rawSiteProfiles, enabledLanguages); - await siteProfileRepository.setSiteProfiles(siteProfiles); + await siteProfileRepository.setSiteProfiles(resolveSiteProfiles(rawSiteProfiles, enabledLanguages)); void chrome.storage.local.set({ lastVersion: currentVersion }); } + +function shouldMigrate(lastVersion: string | undefined, cutoffVersion: string): boolean { + return ( + !lastVersion || + lastVersion.localeCompare(cutoffVersion, undefined, { + numeric: true, + sensitivity: "base", + }) <= 0 + ); +} + +async function migrateLanguageSettings(settingsManager: SettingsManager): Promise { + const langProps: Array<"language" | "fallbackLanguage"> = ["language", "fallbackLanguage"]; + for (const langProp of langProps) { + const storageKey = getSettingStorageKey(langProp); + const language = await settingsManager.get(storageKey); + for (const key of Object.keys(SUPPORTED_LANGUAGES)) { + if (typeof language === "string" && key.startsWith(language)) { + await settingsManager.set(storageKey, key); + break; + } + } + } +} diff --git a/src/adapters/chrome/background/PredictionInputProcessor.ts b/src/adapters/chrome/background/PredictionInputProcessor.ts index bbc24365..5b4cae48 100644 --- a/src/adapters/chrome/background/PredictionInputProcessor.ts +++ b/src/adapters/chrome/background/PredictionInputProcessor.ts @@ -1,4 +1,3 @@ -// Utility for processing prediction input for PresageHandler import { DEFAULT_SEPARATOR_CHARS_REGEX, LANG_ADDITIONAL_SEPARATOR_REGEX } from "@core/domain/lang"; import { extractPredictionTokenSuffix, @@ -12,12 +11,12 @@ export const PAST_WORDS_COUNT = 5; export const MIN_WORD_LENGTH_TO_PREDICT = 1; export class PredictionInputProcessor { - separatorCharRegex: RegExp; - keepPredCharRegex: RegExp; - whiteSpaceRegex: RegExp; - letterRegex: RegExp; - minWordLengthToPredict: number; - autoCapitalize: boolean; + readonly separatorCharRegex: RegExp; + readonly keepPredCharRegex: RegExp; + readonly whiteSpaceRegex: RegExp; + readonly letterRegex: RegExp; + readonly minWordLengthToPredict: number; + readonly autoCapitalize: boolean; constructor(minWordLengthToPredict = MIN_WORD_LENGTH_TO_PREDICT, autoCapitalize = true) { this.separatorCharRegex = RegExp(DEFAULT_SEPARATOR_CHARS_REGEX); @@ -32,17 +31,17 @@ export class PredictionInputProcessor { wordArray: string[]; newSentence: boolean; } { - let newSentence = false; - let wordArray = wordArrayOrig.slice(); + const wordArray = wordArrayOrig.slice(); for (let index = wordArray.length - 1; index >= 0; index--) { const element = wordArray[index]; if (NEW_SENTENCE_CHARS.includes(element) || NEW_SENTENCE_CHARS.includes(element.slice(-1))) { - wordArray = wordArray.splice(index + 1); - newSentence = true; - break; + return { + wordArray: wordArray.slice(index + 1), + newSentence: true, + }; } } - return { wordArray, newSentence }; + return { wordArray, newSentence: false }; } checkDoPrediction( @@ -54,23 +53,15 @@ export class PredictionInputProcessor { if (numSuggestions <= 0) { return false; } - if (!endsWithSpace && isNumber(lastWord)) { - return false; - } - if (endsWithSpace && !predictNextWordAfterSeparatorChar) { - return false; + if (endsWithSpace) { + return predictNextWordAfterSeparatorChar; } - if (!endsWithSpace && lastWord.length < this.minWordLengthToPredict) { + if (isNumber(lastWord) || lastWord.length < this.minWordLengthToPredict) { return false; } - if ( - !endsWithSpace && - (lastWord.match(this.separatorCharRegex) || []).length !== - (lastWord.match(this.keepPredCharRegex) || []).length - ) { - return false; - } - return true; + const separatorMatches = lastWord.match(this.separatorCharRegex) || []; + const keepMatches = lastWord.match(this.keepPredCharRegex) || []; + return separatorMatches.length === keepMatches.length; } private normalizeAdditionalSeparators(value: string, language: string): string { @@ -118,18 +109,18 @@ export class PredictionInputProcessor { }; } const endsWithSpace = predictionInput !== predictionInput.trimEnd(); - predictionInput = this.normalizeAdditionalSeparators(predictionInput, language); + const normalizedInput = this.normalizeAdditionalSeparators(predictionInput, language); const currentWordSuffix = this.resolveCurrentWordSuffix(afterCursorTokenSuffix, language); - const predictionInputWithCurrentWord = `${predictionInput}${currentWordSuffix}`; + const predictionInputWithCurrentWord = `${normalizedInput}${currentWordSuffix}`; const lastWordsArray = predictionInputWithCurrentWord .split(this.whiteSpaceRegex) .filter((e) => e.trim()) - .splice(-PAST_WORDS_COUNT); + .slice(-PAST_WORDS_COUNT); const { wordArray, newSentence } = this.removePrevSentence(lastWordsArray); - predictionInput = wordArray.join(" ") + (endsWithSpace ? " " : ""); - let lastWord = lastWordsArray.length ? lastWordsArray[lastWordsArray.length - 1] : ""; - lastWord = - lastWord + const trimmedPredictionInput = wordArray.join(" ") + (endsWithSpace ? " " : ""); + const lastWordRaw = lastWordsArray.length ? lastWordsArray[lastWordsArray.length - 1] : ""; + const lastWord = + lastWordRaw .split(this.keepPredCharRegex) .filter((e) => e.trim()) .pop() || ""; @@ -146,7 +137,11 @@ export class PredictionInputProcessor { numSuggestions, predictNextWordAfterSeparatorChar, ); - predictionInput = predictionInput.toLowerCase(); - return { predictionInput, lastWord, doPrediction, doCapitalize }; + return { + predictionInput: trimmedPredictionInput.toLowerCase(), + lastWord, + doPrediction, + doCapitalize, + }; } } diff --git a/src/adapters/chrome/background/PredictionOrchestrator.ts b/src/adapters/chrome/background/PredictionOrchestrator.ts index f92fa249..3f007102 100644 --- a/src/adapters/chrome/background/PredictionOrchestrator.ts +++ b/src/adapters/chrome/background/PredictionOrchestrator.ts @@ -148,7 +148,6 @@ export class PredictionOrchestrator { }; const canRunPredictionBase = context.doPrediction && context.effectiveNumSuggestions > 0; - const canRunPresage = canRunPredictionBase && this.debugPresagePredictorEnabled && @@ -206,11 +205,7 @@ export class PredictionOrchestrator { try { aiResult = await aiPromise; } catch { - aiResult = { - predictions: [], - durationMs: 0, - timedOut: false, - }; + aiResult = this.createEmptyAIPredictionResult(); } aiDebug.durationMs = aiResult.durationMs; @@ -249,11 +244,7 @@ export class PredictionOrchestrator { numSuggestions: number, ): Promise { if (!this.aiPredictor) { - return { - predictions: [], - durationMs: 0, - timedOut: false, - }; + return this.createEmptyAIPredictionResult(); } this.aiPredictor.interruptActiveGeneration?.("newer_request"); @@ -264,16 +255,13 @@ export class PredictionOrchestrator { const timeoutPromise = new Promise<{ predictions: string[]; timedOut: boolean; - }>((resolve) => { + }>((resolve) => { timeoutId = setTimeout(() => { this.interruptAIPrediction("timeout", { lang, predictionInput, }); - resolve({ - predictions: [], - timedOut: true, - }); + resolve(this.createEmptyAIPredictionResult(true)); }, this.aiPredictionTimeoutMs); }); @@ -287,10 +275,7 @@ export class PredictionOrchestrator { predictions, timedOut: false, })) - .catch(() => ({ - predictions: [], - timedOut: false, - })); + .catch(() => this.createEmptyAIPredictionResult()); const result = await Promise.race([predictionPromise, timeoutPromise]); if (timeoutId) { @@ -325,6 +310,14 @@ export class PredictionOrchestrator { } } + private createEmptyAIPredictionResult(timedOut = false): AIPredictionResult { + return { + predictions: [], + durationMs: 0, + timedOut, + }; + } + private emitDebugEvent( debugListener: ((debugEvent: PredictionDebugEvent) => void) | undefined, debugEvent: PredictionDebugEvent, diff --git a/src/adapters/chrome/background/PresageEngine.ts b/src/adapters/chrome/background/PresageEngine.ts index 6e18cbc9..ac653bbc 100644 --- a/src/adapters/chrome/background/PresageEngine.ts +++ b/src/adapters/chrome/background/PresageEngine.ts @@ -1,4 +1,3 @@ -// filepath: src/background/PresageEngine.ts import type { Presage, PresageModule, PresageCallback } from "./PresageTypes"; export interface PresagePrediction { @@ -11,19 +10,19 @@ export interface PresageEngineConfig { } export class PresageEngine { - private readonly Module: PresageModule; + private readonly module: PresageModule; private readonly lang: string; public libPresage: Presage; - private libPresageCallback: PresageCallback; - private libPresageCallbackImpl: unknown = {}; + private readonly callback: PresageCallback; + private callbackImpl: unknown; private config: PresageEngineConfig; constructor(Module: PresageModule, config: PresageEngineConfig, lang: string) { - this.Module = Module; + this.module = Module; this.lang = lang; this.config = config; - this.libPresageCallback = { + this.callback = { pastStream: "", get_past_stream() { return this.pastStream; @@ -32,7 +31,7 @@ export class PresageEngine { return ""; }, }; - this.libPresageCallbackImpl = Module.PresageCallback.implement(this.libPresageCallback); + this.callbackImpl = this.module.PresageCallback.implement(this.callback); this.libPresage = this.createLibPresage(); this.setConfig(config); } @@ -48,17 +47,11 @@ export class PresageEngine { } predict(predictionInput: string): string[] { - this.libPresageCallback.pastStream = predictionInput; + this.callback.pastStream = predictionInput; const predictions: string[] = []; const predictionsNative = this.libPresage.predictWithProbability(); for (let i = 0; i < predictionsNative.size(); i++) { - let text: string | null; - try { - const parsedPrediction: unknown = JSON.parse(predictionsNative.get(i).prediction); - text = typeof parsedPrediction === "string" ? parsedPrediction : null; - } catch { - text = predictionsNative.get(i).prediction; - } + const text = this.parsePrediction(predictionsNative.get(i).prediction); if (text) { predictions.push(text); } @@ -67,9 +60,18 @@ export class PresageEngine { } private createLibPresage(): Presage { - return new this.Module.Presage( - this.libPresageCallbackImpl, + return new this.module.Presage( + this.callbackImpl, `resources_js/${this.lang}/presage.xml`, ); } + + private parsePrediction(rawPrediction: string): string | null { + try { + const parsedPrediction: unknown = JSON.parse(rawPrediction); + return typeof parsedPrediction === "string" ? parsedPrediction : null; + } catch { + return rawPrediction; + } + } } diff --git a/src/adapters/chrome/background/PresageHandler.ts b/src/adapters/chrome/background/PresageHandler.ts index 06c8434f..15f5fd92 100644 --- a/src/adapters/chrome/background/PresageHandler.ts +++ b/src/adapters/chrome/background/PresageHandler.ts @@ -4,7 +4,6 @@ import { createLogger } from "@core/application/logging/Logger"; import { getErrorMessage } from "@core/domain/error"; import { Capitalization } from "./CapitalizationHelper"; import { PredictionInputProcessor } from "./PredictionInputProcessor"; -import type { TemplateVariables } from "./TemplateExpander"; import { TemplateExpander } from "./TemplateExpander"; import type { PresageModule } from "./PresageTypes"; import { UserDictionaryManager } from "./UserDictionaryManager"; @@ -157,30 +156,6 @@ export class PresageHandler { return lang in this.presageEngines; } - parseStringTemplate(str: string, obj: TemplateVariables): string { - return TemplateExpander.parseStringTemplate(str, obj); - } - - getExpandedVariables(lang: string): TemplateVariables { - return TemplateExpander.getExpandedVariables( - lang, - - this.timeFormat ?? "", - this.dateFormat ?? "", - ); - } - - removePrevSentence(wordArrayOrig: string[]): { - wordArray: string[]; - foundNewSentence: boolean; - } { - const result = this.predictionInputProcessor.removePrevSentence(wordArrayOrig); - return { - wordArray: result.wordArray, - foundNewSentence: result.newSentence, - }; - } - processInput( predictionInput: string, language: string, @@ -211,30 +186,24 @@ export class PresageHandler { } const resolver = TemplateExpander.createResolver( lang, - this.timeFormat ?? "", this.dateFormat ?? "", tabId, ); - - if (predictionInput === this.lastPrediction[lang]?.pastStream) { + const cachedPrediction = this.lastPrediction[lang]; + if (cachedPrediction?.pastStream === predictionInput) { return Promise.all( - this.lastPrediction[lang].templates.map((text) => - TemplateExpander.parseStringTemplateAsync(text, resolver), - ), + cachedPrediction.templates.map((text) => TemplateExpander.parseStringTemplateAsync(text, resolver)), ); } const predictions = this.presageEngines[lang].predict(predictionInput); - - const expandedPredictions = await Promise.all( - predictions.map((text) => TemplateExpander.parseStringTemplateAsync(text, resolver)), - ); - this.lastPrediction[lang] = { pastStream: predictionInput, templates: predictions.slice(), }; - return expandedPredictions; + return Promise.all( + predictions.map((text) => TemplateExpander.parseStringTemplateAsync(text, resolver)), + ); } preparePredictionContext( @@ -270,13 +239,11 @@ export class PresageHandler { } async predictPresage(context: PresagePredictionContext): Promise { - if (!context.doPrediction) { - return []; - } - if (context.effectiveNumSuggestions <= 0) { - return []; - } - if (!this.hasLanguageEngine(context.lang)) { + if ( + !context.doPrediction || + context.effectiveNumSuggestions <= 0 || + !this.hasLanguageEngine(context.lang) + ) { return []; } return this.doPredictionHandler(context.predictionInput, context.lang, context.tabId); diff --git a/src/adapters/chrome/background/TabMessenger.ts b/src/adapters/chrome/background/TabMessenger.ts index 5be8b5d5..c5e47065 100644 --- a/src/adapters/chrome/background/TabMessenger.ts +++ b/src/adapters/chrome/background/TabMessenger.ts @@ -1,4 +1,3 @@ -// Handles messaging to tabs/content scripts for FluentTyper import type { SettingsManager } from "@core/application/settingsManager"; import { isEnabledForDomain } from "@core/application/domain-utils"; import { checkLastError, promisifiedSendMessage } from "@core/application/transport-utils"; @@ -15,33 +14,32 @@ export class TabMessenger { }); } + private async queryTabs(queryInfo: chrome.tabs.QueryInfo): Promise { + try { + return await chrome.tabs.query(queryInfo); + } catch { + return undefined; + } + } + + private getTabIdFromTabs(tabs: chrome.tabs.Tab[] | undefined): number | undefined { + const tabId = tabs?.[0]?.id; + return typeof tabId === "number" ? tabId : undefined; + } + private async getActiveTabId(): Promise { checkLastError(); - try { - let tabs: chrome.tabs.Tab[] | undefined; - try { - tabs = await chrome.tabs.query({ active: true, currentWindow: true }); - } catch { - // Expected in Firefox during background shortcuts if no current window - } - const firstTabUrl = tabs?.[0]?.url ?? ""; - const isExtensionPage = - firstTabUrl.startsWith("chrome-extension://") || firstTabUrl.startsWith("moz-extension://"); - // If no tabs found, or if the current window is an internal extension page, fallback to lastFocusedWindow - if (!tabs || tabs.length === 0 || isExtensionPage) { - const fallbackTabs = await chrome.tabs.query({ - active: true, - lastFocusedWindow: true, - }); - if (fallbackTabs && fallbackTabs.length > 0) { - tabs = fallbackTabs; - } - } - if (tabs && tabs.length >= 1 && typeof tabs[0].id === "number") { - return tabs[0].id; - } - } catch (e) { - console.warn("Failed to query active tab:", e); + const tabs = await this.queryTabs({ active: true, currentWindow: true }); + const firstTabUrl = tabs?.[0]?.url ?? ""; + const isExtensionPage = + firstTabUrl.startsWith("chrome-extension://") || firstTabUrl.startsWith("moz-extension://"); + const fallbackTabs = + !tabs || tabs.length === 0 || isExtensionPage + ? await this.queryTabs({ active: true, lastFocusedWindow: true }) + : undefined; + const activeTabId = this.getTabIdFromTabs(fallbackTabs ?? tabs); + if (activeTabId !== undefined) { + return activeTabId; } return this.lastActiveTabId; } @@ -104,34 +102,33 @@ export class TabMessenger { } async getLastActiveWebsiteTabContext(): Promise<{ tabId: number; hostname: string } | undefined> { - try { - const currentWindowTabs = await chrome.tabs.query({ active: true, currentWindow: true }); - const currentContext = this.toWebsiteTabContext(currentWindowTabs[0]); - if (currentContext) { - return currentContext; - } + const currentWindowTabs = await this.queryTabs({ active: true, currentWindow: true }); + const currentContext = this.toWebsiteTabContext(currentWindowTabs?.[0]); + if (currentContext) { + return currentContext; + } - const lastFocusedTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - const lastFocusedContext = this.toWebsiteTabContext(lastFocusedTabs[0]); - if (lastFocusedContext) { - return lastFocusedContext; - } + const lastFocusedTabs = await this.queryTabs({ active: true, lastFocusedWindow: true }); + const lastFocusedContext = this.toWebsiteTabContext(lastFocusedTabs?.[0]); + if (lastFocusedContext) { + return lastFocusedContext; + } - const allTabs = await chrome.tabs.query({}); - const recentWebsiteTab = [...allTabs] - .filter((tab) => this.isWebsiteUrl(tab.url)) - .sort((left, right) => (right.lastAccessed || 0) - (left.lastAccessed || 0))[0]; - const recentContext = this.toWebsiteTabContext(recentWebsiteTab); - if (recentContext) { - return recentContext; - } + const allTabs = await this.queryTabs({}); + const recentWebsiteTab = [...(allTabs ?? [])] + .filter((tab) => this.isWebsiteUrl(tab.url)) + .sort((left, right) => (right.lastAccessed || 0) - (left.lastAccessed || 0))[0]; + const recentContext = this.toWebsiteTabContext(recentWebsiteTab); + if (recentContext) { + return recentContext; + } - if (typeof this.lastActiveTabId === "number") { - const tab = await chrome.tabs.get(this.lastActiveTabId); - return this.toWebsiteTabContext(tab); + if (typeof this.lastActiveTabId === "number") { + try { + return this.toWebsiteTabContext(await chrome.tabs.get(this.lastActiveTabId)); + } catch { + return undefined; } - } catch { - return undefined; } return undefined; } diff --git a/src/adapters/chrome/background/TemplateExpander.ts b/src/adapters/chrome/background/TemplateExpander.ts index be9a8996..038ca4f0 100644 --- a/src/adapters/chrome/background/TemplateExpander.ts +++ b/src/adapters/chrome/background/TemplateExpander.ts @@ -1,22 +1,20 @@ -// TemplateExpander.ts -// Utility for template and variable expansion import { resolveDynamicVariable } from "@core/domain/variables"; export interface TemplateVariables { [key: string]: string; } +const TEMPLATE_REGEX = /\$\{(?!\d)[a-zA-Z0-9_æøåÆØÅ]+(?::[^}]+)?\}/g; +const TEMPLATE_SPLIT_REGEX = /\$\{(?!\d)[a-zA-Z0-9_æøåÆØÅ]+(?::[^}]+)?\}/; +const TEMPLATE_ARG_REGEX = /[^{}]+(?=})/g; + export class TemplateExpander { - /** - * Expands a string template asynchronously using a resolver. - */ static async parseStringTemplateAsync( str: string, resolver: (fullVarName: string) => Promise, ): Promise { - const regex = /\$\{(?!\d)[a-zA-Z0-9_æøåÆØÅ]+(?::[^}]+)?\}/g; - const parts = str.split(regex); - const argsMatches = str.match(regex) || []; + const parts = str.split(TEMPLATE_REGEX); + const argsMatches = str.match(TEMPLATE_REGEX) || []; const parameters = await Promise.all( argsMatches.map(async (match) => { @@ -29,12 +27,9 @@ export class TemplateExpander { return String.raw({ raw: parts }, ...parameters); } - /** - * Evaluates templates synchronously if variables are pre-computed. - */ static parseStringTemplate(str: string, obj: TemplateVariables): string { - const parts = str.split(/\$\{(?!\d)[a-zA-Z0-9_æøåÆØÅ]+(?::[^}]+)?\}/); - const args = str.match(/[^{}]+(?=})/g) || []; + const parts = str.split(TEMPLATE_SPLIT_REGEX); + const args = str.match(TEMPLATE_ARG_REGEX) || []; const parameters = args.map( (argument) => obj[argument] || (obj[argument] === undefined ? `\${${argument}}` : obj[argument]), @@ -42,9 +37,6 @@ export class TemplateExpander { return String.raw({ raw: parts }, ...parameters); } - /** - * Generates a resolver function for the active context. - */ static createResolver( lang: string, timeFormat: string, @@ -52,7 +44,6 @@ export class TemplateExpander { tabId?: number, ): (fullVarName: string) => Promise { return async (fullVarName: string) => { - // split varName from arg const colonIdx = fullVarName.indexOf(":"); let varName = fullVarName; let arg: string | undefined = undefined; @@ -62,52 +53,24 @@ export class TemplateExpander { arg = fullVarName.slice(colonIdx + 1); } - // Check standard variables from variables.ts - let stdVar: string | undefined = undefined; try { - stdVar = resolveDynamicVariable(varName, arg, lang, timeFormat, dateFormat); + const stdVar = resolveDynamicVariable(varName, arg, lang, timeFormat, dateFormat); + if (stdVar !== undefined) { + return stdVar; + } } catch (e) { console.warn(`Failed to resolve variable ${varName}`, e); } - if (stdVar !== undefined) { - return stdVar; - } - // Check browser context variables - if ( - ["page_url", "page_title", "page_domain"].includes(varName) && - typeof chrome !== "undefined" && - chrome.tabs && - tabId - ) { - try { - const tab = await chrome.tabs.get(tabId); - if (varName === "page_url") { - return tab.url || ""; - } - if (varName === "page_title") { - return tab.title || ""; - } - if (varName === "page_domain" && tab.url) { - try { - const urlObj = new URL(tab.url); - return urlObj.hostname; - } catch { - return ""; - } - } - } catch (error) { - console.warn(`Failed to fetch tab data for ${varName}`, error); - } + const pageVariable = await TemplateExpander.resolvePageVariable(varName, tabId); + if (pageVariable !== undefined) { + return pageVariable; } return undefined; }; } - /** - * @deprecated Used by older synchronous paths, will evaluate a fixed subset of variables. - */ static getExpandedVariables( lang: string, timeFormat: string, @@ -125,4 +88,40 @@ export class TemplateExpander { } return expandedTemplateVariables; } + + private static async resolvePageVariable( + varName: string, + tabId?: number, + ): Promise { + if ( + !["page_url", "page_title", "page_domain"].includes(varName) || + typeof chrome === "undefined" || + !chrome.tabs || + !tabId + ) { + return undefined; + } + try { + const tab = await chrome.tabs.get(tabId); + if (varName === "page_url") { + return tab.url || ""; + } + if (varName === "page_title") { + return tab.title || ""; + } + if (varName === "page_domain") { + if (!tab.url) { + return ""; + } + try { + return new URL(tab.url).hostname; + } catch { + return ""; + } + } + } catch (error) { + console.warn(`Failed to fetch tab data for ${varName}`, error); + } + return undefined; + } } diff --git a/src/adapters/chrome/background/TextExpansionManager.ts b/src/adapters/chrome/background/TextExpansionManager.ts index 557a2a87..43586fa8 100644 --- a/src/adapters/chrome/background/TextExpansionManager.ts +++ b/src/adapters/chrome/background/TextExpansionManager.ts @@ -1,37 +1,40 @@ -// Manages text expansion logic for Presage import type { PresageModule } from "./PresageTypes"; import type { PresageEngine } from "./PresageEngine"; export class TextExpansionManager { private textExpansions: Array<[string, object]> = []; - private module: PresageModule; - private presageEngineRecord: Record; + private readonly module: PresageModule; + private readonly presageEngineRecord: Record; constructor(module: PresageModule, presageEngineRecord: Record) { this.module = module; this.presageEngineRecord = presageEngineRecord; } - setTextExpansions(textExpansions: Array<[string, object]>) { - this.textExpansions = textExpansions; + setTextExpansions(textExpansions: Array<[string, object]> | null | undefined) { + this.textExpansions = Array.isArray(textExpansions) ? textExpansions : []; this.setupTextExpansions(); } private setupTextExpansions() { - if (!this.textExpansions) { - return; - } - let textExpansionsStr = ""; - this.textExpansions.forEach((textExpansion) => { - const jsonObj = JSON.stringify(textExpansion[1]); - textExpansionsStr += `${textExpansion[0].toLowerCase()}\t${jsonObj}\n`; - }); - this.module.FS.writeFile("/textExpansions.txt", textExpansionsStr); - for (const [, presageEngine] of Object.entries(this.presageEngineRecord)) { - presageEngine.libPresage.config( - "Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS", - "/textExpansions.txt", - ); + const lines = this.textExpansions.map( + ([shortcut, value]) => `${shortcut.toLowerCase()}\t${JSON.stringify(value)}`, + ); + this.writeConfigFile("/textExpansions.txt", lines); + this.applyConfigToEngines( + "Presage.Predictors.DefaultAbbreviationExpansionPredictor.ABBREVIATIONS", + "/textExpansions.txt", + ); + } + + private writeConfigFile(path: string, lines: string[]): void { + const payload = lines.length > 0 ? `${lines.join("\n")}\n` : ""; + this.module.FS.writeFile(path, payload); + } + + private applyConfigToEngines(configKey: string, valuePath: string): void { + for (const presageEngine of Object.values(this.presageEngineRecord)) { + presageEngine.libPresage.config(configKey, valuePath); } } } diff --git a/src/adapters/chrome/background/UserDictionaryManager.ts b/src/adapters/chrome/background/UserDictionaryManager.ts index 2e14d7a1..421b34b5 100644 --- a/src/adapters/chrome/background/UserDictionaryManager.ts +++ b/src/adapters/chrome/background/UserDictionaryManager.ts @@ -1,11 +1,10 @@ -// Manages user dictionary logic for Presage import type { PresageModule } from "./PresageTypes"; import type { PresageEngine } from "./PresageEngine"; export class UserDictionaryManager { private userDictionaryList: string[] = []; - private module: PresageModule; - private presageEngineRecord: Record; + private readonly module: PresageModule; + private readonly presageEngineRecord: Record; constructor(module: PresageModule, presageEngineRecord: Record) { this.module = module; @@ -18,13 +17,20 @@ export class UserDictionaryManager { } private setupUserDictionary() { - const userDictionaryStr = this.userDictionaryList.join("\n"); - this.module.FS.writeFile("/userDictionary.txt", userDictionaryStr); - for (const [, presageEngine] of Object.entries(this.presageEngineRecord)) { - presageEngine.libPresage.config( - "Presage.Predictors.DefaultDictionaryPredictor.DICTIONARY", - "/userDictionary.txt", - ); + this.writeDictionaryFile("/userDictionary.txt", this.userDictionaryList); + this.applyConfigToEngines( + "Presage.Predictors.DefaultDictionaryPredictor.DICTIONARY", + "/userDictionary.txt", + ); + } + + private writeDictionaryFile(path: string, userDictionaryList: string[]): void { + this.module.FS.writeFile(path, userDictionaryList.join("\n")); + } + + private applyConfigToEngines(configKey: string, valuePath: string): void { + for (const presageEngine of Object.values(this.presageEngineRecord)) { + presageEngine.libPresage.config(configKey, valuePath); } } } diff --git a/src/adapters/chrome/background/WebLLMPredictor.ts b/src/adapters/chrome/background/WebLLMPredictor.ts index dd51d0c9..33225312 100644 --- a/src/adapters/chrome/background/WebLLMPredictor.ts +++ b/src/adapters/chrome/background/WebLLMPredictor.ts @@ -85,10 +85,7 @@ export class WebLLMPredictor implements SecondaryPredictor { this.enabled = nextEnabled; this.modelId = nextModelId; - if (modelChanged) { - this.resetEngine(); - } - if (enabledChanged && !this.enabled) { + if (modelChanged || (enabledChanged && !this.enabled)) { this.resetEngine(); } } @@ -305,6 +302,17 @@ export class WebLLMPredictor implements SecondaryPredictor { return this.engineLifecycleService.ensureReady(this.enabled, this.modelId); } + private createEmptyPredictionPayload(): PredictionResponsePayload { + return { + predictions: [], + rawOutput: "", + }; + } + + private getMaxTokens(numSuggestions: number): number { + return Math.max(16, numSuggestions * 8); + } + private resetEngine(): void { this.predictionCache.clear(); this.generationCoordinator.advanceGenerationSeq(); @@ -319,7 +327,7 @@ export class WebLLMPredictor implements SecondaryPredictor { ): Promise { const engine = this.engineLifecycleService.getEngine(); if (!engine) { - return { predictions: [], rawOutput: "" }; + return this.createEmptyPredictionPayload(); } const chatCompletion = (await engine.chat.completions.create({ stream: false, @@ -330,7 +338,7 @@ export class WebLLMPredictor implements SecondaryPredictor { modeContext, ), n: 1, - max_tokens: Math.max(16, request.numSuggestions * 8), + max_tokens: this.getMaxTokens(request.numSuggestions), temperature: 0.2, top_p: 0.95, })) as ChatCreateResponse; @@ -347,7 +355,7 @@ export class WebLLMPredictor implements SecondaryPredictor { ): Promise { const engine = this.engineLifecycleService.getEngine(); if (!engine) { - return { predictions: [], rawOutput: "" }; + return this.createEmptyPredictionPayload(); } const chatCompletion = (await engine.chat.completions.create({ stream: false, @@ -357,7 +365,7 @@ export class WebLLMPredictor implements SecondaryPredictor { modeContext, ), n: 1, - max_tokens: Math.max(16, request.numSuggestions * 8), + max_tokens: this.getMaxTokens(request.numSuggestions), temperature: 0.2, top_p: 0.95, })) as ChatCreateResponse; @@ -374,7 +382,7 @@ export class WebLLMPredictor implements SecondaryPredictor { ): Promise { const engine = this.engineLifecycleService.getEngine(); if (!engine) { - return { predictions: [], rawOutput: "" }; + return this.createEmptyPredictionPayload(); } const completion = (await engine.completions.create({ stream: false, @@ -385,7 +393,7 @@ export class WebLLMPredictor implements SecondaryPredictor { modeContext, ), n: 1, - max_tokens: Math.max(16, request.numSuggestions * 8), + max_tokens: this.getMaxTokens(request.numSuggestions), temperature: 0.2, top_p: 0.95, })) as CompletionCreateResponse; diff --git a/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts b/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts index e250ba95..9714db12 100644 --- a/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts +++ b/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts @@ -7,9 +7,9 @@ import { MessageRouter } from "../router/MessageRouter"; import { registerRuntimeTestHooks } from "@adapters/chrome/background/testing/RuntimeTestHooks"; export class BackgroundBootstrap { - private readonly getWorker = (): BackgroundServiceWorker => new BackgroundServiceWorker(); - private readonly commandRouter = new CommandRouter(this.getWorker); - private readonly messageRouter = new MessageRouter(this.getWorker); + private readonly worker = new BackgroundServiceWorker(); + private readonly commandRouter = new CommandRouter(() => this.worker); + private readonly messageRouter = new MessageRouter(() => this.worker); register(): void { chrome.runtime.onInstalled.addListener(this.onInstalled.bind(this)); @@ -43,13 +43,14 @@ export class BackgroundBootstrap { } private loadLastVersionAndInitialize(): void { - const initializeFromLastVersion = (result: { lastVersion?: unknown }): Promise => { - const lastVersion = result?.lastVersion as string | undefined; - return this.getWorker().initialize(lastVersion); - }; - chrome.storage.local.get( - "lastVersion", - initializeFromLastVersion as unknown as (items: { [key: string]: unknown }) => void, - ); + // Keep listener registration synchronous, but still await startup work once the + // persisted version is available so migration/config initialization stays ordered. + chrome.storage.local.get("lastVersion", async ({ lastVersion }) => { + try { + await this.worker.initialize(typeof lastVersion === "string" ? lastVersion : undefined); + } catch (error) { + logError("lastVersion handler", error); + } + }); } } diff --git a/src/adapters/chrome/background/config/ConfigAssembler.ts b/src/adapters/chrome/background/config/ConfigAssembler.ts index 0b6e7698..f2138ef0 100644 --- a/src/adapters/chrome/background/config/ConfigAssembler.ts +++ b/src/adapters/chrome/background/config/ConfigAssembler.ts @@ -66,7 +66,7 @@ export class ConfigAssembler { this.coreSettingsRepository.getDisplayLangHeader(), this.coreSettingsRepository.getUserDictionaryList(), this.coreSettingsRepository.getThemeSettings(), - this.options.isDevBuild ? this.observabilitySettingsRepository.getSnapshot() : null, + this.getObservabilityConfig(), ]); return { @@ -88,14 +88,7 @@ export class ConfigAssembler { ), userDictionaryList, themeConfig, - observability: - this.options.isDevBuild && observability - ? { - enabled: observability.enabled, - defaultLevel: observability.defaultLevel, - moduleOverrides: observability.moduleOverrides, - } - : undefined, + observability, }, }; } @@ -113,7 +106,7 @@ export class ConfigAssembler { dateFormat, userDictionaryList, predictorSettings, - observabilitySettings, + observability, ] = await Promise.all([ this.coreSettingsRepository.getNumSuggestions(), this.coreSettingsRepository.getMinWordLengthToPredict(), @@ -125,7 +118,7 @@ export class ConfigAssembler { this.coreSettingsRepository.getDateFormat(), this.coreSettingsRepository.getUserDictionaryList(), this.predictorSettingsRepository.getSnapshot(), - this.options.isDevBuild ? this.observabilitySettingsRepository.getSnapshot() : null, + this.getObservabilityConfig(), ]); const normalizedGrammarRules = normalizeGrammarRuleSelection(enabledGrammarRules); const autoCapitalize = normalizedGrammarRules.includes("capitalizeSentenceStart"); @@ -133,14 +126,7 @@ export class ConfigAssembler { return { language, textExpansions, - observabilityConfig: - this.options.isDevBuild && observabilitySettings - ? { - enabled: observabilitySettings.enabled, - defaultLevel: observabilitySettings.defaultLevel, - moduleOverrides: observabilitySettings.moduleOverrides, - } - : undefined, + observabilityConfig: observability, predictionConfig: { numSuggestions, engineNumSuggestions: MAX_NUM_SUGGESTIONS, @@ -177,4 +163,19 @@ export class ConfigAssembler { preferNativeAutocomplete: domainSettings.preferNativeAutocomplete, }; } + + private async getObservabilityConfig(): Promise { + if (!this.options.isDevBuild) { + return undefined; + } + const snapshot = await this.observabilitySettingsRepository.getSnapshot(); + if (!snapshot) { + return undefined; + } + return { + enabled: snapshot.enabled, + defaultLevel: snapshot.defaultLevel, + moduleOverrides: snapshot.moduleOverrides, + }; + } } diff --git a/src/adapters/chrome/background/config/runtimeSettings.ts b/src/adapters/chrome/background/config/runtimeSettings.ts index 85de22f6..7ab52f02 100644 --- a/src/adapters/chrome/background/config/runtimeSettings.ts +++ b/src/adapters/chrome/background/config/runtimeSettings.ts @@ -18,6 +18,11 @@ export interface DomainRuntimeSettings { hasNumSuggestionsOverride: boolean; } +interface LanguageState { + language: string; + enabledLanguages: string[]; +} + export function clampNumSuggestions(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) { return 0; @@ -25,22 +30,34 @@ export function clampNumSuggestions(value: unknown): number { return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, Math.round(value))); } -export async function resolveActiveLanguage(settingsManager: SettingsManager): Promise { +async function resolveLanguageState(settingsManager: SettingsManager): Promise { const settingsRepository = new CoreSettingsRepository(settingsManager); const [currentLanguage, enabledLanguages] = await Promise.all([ settingsRepository.getLanguage(), settingsRepository.getEnabledLanguages(), ]); - const allowAutoDetect = enabledLanguages.length > 1; - if (currentLanguage === "auto_detect" && allowAutoDetect) { - return currentLanguage; + if (currentLanguage === "auto_detect" && enabledLanguages.length > 1) { + return { + language: currentLanguage, + enabledLanguages, + }; } if (enabledLanguages.includes(currentLanguage)) { - return currentLanguage; + return { + language: currentLanguage, + enabledLanguages, + }; } const fallbackLanguage = enabledLanguages[0]; await settingsRepository.setLanguage(fallbackLanguage); - return fallbackLanguage; + return { + language: fallbackLanguage, + enabledLanguages, + }; +} + +export async function resolveActiveLanguage(settingsManager: SettingsManager): Promise { + return (await resolveLanguageState(settingsManager)).language; } export async function resolveDomainRuntimeSettings( @@ -49,26 +66,19 @@ export async function resolveDomainRuntimeSettings( ): Promise { const settingsRepository = new CoreSettingsRepository(settingsManager); const siteProfileRepository = new SiteProfileRepository(settingsManager); - const [ - globalLanguage, - enabledLanguages, - inlineSuggestionGlobal, - preferNativeAutocompleteGlobal, - numGlobal, - siteProfilesRaw, - ] = await Promise.all([ - resolveActiveLanguage(settingsManager), - settingsRepository.getEnabledLanguages(), - settingsRepository.getInlineSuggestion(), - settingsRepository.getPreferNativeAutocomplete(), - settingsRepository.getNumSuggestions(), - siteProfileRepository.getSiteProfiles(), - ]); + const [languageState, inlineSuggestionGlobal, preferNativeAutocompleteGlobal, numGlobal, siteProfilesRaw] = + await Promise.all([ + resolveLanguageState(settingsManager), + settingsRepository.getInlineSuggestion(), + settingsRepository.getPreferNativeAutocomplete(), + settingsRepository.getNumSuggestions(), + siteProfileRepository.getSiteProfiles(), + ]); const profile = domainURL - ? getSiteProfileForDomain(siteProfilesRaw, domainURL, enabledLanguages) + ? getSiteProfileForDomain(siteProfilesRaw, domainURL, languageState.enabledLanguages) : undefined; - const language = profile?.language ?? globalLanguage; + const language = profile?.language ?? languageState.language; const inlineSuggestion = typeof profile?.inline_suggestion === "boolean" ? profile.inline_suggestion @@ -84,7 +94,7 @@ export async function resolveDomainRuntimeSettings( return { language, - enabledLanguages, + enabledLanguages: languageState.enabledLanguages, inlineSuggestion, preferNativeAutocomplete, numSuggestions, diff --git a/src/adapters/chrome/background/router/CommandRouter.ts b/src/adapters/chrome/background/router/CommandRouter.ts index c7d0d058..0da8febc 100644 --- a/src/adapters/chrome/background/router/CommandRouter.ts +++ b/src/adapters/chrome/background/router/CommandRouter.ts @@ -89,8 +89,8 @@ export class CommandRouter { logError("CommandRouter.handle", error); }, }), - createLoggingMiddleware(logger), createValidationMiddleware(isRuntimeCommand), + createLoggingMiddleware(logger), ]); this.registry diff --git a/src/adapters/chrome/background/router/MessageRouter.ts b/src/adapters/chrome/background/router/MessageRouter.ts index 3ff2e689..8e162419 100644 --- a/src/adapters/chrome/background/router/MessageRouter.ts +++ b/src/adapters/chrome/background/router/MessageRouter.ts @@ -163,10 +163,10 @@ export class MessageRouter { context.payload.sendResponse(mappedError.response); }, }), - createLoggingMiddleware(logger), createValidationMiddleware( isRoutedMessageCommand, ), + createLoggingMiddleware(logger), ]); const register = ( diff --git a/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts b/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts index 9ed5dce7..4787c3a7 100644 --- a/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts +++ b/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts @@ -7,12 +7,11 @@ interface RuntimeTestPredictionRequest { } export function maybePredictFromRuntimeTestOverride( - request: RuntimeTestPredictionRequest, + _request: RuntimeTestPredictionRequest, ): Promise { - void request; return Promise.resolve(null); } -export function registerRuntimeTestHooks(commandRouter: CommandRouter): void { - void commandRouter; +export function registerRuntimeTestHooks(_commandRouter: CommandRouter): void { + return; } diff --git a/src/adapters/chrome/background/testing/RuntimeTestHooks.ts b/src/adapters/chrome/background/testing/RuntimeTestHooks.ts index 4c43bfd5..fc7067a1 100644 --- a/src/adapters/chrome/background/testing/RuntimeTestHooks.ts +++ b/src/adapters/chrome/background/testing/RuntimeTestHooks.ts @@ -33,15 +33,25 @@ const TEST_MSG_GET_WEBLLM_PREDICTION_CALLS = "TEST_GET_WEBLLM_PREDICTION_CALLS"; const ENABLE_RUNTIME_TEST_HOOKS = typeof __FT_DEV_BUILD__ !== "undefined" && Boolean(__FT_DEV_BUILD__); const logger = createLogger("RuntimeTestHooks"); +const TEST_TRIGGER_COMMAND_ALLOW_LIST = new Set([ + CMD_TOGGLE_FT_ACTIVE_TAB, + CMD_TRIGGER_FT_ACTIVE_TAB, + CMD_TOGGLE_FT_ACTIVE_LANG, +]); function getWebLLMTestGlobals(): WebLLMTestGlobals { return globalThis as WebLLMTestGlobals; } -function setWebLLMTestOverride(predictions: string[], delayMs: number): void { - const normalizedPredictions = predictions +function normalizePredictions(predictions: unknown[]): string[] { + return predictions + .filter((prediction): prediction is string => typeof prediction === "string") .map((prediction) => prediction.trim()) .filter((prediction) => prediction.length > 0); +} + +function setWebLLMTestOverride(predictions: string[], delayMs: number): void { + const normalizedPredictions = normalizePredictions(predictions); getWebLLMTestGlobals()[WEB_LLM_TEST_OVERRIDE_KEY] = { predictions: normalizedPredictions, delayMs, @@ -99,15 +109,9 @@ export async function maybePredictFromRuntimeTestOverride( if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } - if (!Array.isArray(override.predictions)) { - return []; - } - - return override.predictions - .filter((item): item is string => typeof item === "string") - .map((item) => item.trim()) - .filter((item) => item.length > 0) - .slice(0, request.numSuggestions); + return Array.isArray(override.predictions) + ? normalizePredictions(override.predictions).slice(0, request.numSuggestions) + : []; } export function registerRuntimeTestHooks(commandRouter: CommandRouter): void { @@ -115,17 +119,9 @@ export function registerRuntimeTestHooks(commandRouter: CommandRouter): void { return; } logger.info("Registering runtime test hooks"); - if (typeof globalThis !== "undefined") { - getWebLLMTestGlobals().triggerCommandForTesting = async (command: string) => { - await commandRouter.handle(command); - }; - } - - const testTriggerCommandAllowList = new Set([ - CMD_TOGGLE_FT_ACTIVE_TAB, - CMD_TRIGGER_FT_ACTIVE_TAB, - CMD_TOGGLE_FT_ACTIVE_LANG, - ]); + getWebLLMTestGlobals().triggerCommandForTesting = async (command: string) => { + await commandRouter.handle(command); + }; const isTrustedInternalSender = (sender: chrome.runtime.MessageSender): boolean => { if (typeof sender.url === "string" && sender.url.startsWith(chrome.runtime.getURL(""))) { @@ -155,7 +151,7 @@ export function registerRuntimeTestHooks(commandRouter: CommandRouter): void { switch (type) { case TEST_MSG_TRIGGER_COMMAND: { const command = (message as { command?: unknown }).command; - if (typeof command !== "string" || !testTriggerCommandAllowList.has(command)) { + if (typeof command !== "string" || !TEST_TRIGGER_COMMAND_ALLOW_LIST.has(command)) { sendResponse({ ok: false }); return true; } diff --git a/src/adapters/chrome/background/webllm/CandidateRanker.ts b/src/adapters/chrome/background/webllm/CandidateRanker.ts index eea45261..10f7f98b 100644 --- a/src/adapters/chrome/background/webllm/CandidateRanker.ts +++ b/src/adapters/chrome/background/webllm/CandidateRanker.ts @@ -6,13 +6,25 @@ export class CandidateRanker { modeContext: PredictionModeContext, limit: number, ): string[] { - const normalizedRaw = predictions - .filter((item): item is string => typeof item === "string") - .map((item) => item.trim()) - .filter((item) => item.length > 0); + const normalized = this.normalizePredictions(predictions); + if (modeContext.mode !== "complete_or_correct" || !modeContext.fragment) { + return normalized.slice(0, limit); + } + const ranked = this.rankCompletionCandidates(normalized, modeContext.fragment, limit); + return ranked.length > 0 ? ranked : normalized.slice(0, limit); + } + + private normalizePredictions(predictions: string[]): string[] { const normalized: string[] = []; const seen = new Set(); - for (const token of normalizedRaw) { + for (const item of predictions) { + if (typeof item !== "string") { + continue; + } + const token = item.trim(); + if (!token) { + continue; + } const normalizedToken = token.toLowerCase(); if (seen.has(normalizedToken)) { continue; @@ -20,16 +32,20 @@ export class CandidateRanker { seen.add(normalizedToken); normalized.push(token); } - if (modeContext.mode !== "complete_or_correct" || !modeContext.fragment) { - return normalized.slice(0, limit); - } + return normalized; + } + + private rankCompletionCandidates( + predictions: string[], + fragment: string, + limit: number, + ): string[] { const bestByToken = new Map(); - for (let index = 0; index < normalized.length; index += 1) { - const token = normalized[index]; + predictions.forEach((token, index) => { const tokenLower = token.toLowerCase(); - const score = this.scoreCompletionCandidate(tokenLower, modeContext.fragment); + const score = this.scoreCompletionCandidate(tokenLower, fragment); if (score === null) { - continue; + return; } const existing = bestByToken.get(tokenLower); if ( @@ -39,21 +55,12 @@ export class CandidateRanker { ) { bestByToken.set(tokenLower, { token, score, index }); } - } - const ranked = Array.from(bestByToken.values()) - .sort((a, b) => { - if (a.score !== b.score) { - return a.score - b.score; - } - return a.index - b.index; - }) + }); + + return Array.from(bestByToken.values()) + .sort((a, b) => (a.score !== b.score ? a.score - b.score : a.index - b.index)) .map((entry) => entry.token) .slice(0, limit); - if (ranked.length > 0) { - return ranked; - } - // Avoid empty output if model returns unusual tokens that fail strict filtering. - return normalized.slice(0, limit); } private scoreCompletionCandidate(candidate: string, fragment: string): number | null { diff --git a/src/adapters/chrome/background/webllm/EngineLifecycleService.ts b/src/adapters/chrome/background/webllm/EngineLifecycleService.ts index 58ca70f0..79eb4780 100644 --- a/src/adapters/chrome/background/webllm/EngineLifecycleService.ts +++ b/src/adapters/chrome/background/webllm/EngineLifecycleService.ts @@ -73,10 +73,7 @@ export class EngineLifecycleService { } async ensureReady(enabled: boolean, modelId: string): Promise { - if (!enabled) { - return false; - } - if (!this.hasWebGPU()) { + if (!enabled || !this.hasWebGPU()) { return false; } if (this.status === PredictorStatus.Ready && this.engine) { @@ -95,18 +92,7 @@ export class EngineLifecycleService { this.status = PredictorStatus.Loading; this.initAttemptCount += 1; const initStartedAt = Date.now(); - this.lastInitStartedAt = initStartedAt; - this.lastInitDurationMs = 0; - this.lastInitProgress = 0; - this.lastInitProgressAt = initStartedAt; - this.lastInitProgressText = "initializing"; - this.lastInitError = null; - this.lastInitProgressLog = []; - this.recordInitProgress({ - progress: 0, - timeElapsed: 0, - text: "initializing", - }); + this.resetInitTracking(initStartedAt); this.initPromise = (async () => { try { @@ -115,35 +101,11 @@ export class EngineLifecycleService { this.recordInitProgress(report); }, }); - this.status = PredictorStatus.Ready; - this.lastFailureAt = 0; - this.lastInitDurationMs = Date.now() - initStartedAt; - this.lastInitProgress = 1; - this.lastInitProgressAt = Date.now(); - this.lastInitProgressText = "ready"; - this.recordInitProgress({ - progress: 1, - timeElapsed: this.lastInitDurationMs, - text: "ready", - }); + this.markInitReady(initStartedAt); return true; } catch (error) { const errorMessage = getErrorMessage(error); - this.engine = null; - this.status = PredictorStatus.Failed; - this.lastFailureAt = Date.now(); - this.lastInitDurationMs = Date.now() - initStartedAt; - this.lastInitError = errorMessage; - this.lastInitProgressAt = Date.now(); - this.lastInitProgressText = "failed"; - this.recordInitProgress({ - progress: - this.lastInitProgress >= 0 && Number.isFinite(this.lastInitProgress) - ? this.lastInitProgress - : 0, - timeElapsed: this.lastInitDurationMs, - text: `failed: ${this.lastInitError}`, - }); + this.markInitFailed(initStartedAt, errorMessage); logger.warn("WebLLM init failed; fallback to Presage", { modelId, initAttemptCount: this.initAttemptCount, @@ -202,4 +164,52 @@ export class EngineLifecycleService { ); } } + + private resetInitTracking(initStartedAt: number): void { + this.lastInitStartedAt = initStartedAt; + this.lastInitDurationMs = 0; + this.lastInitProgress = 0; + this.lastInitProgressAt = initStartedAt; + this.lastInitProgressText = "initializing"; + this.lastInitError = null; + this.lastInitProgressLog = []; + this.recordInitProgress({ + progress: 0, + timeElapsed: 0, + text: "initializing", + }); + } + + private markInitReady(initStartedAt: number): void { + this.status = PredictorStatus.Ready; + this.lastFailureAt = 0; + this.lastInitDurationMs = Date.now() - initStartedAt; + this.lastInitProgress = 1; + this.lastInitProgressAt = Date.now(); + this.lastInitProgressText = "ready"; + this.recordInitProgress({ + progress: 1, + timeElapsed: this.lastInitDurationMs, + text: "ready", + }); + } + + private markInitFailed(initStartedAt: number, errorMessage: string): void { + this.engine = null; + this.status = PredictorStatus.Failed; + this.lastFailureAt = Date.now(); + this.lastInitDurationMs = Date.now() - initStartedAt; + this.lastInitError = errorMessage; + this.lastInitProgressAt = Date.now(); + this.lastInitProgressText = "failed"; + const progress = + this.lastInitProgress >= 0 && Number.isFinite(this.lastInitProgress) + ? this.lastInitProgress + : 0; + this.recordInitProgress({ + progress, + timeElapsed: this.lastInitDurationMs, + text: `failed: ${this.lastInitError}`, + }); + } } diff --git a/src/adapters/chrome/background/webllm/GenerationCoordinator.ts b/src/adapters/chrome/background/webllm/GenerationCoordinator.ts index 0c19a09a..f13a14ae 100644 --- a/src/adapters/chrome/background/webllm/GenerationCoordinator.ts +++ b/src/adapters/chrome/background/webllm/GenerationCoordinator.ts @@ -43,12 +43,9 @@ export class GenerationCoordinator { } registerGeneration(seq: number, request: PredictorRequest): void { - let resolveDone = () => {}; - const donePromise = new Promise((resolve) => { - resolveDone = resolve; - }); - this.generationDonePromises.set(seq, donePromise); - this.generationDoneResolvers.set(seq, resolveDone); + const deferred = this.createDeferred(); + this.generationDonePromises.set(seq, deferred.promise); + this.generationDoneResolvers.set(seq, deferred.resolve); this.inFlightGenerationSeq = seq; this.inFlightRequest = { lang: request.lang, @@ -58,12 +55,7 @@ export class GenerationCoordinator { } completeGeneration(seq: number): void { - const resolveDone = this.generationDoneResolvers.get(seq); - if (resolveDone) { - this.generationDoneResolvers.delete(seq); - this.generationDonePromises.delete(seq); - resolveDone(); - } + this.resolveGeneration(seq); if (this.inFlightGenerationSeq === seq) { this.inFlightGenerationSeq = null; this.inFlightRequest = null; @@ -77,16 +69,7 @@ export class GenerationCoordinator { if (!donePromise || timeoutMs <= 0) { return; } - let timeoutId: ReturnType | null = null; - await Promise.race([ - donePromise, - new Promise((resolve) => { - timeoutId = setTimeout(resolve, timeoutMs); - }), - ]); - if (timeoutId) { - clearTimeout(timeoutId); - } + await this.raceWithTimeout(donePromise, timeoutMs); } clearGenerationTracking(): void { @@ -100,4 +83,35 @@ export class GenerationCoordinator { this.cancelledGenerationSeqs.clear(); this.isGenerating = false; } + + private createDeferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; + } + + private resolveGeneration(seq: number): void { + const resolveDone = this.generationDoneResolvers.get(seq); + if (!resolveDone) { + return; + } + this.generationDoneResolvers.delete(seq); + this.generationDonePromises.delete(seq); + resolveDone(); + } + + private async raceWithTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | null = null; + await Promise.race([ + promise, + new Promise((resolve) => { + timeoutId = setTimeout(resolve, timeoutMs); + }), + ]); + if (timeoutId) { + clearTimeout(timeoutId); + } + } } diff --git a/src/adapters/chrome/background/webllm/PredictionCache.ts b/src/adapters/chrome/background/webllm/PredictionCache.ts index 8dd581a5..3daad4d4 100644 --- a/src/adapters/chrome/background/webllm/PredictionCache.ts +++ b/src/adapters/chrome/background/webllm/PredictionCache.ts @@ -19,19 +19,19 @@ export class PredictionCache { } getCacheKey(modelId: string, request: PredictorRequest): string { - return `${modelId}|${request.lang}|${request.numSuggestions}|${request.predictionInput}`; + return [modelId, request.lang, request.numSuggestions, request.predictionInput].join("|"); } get(cacheKey: string): string[] | null { - const cached = this.cache.get(cacheKey); - if (!cached) { + const entry = this.cache.get(cacheKey); + if (!entry) { return null; } - if (cached.expiresAt < Date.now()) { + if (entry.expiresAt < Date.now()) { this.cache.delete(cacheKey); return null; } - return cached.predictions.slice(); + return entry.predictions.slice(); } set(cacheKey: string, predictions: string[]): void { diff --git a/src/adapters/chrome/background/webllm/PromptBuilder.ts b/src/adapters/chrome/background/webllm/PromptBuilder.ts index 2c3bc704..8d942019 100644 --- a/src/adapters/chrome/background/webllm/PromptBuilder.ts +++ b/src/adapters/chrome/background/webllm/PromptBuilder.ts @@ -5,8 +5,7 @@ export class PromptBuilder { resolvePredictionMode(predictionInput: string): PredictionModeContext { const trimmedInput = predictionInput.trim(); - const endsWithSpace = predictionInput !== predictionInput.trimEnd(); - if (trimmedInput.length === 0 || endsWithSpace) { + if (trimmedInput.length === 0 || predictionInput !== predictionInput.trimEnd()) { return { mode: "next_word", fragment: "", @@ -14,15 +13,9 @@ export class PromptBuilder { } const fragmentMatch = trimmedInput.match(/([\p{L}\p{N}'-]+)$/u); const fragment = (fragmentMatch?.[1] || "").toLowerCase(); - if (!fragment) { - return { - mode: "next_word", - fragment: "", - }; - } return { - mode: "complete_or_correct", - fragment, + mode: fragment ? "complete_or_correct" : "next_word", + fragment: fragment || "", }; } @@ -32,9 +25,9 @@ export class PromptBuilder { numSuggestions: number, modeContext: PredictionModeContext, ): string { - const languageLabel = lang.replace("_", "-"); - const safeText = predictionInput.trim() || ""; - const count = Math.min(this.maxGenerationChoices, Math.max(1, numSuggestions)); + const languageLabel = this.getLanguageLabel(lang); + const safeText = this.getSafeText(predictionInput); + const count = this.getSuggestionCount(numSuggestions); if (modeContext.mode === "complete_or_correct") { return [ `You are a typing autocomplete assistant for language ${languageLabel}.`, @@ -72,9 +65,9 @@ export class PromptBuilder { numSuggestions: number, modeContext: PredictionModeContext, ): Array<{ role: "system" | "user"; content: string }> { - const languageLabel = lang.replace("_", "-"); - const safeText = predictionInput.trim() || ""; - const count = Math.min(this.maxGenerationChoices, Math.max(1, numSuggestions)); + const languageLabel = this.getLanguageLabel(lang); + const safeText = this.getSafeText(predictionInput); + const count = this.getSuggestionCount(numSuggestions); if (modeContext.mode === "complete_or_correct") { return [ { @@ -127,8 +120,8 @@ export class PromptBuilder { numSuggestions: number, modeContext: PredictionModeContext, ): Array<{ role: "user"; content: string }> { - const safeText = predictionInput.trim() || ""; - const count = Math.min(this.maxGenerationChoices, Math.max(1, numSuggestions)); + const safeText = this.getSafeText(predictionInput); + const count = this.getSuggestionCount(numSuggestions); if (modeContext.mode === "complete_or_correct") { return [ { @@ -151,4 +144,16 @@ export class PromptBuilder { }, ]; } + + private getLanguageLabel(lang: string): string { + return lang.replace("_", "-"); + } + + private getSafeText(predictionInput: string): string { + return predictionInput.trim() || ""; + } + + private getSuggestionCount(numSuggestions: number): number { + return Math.min(this.maxGenerationChoices, Math.max(1, numSuggestions)); + } } diff --git a/src/adapters/chrome/background/webllm/ResponseParser.ts b/src/adapters/chrome/background/webllm/ResponseParser.ts index e4257fcf..0525d2b5 100644 --- a/src/adapters/chrome/background/webllm/ResponseParser.ts +++ b/src/adapters/chrome/background/webllm/ResponseParser.ts @@ -15,44 +15,44 @@ export class ResponseParser { response: ChatCreateResponse, limit: number, ): Promise { - if (!this.isAsyncIterable(response)) { - return this.parseChatCompletionOutput(response, limit); - } - let rawOutput = ""; - for await (const chunk of response) { - for (const choice of chunk.choices ?? []) { - const content = choice?.delta?.content; - if (typeof content === "string") { - rawOutput += content; + if (this.isAsyncIterable(response)) { + let rawOutput = ""; + for await (const chunk of response) { + for (const choice of chunk.choices ?? []) { + const content = choice?.delta?.content; + if (typeof content === "string") { + rawOutput += content; + } } } + return { + predictions: this.parsePredictionLines(rawOutput, limit), + rawOutput, + }; } - return { - predictions: this.parsePredictionLines(rawOutput, limit), - rawOutput, - }; + return this.parseChatCompletionOutput(response, limit); } async parseCompletionCreateResponse( response: CompletionCreateResponse, limit: number, ): Promise { - if (!this.isAsyncIterable(response)) { - return this.parseCompletionOutput(response, limit); - } - let rawOutput = ""; - for await (const chunk of response) { - for (const choice of chunk.choices ?? []) { - const text = choice?.text; - if (typeof text === "string") { - rawOutput += text; + if (this.isAsyncIterable(response)) { + let rawOutput = ""; + for await (const chunk of response) { + for (const choice of chunk.choices ?? []) { + const text = choice?.text; + if (typeof text === "string") { + rawOutput += text; + } } } + return { + predictions: this.parsePredictionLines(rawOutput, limit), + rawOutput, + }; } - return { - predictions: this.parsePredictionLines(rawOutput, limit), - rawOutput, - }; + return this.parseCompletionOutput(response, limit); } async enrichFromEngineMessage( @@ -149,9 +149,6 @@ export class ResponseParser { if (typeof value !== "object" || value === null) { return false; } - const maybeIterable = value as { - [Symbol.asyncIterator]?: unknown; - }; - return typeof maybeIterable[Symbol.asyncIterator] === "function"; + return typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function"; } } diff --git a/src/adapters/chrome/content-script/ContentMessageHandler.ts b/src/adapters/chrome/content-script/ContentMessageHandler.ts index 0eb67ab0..bf894700 100644 --- a/src/adapters/chrome/content-script/ContentMessageHandler.ts +++ b/src/adapters/chrome/content-script/ContentMessageHandler.ts @@ -22,7 +22,10 @@ import type { PredictResponseContext, SetConfigContext, } from "@core/domain/messageTypes"; -import { generatePredictionTraceId, resolveTraceAgeMs } from "./predictionTrace"; +import { + createPredictionTraceContext, + resolveTraceAgeMs, +} from "./predictionTrace"; type RuntimeInboundMessage = | Message @@ -61,22 +64,24 @@ export class ContentMessageHandler { typeof context.runtimeGeneration === "number" && Number.isFinite(context.runtimeGeneration) ? context.runtimeGeneration : this.dependencies.getPredictionGeneration(); - const traceId = isNonEmptyString(context.traceId) - ? context.traceId.trim() - : generatePredictionTraceId(); const traceStartedAtMs = typeof context.traceStartedAtMs === "number" && Number.isFinite(context.traceStartedAtMs) ? context.traceStartedAtMs : Date.now(); + const traceContext = createPredictionTraceContext( + traceStartedAtMs, + isNonEmptyString(context.traceId) ? context.traceId.trim() : undefined, + ); + const lang = this.dependencies.getLanguage(); logger.debug("Preparing prediction request", { - traceId, + traceId: traceContext.traceId, requestId: context.requestId, suggestionId: context.suggestionId, runtimeGeneration, nextChar: context.nextChar, - lang: this.dependencies.getLanguage(), - requestAgeMs: resolveTraceAgeMs(traceStartedAtMs), + lang, + requestAgeMs: resolveTraceAgeMs(traceContext.traceStartedAtMs), }); const message: ContentScriptPredictRequestMessage = { command: CMD_CONTENT_SCRIPT_PREDICT_REQ, @@ -88,10 +93,10 @@ export class ContentMessageHandler { suggestionId: context.suggestionId, requestId: context.requestId, runtimeGeneration, - lang: this.dependencies.getLanguage(), + lang, documentLang: document.documentElement.lang || undefined, - traceId, - traceStartedAtMs, + traceId: traceContext.traceId, + traceStartedAtMs: traceContext.traceStartedAtMs, }, }; this.pendingReq = message; @@ -131,7 +136,6 @@ export class ContentMessageHandler { ): void { void sender; checkLastError(); - let sendStatusMsg = false; if (!message) { logger.error("Received empty runtime message"); return; @@ -143,89 +147,88 @@ export class ContentMessageHandler { switch (message.command) { case CMD_BACKGROUND_PAGE_PREDICT_RESP: { - const context = (message as { context: PredictResponseContext }).context; - const traceIdMatches = - !isNonEmptyString(this.pendingReq?.context.traceId) || - !isNonEmptyString(context.traceId) || - this.pendingReq?.context.traceId === context.traceId; - const isMatchingPending = - this.pendingReq && - this.pendingReq.context.suggestionId === context.suggestionId && - this.pendingReq.context.requestId === context.requestId && - this.pendingReq.context.runtimeGeneration === context.runtimeGeneration && - traceIdMatches; - - if (isMatchingPending) { - // Clear before fulfillment so synchronous follow-up requests created - // by text edits are not wiped out after the callback returns. - this.pendingReq = null; - logger.debug("Fulfilling prediction response", { - traceId: context.traceId, - requestId: context.requestId, - suggestionId: context.suggestionId, - runtimeGeneration: context.runtimeGeneration, - predictionCount: context.predictions.length, - responseAgeMs: resolveTraceAgeMs(context.traceStartedAtMs), - }); - } else { - logger.debug( - "Forwarding non-matching prediction response for manager-level stale filtering", - { - traceId: context.traceId, - requestId: context.requestId, - suggestionId: context.suggestionId, - runtimeGeneration: context.runtimeGeneration, - pendingRequestId: this.pendingReq?.context.requestId, - pendingSuggestionId: this.pendingReq?.context.suggestionId, - pendingGeneration: this.pendingReq?.context.runtimeGeneration, - }, - ); - } - this.dependencies.fulfillPrediction(context); - break; + this.handlePredictionResponse((message as { context: PredictResponseContext }).context); + return; } case CMD_BACKGROUND_PAGE_SET_CONFIG: this.dependencies.setConfig((message as { context: SetConfigContext }).context); - sendStatusMsg = true; - break; + this.sendRuntimeStatus(sendResponse); + return; case CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG: this.dependencies.updateLanguage((message as { context: { lang: string } }).context.lang); - sendStatusMsg = true; - break; + this.sendRuntimeStatus(sendResponse); + return; case CMD_POPUP_PAGE_DISABLE: this.dependencies.setEnabled(false); - sendStatusMsg = true; - break; + this.sendRuntimeStatus(sendResponse); + return; case CMD_POPUP_PAGE_ENABLE: this.dependencies.setEnabled(true); - sendStatusMsg = true; - break; + this.sendRuntimeStatus(sendResponse); + return; case CMD_TOGGLE_FT_ACTIVE_TAB: this.dependencies.toggleEnabled(); - sendStatusMsg = true; - break; + this.sendRuntimeStatus(sendResponse); + return; case CMD_TRIGGER_FT_ACTIVE_TAB: this.dependencies.triggerActiveSuggestion(); - sendStatusMsg = true; - break; + this.sendRuntimeStatus(sendResponse); + return; case CMD_GET_HOSTNAME: - if (sendResponse) { - sendResponse({ hostname: window.location.hostname }); - } - break; + sendResponse?.({ hostname: window.location.hostname }); + return; default: logger.debug("Unknown message command", { command: message.command }); - break; + return; } + } - if (sendStatusMsg) { - const statusMsg: PopupPageStatusMessage = { - command: CMD_STATUS_COMMAND, - context: { enabled: this.dependencies.getEnabled() }, - }; - if (sendResponse) { - sendResponse(statusMsg); - } + private handlePredictionResponse(context: PredictResponseContext): void { + const traceIdMatches = + !isNonEmptyString(this.pendingReq?.context.traceId) || + !isNonEmptyString(context.traceId) || + this.pendingReq?.context.traceId === context.traceId; + const isMatchingPending = + this.pendingReq && + this.pendingReq.context.suggestionId === context.suggestionId && + this.pendingReq.context.requestId === context.requestId && + this.pendingReq.context.runtimeGeneration === context.runtimeGeneration && + traceIdMatches; + + if (isMatchingPending) { + // Clear before fulfillment so synchronous follow-up requests created + // by text edits are not wiped out after the callback returns. + this.pendingReq = null; + logger.debug("Fulfilling prediction response", { + traceId: context.traceId, + requestId: context.requestId, + suggestionId: context.suggestionId, + runtimeGeneration: context.runtimeGeneration, + predictionCount: context.predictions.length, + responseAgeMs: resolveTraceAgeMs(context.traceStartedAtMs), + }); + } else { + logger.debug( + "Forwarding non-matching prediction response for manager-level stale filtering", + { + traceId: context.traceId, + requestId: context.requestId, + suggestionId: context.suggestionId, + runtimeGeneration: context.runtimeGeneration, + pendingRequestId: this.pendingReq?.context.requestId, + pendingSuggestionId: this.pendingReq?.context.suggestionId, + pendingGeneration: this.pendingReq?.context.runtimeGeneration, + }, + ); } + this.dependencies.fulfillPrediction(context); + } + + private sendRuntimeStatus(sendResponse?: (response: unknown) => void): void { + const statusMsg: PopupPageStatusMessage = { + command: CMD_STATUS_COMMAND, + context: { enabled: this.dependencies.getEnabled() }, + }; + sendResponse?.(statusMsg); } } diff --git a/src/adapters/chrome/content-script/ContentRuntimeController.ts b/src/adapters/chrome/content-script/ContentRuntimeController.ts index 8d94fae0..b3ecaa92 100644 --- a/src/adapters/chrome/content-script/ContentRuntimeController.ts +++ b/src/adapters/chrome/content-script/ContentRuntimeController.ts @@ -11,11 +11,13 @@ import { MutationScheduler } from "./MutationScheduler"; import { ShadowRootInterceptor } from "./ShadowRootInterceptor"; import { ThemeApplicator } from "./ThemeApplicator"; import { SuggestionManager } from "./SuggestionManager"; +import type { EarlyTabAcceptResult } from "./suggestions/SuggestionManagerRuntime"; const logger = createLogger("ContentRuntimeController"); export class ContentRuntimeController { private static readonly SELECTORS = "textarea, input, [contentEditable]"; + private static readonly LATE_DISCOVERY_EVENTS = ["focusin", "mousedown", "input"] as const; private static readonly MUTATION_COALESCE_DELAY_MS = 16; private static readonly MAX_MUTATION_BATCH_SIZE = 200; private static readonly MAX_MUTATION_ROOTS = 64; @@ -41,11 +43,8 @@ export class ContentRuntimeController { private readonly shadowObservers = new Map(); private shadowRootInterceptor: ShadowRootInterceptor | null = null; private lateDiscoveryListenersAttached = false; - private readonly onDocumentFocusInBound: EventListener = - this.onDocumentPotentialLateTarget.bind(this); - private readonly onDocumentMouseDownBound: EventListener = - this.onDocumentPotentialLateTarget.bind(this); - private readonly onDocumentInputBound: EventListener = + private readonly onMutationCallbackBound = this.mutationCallback.bind(this); + private readonly onDocumentPotentialLateTargetBound: EventListener = this.onDocumentPotentialLateTarget.bind(this); private _enabled = false; @@ -62,7 +61,7 @@ export class ContentRuntimeController { constructor(private readonly themeApplicator: ThemeApplicator = new ThemeApplicator()) { this.domObserver = new DomObserver( document.body || document.documentElement, - this.mutationCallback.bind(this), + this.onMutationCallbackBound, ); this.mutationScheduler = new MutationScheduler( ContentRuntimeController.MUTATION_COALESCE_DELAY_MS, @@ -204,9 +203,7 @@ export class ContentRuntimeController { }); } this.domObserver.disconnect(); - for (const o of this.shadowObservers.values()) { - o.disconnect(); - } + this.disconnectShadowObservers(); try { if (!this.suggestionManager) { return; @@ -218,14 +215,7 @@ export class ContentRuntimeController { } finally { if (this.enabled) { this.attachMutationObserver(); - for (const [root, observer] of this.shadowObservers.entries()) { - if (!isInDocument(root.host)) { - observer.disconnect(); - this.shadowObservers.delete(root); - } else { - observer.attach(); - } - } + this.refreshShadowObservers(); } } } @@ -238,9 +228,7 @@ export class ContentRuntimeController { this.suggestionManager?.queryAndAttachHelper(); this.suggestionManager?.triggerActiveSuggestion(); this.attachMutationObserver(); - for (const o of this.shadowObservers.values()) { - o.attach(); - } + this.refreshShadowObservers(); this.ensureShadowRootInterceptor(); this.ensureLateDiscoveryListeners(); this.reportRuntimeActivity(); @@ -254,9 +242,7 @@ export class ContentRuntimeController { this.pendingRestartToken = null; } this.domObserver.disconnect(); - for (const o of this.shadowObservers.values()) { - o.disconnect(); - } + this.disconnectShadowObservers(); this.mutationScheduler.clear(); this.suggestionManager?.detachAllHelpers(); this.shadowRootInterceptor?.detach(); @@ -298,13 +284,30 @@ export class ContentRuntimeController { if (this.shadowObservers.has(root)) { return; } - const observer = new DomObserver(root, this.mutationCallback.bind(this)); + const observer = new DomObserver(root, this.onMutationCallbackBound); this.shadowObservers.set(root, observer); if (this.enabled) { observer.attach(); } } + private disconnectShadowObservers(): void { + for (const observer of this.shadowObservers.values()) { + observer.disconnect(); + } + } + + private refreshShadowObservers(): void { + for (const [root, observer] of this.shadowObservers.entries()) { + if (!isInDocument(root.host)) { + observer.disconnect(); + this.shadowObservers.delete(root); + continue; + } + observer.attach(); + } + } + private ensureShadowRootInterceptor(): void { if (!this.shadowRootInterceptor) { this.shadowRootInterceptor = new ShadowRootInterceptor((root) => { @@ -322,9 +325,9 @@ export class ContentRuntimeController { if (this.lateDiscoveryListenersAttached) { return; } - document.addEventListener("focusin", this.onDocumentFocusInBound, true); - document.addEventListener("mousedown", this.onDocumentMouseDownBound, true); - document.addEventListener("input", this.onDocumentInputBound, true); + for (const eventName of ContentRuntimeController.LATE_DISCOVERY_EVENTS) { + document.addEventListener(eventName, this.onDocumentPotentialLateTargetBound, true); + } this.lateDiscoveryListenersAttached = true; } @@ -332,9 +335,9 @@ export class ContentRuntimeController { if (!this.lateDiscoveryListenersAttached) { return; } - document.removeEventListener("focusin", this.onDocumentFocusInBound, true); - document.removeEventListener("mousedown", this.onDocumentMouseDownBound, true); - document.removeEventListener("input", this.onDocumentInputBound, true); + for (const eventName of ContentRuntimeController.LATE_DISCOVERY_EVENTS) { + document.removeEventListener(eventName, this.onDocumentPotentialLateTargetBound, true); + } this.lateDiscoveryListenersAttached = false; } @@ -421,16 +424,17 @@ export class ContentRuntimeController { return; } - if (mutationPlan.type === "full-scan") { - this.suggestionManager.queryAndAttachHelper(); - return; - } - - if (mutationPlan.type === "targeted-scan") { - for (const mutationRoot of mutationPlan.roots) { - this.suggestionManager.queryAndAttachHelper(mutationRoot); - } + switch (mutationPlan.type) { + case "full-scan": + this.suggestionManager.queryAndAttachHelper(); + return; + case "targeted-scan": + for (const mutationRoot of mutationPlan.roots) { + this.suggestionManager.queryAndAttachHelper(mutationRoot); + } + return; + default: + return; } } } -import type { EarlyTabAcceptResult } from "./suggestions/SuggestionManagerRuntime"; diff --git a/src/adapters/chrome/content-script/DomObserver.ts b/src/adapters/chrome/content-script/DomObserver.ts index f60c519b..d9f074a1 100644 --- a/src/adapters/chrome/content-script/DomObserver.ts +++ b/src/adapters/chrome/content-script/DomObserver.ts @@ -1,20 +1,20 @@ import { SHADOW_ATTACH_MARKER_ATTR } from "./ShadowRootInterceptor"; /** - * DomObserver class encapsulates MutationObserver logic for DOM changes. - * It notifies a callback when relevant mutations occur. + * Wraps a MutationObserver around a single root node and forwards only + * non-empty mutation batches to the runtime callback. */ export class DomObserver { private observer: MutationObserver | null = null; private node: Node; - private callback: (mutationsList: MutationRecord[]) => void; + private readonly callback: (mutationsList: MutationRecord[]) => void; constructor(node: Node, callback: (mutationsList: MutationRecord[]) => void) { this.node = node; this.callback = callback; } - attach() { + attach(): void { if (!this.observer) { this.observer = new MutationObserver((mutationsList) => { if (mutationsList.length > 0) { @@ -50,13 +50,13 @@ export class DomObserver { }); } - disconnect() { + disconnect(): void { if (this.observer) { this.observer.disconnect(); } } - setNode(node: Node) { + setNode(node: Node): void { this.node = node; if (this.observer) { this.disconnect(); @@ -64,7 +64,7 @@ export class DomObserver { } } - getNode() { + getNode(): Node { return this.node; } } diff --git a/src/adapters/chrome/content-script/HostChangeWatcher.ts b/src/adapters/chrome/content-script/HostChangeWatcher.ts index 25852c2a..d40befaf 100644 --- a/src/adapters/chrome/content-script/HostChangeWatcher.ts +++ b/src/adapters/chrome/content-script/HostChangeWatcher.ts @@ -57,11 +57,7 @@ export class HostChangeWatcher { checkHostName(): boolean { const decision = this.stateMachine.evaluateHost(window.location.hostname); if (decision.type === "host-changed") { - logger.info("Host changed; refetching config", { - previousHost: decision.previousHostName, - nextHost: decision.nextHostName, - }); - this.dependencies.requestConfig(); + this.handleHostChange(decision.previousHostName, decision.nextHostName); return true; } return false; @@ -76,24 +72,22 @@ export class HostChangeWatcher { runtimeEnabled: this.dependencies.isRuntimeEnabled(), }); - if (decision.type === "host-changed") { - logger.info("Host changed; refetching config", { - previousHost: decision.previousHostName, - nextHost: decision.nextHostName, - }); - this.dependencies.requestConfig(); - logger.debug("Host changed during watchdog cycle; skipping DOM restart"); - return; - } - - if (decision.type === "node-changed") { - logger.warn("Observed root node changed; restarting runtime", { - runtimeEnabled: decision.runtimeEnabled, - }); - if (decision.runtimeEnabled) { - this.dependencies.restartRuntime(); - } - this.dependencies.setObservedNode(decision.nextObservedNode); + switch (decision.type) { + case "host-changed": + this.handleHostChange(decision.previousHostName, decision.nextHostName); + logger.debug("Host changed during watchdog cycle; skipping DOM restart"); + return; + case "node-changed": + logger.warn("Observed root node changed; restarting runtime", { + runtimeEnabled: decision.runtimeEnabled, + }); + if (decision.runtimeEnabled) { + this.dependencies.restartRuntime(); + } + this.dependencies.setObservedNode(decision.nextObservedNode); + return; + default: + return; } } @@ -119,6 +113,14 @@ export class HostChangeWatcher { }); } + private handleHostChange(previousHostName: string, nextHostName: string): void { + logger.info("Host changed; refetching config", { + previousHost: previousHostName, + nextHost: nextHostName, + }); + this.dependencies.requestConfig(); + } + private attachWatchDogEventListeners(): void { window.navigation?.addEventListener("navigate", this.scheduleWatchDogCheckBound); window.addEventListener("pageshow", this.scheduleWatchDogCheckBound); diff --git a/src/adapters/chrome/content-script/MutationPipeline.ts b/src/adapters/chrome/content-script/MutationPipeline.ts index 395599ff..1dbff925 100644 --- a/src/adapters/chrome/content-script/MutationPipeline.ts +++ b/src/adapters/chrome/content-script/MutationPipeline.ts @@ -51,18 +51,16 @@ export class MutationPipeline { private collectMutationRoots(mutationsList: MutationRecord[]): Element[] { const candidates: Element[] = []; + const addCandidate = (node: Node | null | undefined): void => { + if (node instanceof Element && isInDocument(node)) { + candidates.push(node); + } + }; + for (const mutation of mutationsList) { - mutation.addedNodes.forEach((node) => { - if (node instanceof Element && isInDocument(node)) { - candidates.push(node); - } - }); - if ( - mutation.type === "attributes" && - mutation.target instanceof Element && - isInDocument(mutation.target) - ) { - candidates.push(mutation.target); + mutation.addedNodes.forEach(addCandidate); + if (mutation.type === "attributes") { + addCandidate(mutation.target); } } diff --git a/src/adapters/chrome/content-script/MutationScheduler.ts b/src/adapters/chrome/content-script/MutationScheduler.ts index 9bead6b4..17700db5 100644 --- a/src/adapters/chrome/content-script/MutationScheduler.ts +++ b/src/adapters/chrome/content-script/MutationScheduler.ts @@ -19,28 +19,15 @@ export class MutationScheduler { } this.scheduled = true; if (this.shouldUseAnimationFrame()) { - this.animationFrameId = window.requestAnimationFrame(() => { - this.animationFrameId = null; - this.flush(); - }); + this.scheduleAnimationFrameFlush(); return; } - - this.timeoutId = window.setTimeout(() => { - this.timeoutId = null; - this.flush(); - }, this.coalesceDelayMs); + this.scheduleTimeoutFlush(); } clear(): void { - if (this.animationFrameId !== null) { - window.cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = null; - } - if (this.timeoutId !== null) { - window.clearTimeout(this.timeoutId); - this.timeoutId = null; - } + this.clearAnimationFrame(); + this.clearTimeout(); this.scheduled = false; this.pendingMutations = []; } @@ -51,6 +38,36 @@ export class MutationScheduler { ); } + private scheduleAnimationFrameFlush(): void { + this.animationFrameId = window.requestAnimationFrame(() => { + this.animationFrameId = null; + this.flush(); + }); + } + + private scheduleTimeoutFlush(): void { + this.timeoutId = window.setTimeout(() => { + this.timeoutId = null; + this.flush(); + }, this.coalesceDelayMs); + } + + private clearAnimationFrame(): void { + if (this.animationFrameId === null) { + return; + } + window.cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + private clearTimeout(): void { + if (this.timeoutId === null) { + return; + } + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + private flush(): void { this.scheduled = false; const mergedMutations = this.pendingMutations; diff --git a/src/adapters/chrome/content-script/ShadowRootInterceptor.ts b/src/adapters/chrome/content-script/ShadowRootInterceptor.ts index d5dc2d62..0a10d69b 100644 --- a/src/adapters/chrome/content-script/ShadowRootInterceptor.ts +++ b/src/adapters/chrome/content-script/ShadowRootInterceptor.ts @@ -45,15 +45,14 @@ const INTERCEPT_SNIPPET = `(function(){ })();`; export class ShadowRootInterceptor { - private readonly onShadowAttached: (root: ShadowRoot) => void; - private readonly doc: Document; private readonly handler: EventListener; private attached = false; private injected = false; - constructor(onShadowAttached: (root: ShadowRoot) => void, doc: Document = document) { - this.onShadowAttached = onShadowAttached; - this.doc = doc; + constructor( + private readonly onShadowAttached: (root: ShadowRoot) => void, + private readonly doc: Document = document, + ) { this.handler = this.onEvent.bind(this); } diff --git a/src/adapters/chrome/content-script/ThemeApplicator.ts b/src/adapters/chrome/content-script/ThemeApplicator.ts index 871d6d8b..98a8e4c2 100644 --- a/src/adapters/chrome/content-script/ThemeApplicator.ts +++ b/src/adapters/chrome/content-script/ThemeApplicator.ts @@ -1,11 +1,57 @@ import { DEFAULT_SUGGESTION_THEME_SETTINGS } from "@core/domain/themeDefaults"; import type { SetConfigContext } from "@core/domain/messageTypes"; +type ThemeSettings = NonNullable; +type ThemeSettingKey = keyof ThemeSettings; + +type ThemeSettingSpec = { + key: ThemeSettingKey; + cssName: string; + cssProperty: string; +}; + +const THEME_SETTING_SPECS: ThemeSettingSpec[] = [ + { key: "suggestionBgLight", cssName: "suggestion-bg-light", cssProperty: "color" }, + { key: "suggestionTextLight", cssName: "suggestion-text-light", cssProperty: "color" }, + { + key: "suggestionHighlightBgLight", + cssName: "suggestion-highlight-bg-light", + cssProperty: "color", + }, + { + key: "suggestionHighlightTextLight", + cssName: "suggestion-highlight-text-light", + cssProperty: "color", + }, + { key: "suggestionBorderLight", cssName: "suggestion-border-color-light", cssProperty: "color" }, + { key: "suggestionBgDark", cssName: "suggestion-bg-dark", cssProperty: "color" }, + { key: "suggestionTextDark", cssName: "suggestion-text-dark", cssProperty: "color" }, + { + key: "suggestionHighlightBgDark", + cssName: "suggestion-highlight-bg-dark", + cssProperty: "color", + }, + { + key: "suggestionHighlightTextDark", + cssName: "suggestion-highlight-text-dark", + cssProperty: "color", + }, + { key: "suggestionBorderDark", cssName: "suggestion-border-color-dark", cssProperty: "color" }, + { key: "suggestionFontSize", cssName: "suggestion-font-size", cssProperty: "font-size" }, + { + key: "suggestionPaddingVertical", + cssName: "suggestion-padding-vertical", + cssProperty: "padding-top", + }, + { + key: "suggestionPaddingHorizontal", + cssName: "suggestion-padding-horizontal", + cssProperty: "padding-left", + }, +]; + export class ThemeApplicator { - apply( - themeSettings: NonNullable, - doc: Document = document, - ): void { + apply(themeSettings: ThemeSettings, doc: Document = document): void { const safeThemeSettings = this.sanitizeThemeSettings(themeSettings, doc); const existingStyle = doc.getElementById("fluent-typer-theme-overrides"); if (existingStyle) { @@ -15,124 +61,34 @@ export class ThemeApplicator { const styleElement = doc.createElement("style"); styleElement.id = "fluent-typer-theme-overrides"; - styleElement.textContent = ` - :root { - --suggestion-bg-light: ${safeThemeSettings.suggestionBgLight} !important; - --ft-theme-suggestion-bg-light: ${safeThemeSettings.suggestionBgLight} !important; - --suggestion-text-light: ${safeThemeSettings.suggestionTextLight} !important; - --ft-theme-suggestion-text-light: ${safeThemeSettings.suggestionTextLight} !important; - --suggestion-highlight-bg-light: ${safeThemeSettings.suggestionHighlightBgLight} !important; - --ft-theme-suggestion-highlight-bg-light: ${safeThemeSettings.suggestionHighlightBgLight} !important; - --suggestion-highlight-text-light: ${safeThemeSettings.suggestionHighlightTextLight} !important; - --ft-theme-suggestion-highlight-text-light: ${safeThemeSettings.suggestionHighlightTextLight} !important; - --suggestion-border-color-light: ${safeThemeSettings.suggestionBorderLight} !important; - --ft-theme-suggestion-border-color-light: ${safeThemeSettings.suggestionBorderLight} !important; - --suggestion-bg-dark: ${safeThemeSettings.suggestionBgDark} !important; - --ft-theme-suggestion-bg-dark: ${safeThemeSettings.suggestionBgDark} !important; - --suggestion-text-dark: ${safeThemeSettings.suggestionTextDark} !important; - --ft-theme-suggestion-text-dark: ${safeThemeSettings.suggestionTextDark} !important; - --suggestion-highlight-bg-dark: ${safeThemeSettings.suggestionHighlightBgDark} !important; - --ft-theme-suggestion-highlight-bg-dark: ${safeThemeSettings.suggestionHighlightBgDark} !important; - --suggestion-highlight-text-dark: ${safeThemeSettings.suggestionHighlightTextDark} !important; - --ft-theme-suggestion-highlight-text-dark: ${safeThemeSettings.suggestionHighlightTextDark} !important; - --suggestion-border-color-dark: ${safeThemeSettings.suggestionBorderDark} !important; - --ft-theme-suggestion-border-color-dark: ${safeThemeSettings.suggestionBorderDark} !important; - --suggestion-font-size: ${safeThemeSettings.suggestionFontSize} !important; - --ft-theme-suggestion-font-size: ${safeThemeSettings.suggestionFontSize} !important; - --suggestion-padding-vertical: ${safeThemeSettings.suggestionPaddingVertical} !important; - --ft-theme-suggestion-padding-vertical: ${safeThemeSettings.suggestionPaddingVertical} !important; - --suggestion-padding-horizontal: ${safeThemeSettings.suggestionPaddingHorizontal} !important; - --ft-theme-suggestion-padding-horizontal: ${safeThemeSettings.suggestionPaddingHorizontal} !important; - } - `; + styleElement.textContent = this.buildThemeOverrideCss(safeThemeSettings); doc.head.appendChild(styleElement); } - private sanitizeThemeSettings( - themeSettings: NonNullable, - doc: Document, - ): NonNullable { - return { - suggestionBgLight: this.sanitizeCssValue( - themeSettings.suggestionBgLight, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionBgLight, - "color", - doc, - ), - suggestionTextLight: this.sanitizeCssValue( - themeSettings.suggestionTextLight, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionTextLight, - "color", - doc, - ), - suggestionHighlightBgLight: this.sanitizeCssValue( - themeSettings.suggestionHighlightBgLight, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightBgLight, - "color", - doc, - ), - suggestionHighlightTextLight: this.sanitizeCssValue( - themeSettings.suggestionHighlightTextLight, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightTextLight, - "color", - doc, - ), - suggestionBorderLight: this.sanitizeCssValue( - themeSettings.suggestionBorderLight, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionBorderLight, - "color", - doc, - ), - suggestionBgDark: this.sanitizeCssValue( - themeSettings.suggestionBgDark, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionBgDark, - "color", - doc, - ), - suggestionTextDark: this.sanitizeCssValue( - themeSettings.suggestionTextDark, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionTextDark, - "color", - doc, - ), - suggestionHighlightBgDark: this.sanitizeCssValue( - themeSettings.suggestionHighlightBgDark, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightBgDark, - "color", - doc, - ), - suggestionHighlightTextDark: this.sanitizeCssValue( - themeSettings.suggestionHighlightTextDark, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightTextDark, - "color", - doc, - ), - suggestionBorderDark: this.sanitizeCssValue( - themeSettings.suggestionBorderDark, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionBorderDark, - "color", - doc, - ), - suggestionFontSize: this.sanitizeCssValue( - themeSettings.suggestionFontSize, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionFontSize, - "font-size", - doc, - ), - suggestionPaddingVertical: this.sanitizeCssValue( - themeSettings.suggestionPaddingVertical, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionPaddingVertical, - "padding-top", - doc, - ), - suggestionPaddingHorizontal: this.sanitizeCssValue( - themeSettings.suggestionPaddingHorizontal, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionPaddingHorizontal, - "padding-left", + private buildThemeOverrideCss(themeSettings: ThemeSettings): string { + const lines: string[] = [":root {"]; + for (const spec of THEME_SETTING_SPECS) { + const value = themeSettings[spec.key]; + lines.push(` --${spec.cssName}: ${value} !important;`); + lines.push(` --ft-theme-${spec.cssName}: ${value} !important;`); + } + lines.push("}"); + return lines.join("\n"); + } + + private sanitizeThemeSettings(themeSettings: ThemeSettings, doc: Document): ThemeSettings { + const sanitizedThemeSettings = {} as ThemeSettings; + for (const spec of THEME_SETTING_SPECS) { + const fallbackValue = DEFAULT_SUGGESTION_THEME_SETTINGS[spec.key]; + sanitizedThemeSettings[spec.key] = this.sanitizeCssValue( + themeSettings[spec.key], + fallbackValue, + spec.cssProperty, doc, - ), - }; + ); + } + return sanitizedThemeSettings; } private sanitizeCssValue( diff --git a/src/adapters/chrome/content-script/content_script.ts b/src/adapters/chrome/content-script/content_script.ts index 69b1ea08..3675d122 100644 --- a/src/adapters/chrome/content-script/content_script.ts +++ b/src/adapters/chrome/content-script/content_script.ts @@ -13,11 +13,13 @@ import type { ContentScriptGetConfigMessage, ContentScriptPredictRequestContext, Message, + PredictResponseContext, SetConfigContext, } from "@core/domain/messageTypes"; import { ContentMessageHandler } from "./ContentMessageHandler"; +import type { ContentMessageHandlerDependencies } from "./ContentMessageHandler"; import { ContentRuntimeController } from "./ContentRuntimeController"; -import { HostChangeWatcher } from "./HostChangeWatcher"; +import { HostChangeWatcher, type HostChangeWatcherDependencies } from "./HostChangeWatcher"; import { isEarlyTabAcceptMessage } from "./suggestions/EarlyTabAcceptBridgeProtocol"; import { ThemeApplicator } from "./ThemeApplicator"; import type { DomObserver } from "./DomObserver"; @@ -80,37 +82,16 @@ class FluentTyper { this.runtimeController = new ContentRuntimeController(new ThemeApplicator()); this.runtimeController.setRestartRequestHandler(() => this.restart()); - this.contentMessageHandler = new ContentMessageHandler({ - getEnabled: () => this.enabled, - setEnabled: (value) => { - this.enabled = value; - }, - toggleEnabled: () => { - this.enabled = !this.enabled; - }, - setConfig: (config) => this.setConfig(config), - updateLanguage: (lang) => this.runtimeController.updateLanguage(lang), - triggerActiveSuggestion: () => this.runtimeController.triggerActiveSuggestion(), - fulfillPrediction: (context) => this.runtimeController.fulfillPrediction(context), - getLanguage: () => this.config.lang, - getPredictionGeneration: () => this.runtimeController.getPredictionGeneration(), - }); + this.contentMessageHandler = new ContentMessageHandler( + this.createContentMessageHandlerDependencies(), + ); this.runtimeController.setRuntimeActivityHandler((runtimeGeneration) => { this.contentMessageHandler.reportRuntimeStatus(runtimeGeneration); }); - this.runtimeController.setPredictionRequestHandler((context) => - this.handleGetPrediction(context), - ); + this.runtimeController.setPredictionRequestHandler(this.handleGetPrediction.bind(this)); - this.hostChangeWatcher = new HostChangeWatcher({ - watchDogRunner: () => this.watchDog(), - getObservedNode: () => this.runtimeController.getObservedNode(), - setObservedNode: (node) => this.runtimeController.setObservedNode(node), - isRuntimeEnabled: () => this.enabled, - restartRuntime: () => this.restart(), - requestConfig: () => this.getConfig(), - }); + this.hostChangeWatcher = new HostChangeWatcher(this.createHostChangeWatcherDependencies()); chrome.runtime.onMessage.addListener(this.boundMessageHandler); window.addEventListener("message", this.boundEarlyTabAcceptHandler); @@ -210,6 +191,36 @@ class FluentTyper { this.contentMessageHandler.handleMessage(message, sender, sendResponse); } + private createContentMessageHandlerDependencies(): ContentMessageHandlerDependencies { + return { + getEnabled: () => this.enabled, + setEnabled: (value: boolean) => { + this.enabled = value; + }, + toggleEnabled: () => { + this.enabled = !this.enabled; + }, + setConfig: (config: SetConfigContext) => this.setConfig(config), + updateLanguage: (lang: string) => this.runtimeController.updateLanguage(lang), + triggerActiveSuggestion: () => this.runtimeController.triggerActiveSuggestion(), + fulfillPrediction: (context: PredictResponseContext) => + this.runtimeController.fulfillPrediction(context), + getLanguage: () => this.config.lang, + getPredictionGeneration: () => this.runtimeController.getPredictionGeneration(), + }; + } + + private createHostChangeWatcherDependencies(): HostChangeWatcherDependencies { + return { + watchDogRunner: () => this.watchDog(), + getObservedNode: () => this.runtimeController.getObservedNode(), + setObservedNode: (node: Node) => this.runtimeController.setObservedNode(node), + isRuntimeEnabled: () => this.enabled, + restartRuntime: () => this.restart(), + requestConfig: () => this.getConfig(), + }; + } + getConfig(): void { const msg: ContentScriptGetConfigMessage = { command: CMD_CONTENT_SCRIPT_GET_CONFIG, diff --git a/src/adapters/chrome/content-script/predictionTrace.ts b/src/adapters/chrome/content-script/predictionTrace.ts index 132b4d0b..2f810755 100644 --- a/src/adapters/chrome/content-script/predictionTrace.ts +++ b/src/adapters/chrome/content-script/predictionTrace.ts @@ -13,9 +13,10 @@ export function generatePredictionTraceId(): string { export function createPredictionTraceContext( startedAtMs: number = Date.now(), + traceId?: string, ): PredictionTraceContext { return { - traceId: generatePredictionTraceId(), + traceId: traceId ?? generatePredictionTraceId(), traceStartedAtMs: startedAtMs, }; } diff --git a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts index fb5267c4..7e3fccf3 100644 --- a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts +++ b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts @@ -187,17 +187,8 @@ export class ContentEditableAdapter { } public getBlockContext(elem: HTMLElement): { beforeCursor: string; afterCursor: string } | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - - const range = selection.getRangeAt(0); - const targetNode = elem as Node; - const startInside = - range.startContainer === targetNode || targetNode.contains(range.startContainer); - const endInside = range.endContainer === targetNode || targetNode.contains(range.endContainer); - if (!startInside || !endInside) { + const range = this.resolveSelectionRangeWithinElement(elem); + if (!range) { return null; } @@ -270,17 +261,8 @@ export class ContentEditableAdapter { } public getActiveBlockElement(elem: HTMLElement): HTMLElement | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - - const range = selection.getRangeAt(0); - const targetNode = elem as Node; - const startInside = - range.startContainer === targetNode || targetNode.contains(range.startContainer); - const endInside = range.endContainer === targetNode || targetNode.contains(range.endContainer); - if (!startInside || !endInside) { + const range = this.resolveSelectionRangeWithinElement(elem); + if (!range) { return null; } @@ -288,17 +270,13 @@ export class ContentEditableAdapter { } public hasUnstableSelection(elem: HTMLElement): boolean { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { + const range = this.resolveSelectionRangeWithinElement(elem); + if (!range) { return true; } - const range = selection.getRangeAt(0); - const targetNode = elem as Node; - const startInside = - range.startContainer === targetNode || targetNode.contains(range.startContainer); - const endInside = range.endContainer === targetNode || targetNode.contains(range.endContainer); - if (!startInside || !endInside) { + const selection = window.getSelection(); + if (!selection) { return true; } @@ -324,33 +302,16 @@ export class ContentEditableAdapter { public getBlockContextBySelection( elem: HTMLElement, ): { beforeCursor: string; afterCursor: string } | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - const range = selection.getRangeAt(0); - const targetNode = elem as Node; - const startInside = - range.startContainer === targetNode || targetNode.contains(range.startContainer); - const endInside = range.endContainer === targetNode || targetNode.contains(range.endContainer); - if (!startInside || !endInside) { + const range = this.resolveSelectionRangeWithinElement(elem); + if (!range) { return null; } return this.getBlockContextByWalking(elem, range); } public getPreviousBlockTextBySelection(elem: HTMLElement): string | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - - const range = selection.getRangeAt(0); - const targetNode = elem as Node; - const startInside = - range.startContainer === targetNode || targetNode.contains(range.startContainer); - const endInside = range.endContainer === targetNode || targetNode.contains(range.endContainer); - if (!startInside || !endInside) { + const range = this.resolveSelectionRangeWithinElement(elem); + if (!range) { return null; } @@ -640,11 +601,8 @@ export class ContentEditableAdapter { return false; } - const range = selection.getRangeAt(0); - const targetNode = elem as Node; - const startInside = - range.startContainer === targetNode || targetNode.contains(range.startContainer); - if (!startInside) { + const range = this.resolveSelectionRangeWithinElement(elem, { requireEndContainer: false }); + if (!range) { return false; } @@ -1127,17 +1085,8 @@ export class ContentEditableAdapter { } private captureSelectionOffsetAnchors(root: HTMLElement): SelectionOffsetAnchors | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - - const range = selection.getRangeAt(0); - const rootNode = root as Node; - const startInside = - range.startContainer === rootNode || rootNode.contains(range.startContainer); - const endInside = range.endContainer === rootNode || rootNode.contains(range.endContainer); - if (!startInside || !endInside) { + const range = this.resolveSelectionRangeWithinElement(root); + if (!range) { return null; } @@ -1186,6 +1135,30 @@ export class ContentEditableAdapter { return null; } + private resolveSelectionRangeWithinElement( + elem: HTMLElement, + { requireEndContainer = true }: { requireEndContainer?: boolean } = {}, + ): Range | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + const range = selection.getRangeAt(0); + const targetNode = elem as Node; + const startInside = + range.startContainer === targetNode || targetNode.contains(range.startContainer); + if (!startInside) { + return null; + } + if (!requireEndContainer) { + return range; + } + + const endInside = range.endContainer === targetNode || targetNode.contains(range.endContainer); + return endInside ? range : null; + } + private resolveWithinTextNodes( elem: HTMLElement, clampedTarget: number, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index bf66bd4e..d38e1d43 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -166,16 +166,16 @@ export class SuggestionManagerRuntime { updateSelectionHighlight: (entry) => this.menuPresenter.updateHighlight(entry.list, entry.selectedIndex), acceptSuggestion: (entry, suggestion) => - this.sessionRegistry.get(entry.id)?.acceptSuggestion(suggestion) ?? false, + this.getSession(entry.id)?.acceptSuggestion(suggestion) ?? false, acceptSuggestionAtIndex: (entry, index) => - this.sessionRegistry.get(entry.id)?.acceptSuggestionAtIndex(index) ?? false, + this.getSession(entry.id)?.acceptSuggestionAtIndex(index) ?? false, requestInlineSuggestion: (entry) => - this.sessionRegistry.get(entry.id)?.requestInlineSuggestion(), + this.getSession(entry.id)?.requestInlineSuggestion(), }); } public fulfillPrediction(context: PredictionResponse): void { - this.sessionRegistry.get(context.suggestionId)?.handlePredictionResponse(context); + this.getSession(context.suggestionId)?.handlePredictionResponse(context); } public detachAllHelpers(): void { @@ -221,7 +221,7 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(entry.id)?.requestPrediction(); + this.getSession(entry.id)?.requestPrediction(); } public handleEarlyTabAcceptRequest(entryId: string): EarlyTabAcceptResult { @@ -237,7 +237,7 @@ export class SuggestionManagerRuntime { }; } - const session = this.sessionRegistry.get(entry.id); + const session = this.getSession(entry.id); if (!session) { return { accepted: false, @@ -562,7 +562,7 @@ export class SuggestionManagerRuntime { } this.clearPendingKeyFallback(id); - this.sessionRegistry.get(id)?.dispose(); + this.getSession(id)?.dispose(); this.lifecycleController.detachEntryListeners(entry); entry.menu.remove(); const stateHost = resolveSuggestionStateHost(entry.elem); @@ -588,7 +588,7 @@ export class SuggestionManagerRuntime { private dismissEntry(entry: SuggestionEntry, keepActive = false): void { this.clearPendingKeyFallback(entry.id); - this.sessionRegistry.get(entry.id)?.dispose(); + this.getSession(entry.id)?.dispose(); entry.requestId += 1; if (!keepActive && this.activeEntryId === entry.id) { this.activeEntryId = null; @@ -636,7 +636,7 @@ export class SuggestionManagerRuntime { private onElementFocus(id: number): void { this.activeEntryId = id; - this.sessionRegistry.get(id)?.handleFocus(); + this.getSession(id)?.handleFocus(); } private onElementClick(id: number): void { @@ -645,7 +645,7 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(id)?.handleClick({ + this.getSession(id)?.handleClick({ dismissEntry: () => this.dismissEntry(entry, true), }); } @@ -658,7 +658,7 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(id)?.handleBlur({ + this.getSession(id)?.handleBlur({ dismissEntry: () => this.dismissEntry(entry), }); } @@ -669,7 +669,7 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(id)?.handleInput(event); + this.getSession(id)?.handleInput(event); } private onElementBeforeInput(id: number, event: Event): void { @@ -690,7 +690,7 @@ export class SuggestionManagerRuntime { private onElementPaste(id: number): void { this.activeEntryId = id; - this.sessionRegistry.get(id)?.handlePaste(); + this.getSession(id)?.handlePaste(); } private onElementCompositionStart(id: number): void { @@ -699,7 +699,7 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(id)?.handleCompositionStart(); + this.getSession(id)?.handleCompositionStart(); } private onElementCompositionEnd(id: number): void { @@ -708,11 +708,11 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(id)?.handleCompositionEnd(); + this.getSession(id)?.handleCompositionEnd(); } private clearSuggestions(entry: SuggestionEntry): void { - this.sessionRegistry.get(entry.id)?.clearSuggestions(); + this.getSession(entry.id)?.clearSuggestions(); } private buildEntrySession(entry: SuggestionEntry): SuggestionEntrySession { @@ -778,7 +778,7 @@ export class SuggestionManagerRuntime { } private reconcileEntrySelection(entry: SuggestionEntry): void { - this.sessionRegistry.get(entry.id)?.reconcileSelection({ + this.getSession(entry.id)?.reconcileSelection({ dismissEntry: () => this.dismissEntry(entry, true), }); } @@ -804,7 +804,7 @@ export class SuggestionManagerRuntime { return; } - this.sessionRegistry.get(id)?.acceptSuggestionAtIndex(index); + this.getSession(id)?.acceptSuggestionAtIndex(index); } private onElementKeyDown(id: number, event: Event): void { @@ -818,7 +818,7 @@ export class SuggestionManagerRuntime { if (!entry) { return; } - this.sessionRegistry.get(id)?.handleKeyDown(keyboardEvent, { + this.getSession(id)?.handleKeyDown(keyboardEvent, { dispatchKeyboard: () => this.keyboardHandler.handle(entry, keyboardEvent), dismissEntry: (keepActive = true) => this.dismissEntry(entry, keepActive), clearPendingFallback: () => this.clearPendingKeyFallback(id), @@ -838,7 +838,7 @@ export class SuggestionManagerRuntime { this.clearPendingKeyFallback(id); return; } - this.sessionRegistry.get(id)?.handleKeyFallbackReconcile(pending, { + this.getSession(id)?.handleKeyFallbackReconcile(pending, { clearPendingFallback: () => this.clearPendingKeyFallback(id), dismissEntry: () => this.dismissEntry(current, true), rescheduleFallback: (delayMs: number) => @@ -871,6 +871,10 @@ export class SuggestionManagerRuntime { return elem.tagName !== "INPUT" && elem.tagName !== "TEXTAREA"; } + private getSession(entryId: number): SuggestionEntrySession | undefined { + return this.sessionRegistry.get(entryId); + } + private consumeCancelableEvent(event: Event): void { event.preventDefault(); event.stopPropagation(); diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionPositioningService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionPositioningService.ts index 43b81c77..bbbca93f 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionPositioningService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionPositioningService.ts @@ -200,12 +200,9 @@ export class SuggestionPositioningService { document.body.removeChild(mirror); - const clamp = (value: number, min: number, max: number): number => - Math.max(min, Math.min(value, max)); - return this.createRect( - clamp(caretRect.left, mirrorRect.left, mirrorRect.left + mirrorRect.width), - clamp(lineBoxTop, mirrorRect.top, mirrorRect.top + mirrorRect.height), + this.clamp(caretRect.left, mirrorRect.left, mirrorRect.left + mirrorRect.width), + this.clamp(lineBoxTop, mirrorRect.top, mirrorRect.top + mirrorRect.height), 0, Math.min(mirrorRect.height, lineBoxHeight), ); @@ -256,12 +253,9 @@ export class SuggestionPositioningService { } const parentRect = parent.getBoundingClientRect(); - const clamp = (value: number, min: number, max: number): number => - Math.max(min, Math.min(value, max)); - return this.createRect( - clamp(rect.left, parentRect.left, parentRect.left + parentRect.width), - clamp(rect.top, parentRect.top, parentRect.top + parentRect.height), + this.clamp(rect.left, parentRect.left, parentRect.left + parentRect.width), + this.clamp(rect.top, parentRect.top, parentRect.top + parentRect.height), 0, Math.min(parentRect.height, rect.height), ); diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 7ce96815..a3efe1b5 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -108,16 +108,12 @@ export class SuggestionTextEditService { : this.contentEditableAdapter.getBlockContext(entry.elem); const blockTokenInfo = blockContext ? this.findMentionToken(blockContext.beforeCursor) : null; if (!isTextValueTarget && blockContext && blockTokenInfo && blockTokenInfo.token.length > 0) { - return this.acceptContentEditableSuggestion(entry, suggestion, blockContext); + return this.acceptContentEditableSuggestion(entry, suggestion, blockContext, blockTokenInfo); } let snapshot = TextTargetAdapter.snapshot(entry.elem as TextTarget); - const tokenSource = snapshot.beforeCursor; - const tokenInfo = this.findMentionToken(tokenSource); - const cursorTokenInfo = this.findMentionToken(snapshot.beforeCursor); - const triggerText = isTextValueTarget - ? tokenInfo.token || cursorTokenInfo.token || entry.latestMentionText - : tokenInfo.token || entry.latestMentionText; + const tokenInfo = this.findMentionToken(snapshot.beforeCursor); + const triggerText = tokenInfo.token || entry.latestMentionText; if (!isTextValueTarget && triggerText && snapshot.beforeCursor.length === 0) { const fullText = entry.elem.textContent ?? ""; @@ -1127,6 +1123,7 @@ export class SuggestionTextEditService { entry: SuggestionEntry, suggestion: string, blockContext: { beforeCursor: string; afterCursor: string }, + blockTokenInfo: { token: string; start: number }, ): AcceptedSuggestionEditResult | null { const startedAt = typeof globalThis.performance?.now === "function" ? globalThis.performance.now() : Date.now(); @@ -1137,15 +1134,14 @@ export class SuggestionTextEditService { return null; } - const tokenInfo = this.findMentionToken(blockContext.beforeCursor); - const triggerText = tokenInfo.token || entry.latestMentionText; + const triggerText = blockTokenInfo.token || entry.latestMentionText; const beforeBlockBoundary = this.contentEditableAdapter.isCollapsedSelectionBeforeBlockBoundary( entry.elem, ); const blockSourceText = `${blockContext.beforeCursor}${blockContext.afterCursor}`; let replaceEnd = blockContext.beforeCursor.length; - if (tokenInfo.token.length === 0) { + if (blockTokenInfo.token.length === 0) { while (replaceEnd > 0 && this.isSeparator(blockContext.beforeCursor.charAt(replaceEnd - 1))) { replaceEnd -= 1; } diff --git a/src/core/application/domain-utils.ts b/src/core/application/domain-utils.ts index c4fd9903..add9d2cc 100644 --- a/src/core/application/domain-utils.ts +++ b/src/core/application/domain-utils.ts @@ -6,6 +6,10 @@ import { getSettingStorageKey } from "@core/domain/contracts/settings"; export const SETTINGS_DOMAIN_BLACKLIST = getSettingStorageKey("domainList"); const SETTINGS_ENABLED = getSettingStorageKey("enabled"); const SETTINGS_DOMAIN_LIST_MODE = getSettingStorageKey("domainListMode"); +const WHITESPACE_REGEX = /\s+/; +const WHITESPACE_EXCLUDING_NEWLINE_REGEX = /[^\S\r\n]+/; +const LETTER_REGEX = /^\p{L}/u; +const DIGITS_ONLY_REGEX = /[^0-9]/g; function toDomainListEntry(value: unknown): string | null { if (typeof value === "string") { @@ -22,6 +26,13 @@ export const DOMAIN_LIST_MODE = { whiteList: "Whitelist - disabled on all websites, enabled on specific sites", }; +function isDomainAllowedByMode( + mode: "blackList" | "whiteList", + isDomainOnBWList: boolean, +): boolean { + return (mode === "blackList" && !isDomainOnBWList) || (mode === "whiteList" && isDomainOnBWList); +} + async function getDomainList(settings: SettingsManager): Promise { const domainList = await settings.get(SETTINGS_DOMAIN_BLACKLIST); return Array.isArray(domainList) @@ -118,13 +129,7 @@ export async function isEnabledForDomain( getDomainListMode(settings), isDomainOnList(settings, domainURL), ]); - let enabledForDomain = enabled; - if (enabledForDomain) { - enabledForDomain = - (domainListMode === "blackList" && !isDomainOnBWList) || - (domainListMode === "whiteList" && isDomainOnBWList); - } - return enabledForDomain; + return enabled && isDomainAllowedByMode(domainListMode, isDomainOnBWList); } export async function isDomainAllowedByPreference( @@ -136,10 +141,7 @@ export async function isDomainAllowedByPreference( isDomainOnList(settings, domainURL), ]); - return ( - (domainListMode === "blackList" && !isDomainOnBWList) || - (domainListMode === "whiteList" && isDomainOnBWList) - ); + return isDomainAllowedByMode(domainListMode, isDomainOnBWList); } export async function blockUnBlockDomain( @@ -156,22 +158,17 @@ export async function blockUnBlockDomain( } export function isWhiteSpace(character: string, matchNewLine: boolean = true): boolean { - const whiteSpaceRegex = /\s+/; - const whiteSpaceRegexExcludeNewLine = /[^\S\r\n]+/; - if (matchNewLine) { - return whiteSpaceRegex.test(character); - } else { - return whiteSpaceRegexExcludeNewLine.test(character); - } + return matchNewLine + ? WHITESPACE_REGEX.test(character) + : WHITESPACE_EXCLUDING_NEWLINE_REGEX.test(character); } export function isLetter(character: string): boolean { - const letterRegex = /^\p{L}/u; - return letterRegex.test(character); + return LETTER_REGEX.test(character); } function countDigits(str: string): number { - return str.replace(/[^0-9]/g, "").length; + return str.replace(DIGITS_ONLY_REGEX, "").length; } export function isNumber(str: string): boolean { diff --git a/src/core/application/logging/Logger.ts b/src/core/application/logging/Logger.ts index ead20668..bd025f86 100644 --- a/src/core/application/logging/Logger.ts +++ b/src/core/application/logging/Logger.ts @@ -1,5 +1,6 @@ import { DEFAULT_OBSERVABILITY_CONFIG, + isLogLevel, type LogLevel, type ObservabilityConfig, type ObservabilityEvent, @@ -42,26 +43,10 @@ function getLoggingGlobals(): LoggerRuntimeGlobals { return globalThis as LoggerRuntimeGlobals; } -function parseLogLevel(level: unknown): LogLevel | null { - if (typeof level !== "string") { - return null; - } - const normalized = level.trim().toLowerCase(); - if ( - normalized === "debug" || - normalized === "info" || - normalized === "warn" || - normalized === "error" - ) { - return normalized; - } - return null; -} - function resolveDefaultMinLevel(): LogLevel { const globals = getLoggingGlobals(); - const explicitLogLevel = parseLogLevel(globals.__FT_LOG_LEVEL__); - if (explicitLogLevel) { + const explicitLogLevel = globals.__FT_LOG_LEVEL__; + if (isLogLevel(explicitLogLevel)) { return explicitLogLevel; } return globals.__FT_DEV_BUILD__ ? "debug" : "warn"; diff --git a/src/core/application/productivityStats/ProductivityStatsService.ts b/src/core/application/productivityStats/ProductivityStatsService.ts index 170c7856..1293450a 100644 --- a/src/core/application/productivityStats/ProductivityStatsService.ts +++ b/src/core/application/productivityStats/ProductivityStatsService.ts @@ -8,7 +8,10 @@ import { DonationPromptPolicy } from "@core/domain/productivityStats/DonationPro import { RecapPolicy } from "@core/domain/productivityStats/RecapPolicy"; import { StatsAggregator } from "@core/domain/productivityStats/StatsAggregator"; import { StatsSanitizer } from "@core/domain/productivityStats/StatsSanitizer"; -import type { ProductivityStatsState } from "@core/domain/productivityStats/types"; +import type { + DailyProductivityState, + ProductivityStatsState, +} from "@core/domain/productivityStats/types"; import { StatsRepository } from "./StatsRepository"; export class ProductivityStatsService { @@ -27,10 +30,50 @@ export class ProductivityStatsService { this.aggregator = new StatsAggregator(this.sanitizer); this.recapPolicy = new RecapPolicy(this.sanitizer, this.aggregator); this.donationPromptPolicy = new DonationPromptPolicy(this.sanitizer); - this.repository = new StatsRepository(settingsManager, this.sanitizer); + this.repository = new StatsRepository(settingsManager); this.now = options.now || (() => new Date()); } + private getTodayBucket( + state: ProductivityStatsState, + now: Date, + ): { todayKey: string; todayBucket: DailyProductivityState } { + const todayKey = this.sanitizer.toLocalDateKey(now); + return { + todayKey, + todayBucket: state.daily[todayKey] || this.sanitizer.createDailyState(), + }; + } + + private recordLanguageUsage( + state: ProductivityStatsState, + todayBucket: DailyProductivityState, + language: string, + charactersSaved: number, + ): void { + this.aggregator.incrementLanguageUsageCounter(state.languageUsage, language, charactersSaved); + this.aggregator.incrementLanguageUsageCounter( + todayBucket.languageUsage, + language, + charactersSaved, + ); + } + + private recordSnippetUsage( + state: ProductivityStatsState, + todayBucket: DailyProductivityState, + snippetKey: string, + update: { + countDelta?: number; + charsSavedDelta?: number; + charsInsertedDelta?: number; + charsTypedDelta?: number; + }, + ): void { + this.aggregator.incrementSnippetUsageCounter(state.snippetUsage, snippetKey, update); + this.aggregator.incrementSnippetUsageCounter(todayBucket.snippetUsage, snippetKey, update); + } + setSnippetShortcuts(textExpansions: unknown): void { if (!Array.isArray(textExpansions)) { this.snippetShortcuts = new Set(); @@ -49,8 +92,7 @@ export class ProductivityStatsService { async recordUsageEvent(event: ContentScriptUsageEventContext): Promise { await this.enqueueMutation((state) => { - const todayKey = this.sanitizer.toLocalDateKey(this.now()); - const todayBucket = state.daily[todayKey] || this.sanitizer.createDailyState(); + const { todayKey, todayBucket } = this.getTodayBucket(state, this.now()); switch (event.eventType) { case "suggestion_shown": { @@ -71,19 +113,10 @@ export class ProductivityStatsService { state.acceptedSuggestions += 1; state.charactersSaved += charactersSaved; - this.aggregator.incrementLanguageUsageCounter( - state.languageUsage, - language, - charactersSaved, - ); + this.recordLanguageUsage(state, todayBucket, language, charactersSaved); todayBucket.acceptedSuggestions += 1; todayBucket.charactersSaved += charactersSaved; - this.aggregator.incrementLanguageUsageCounter( - todayBucket.languageUsage, - language, - charactersSaved, - ); break; } @@ -99,18 +132,10 @@ export class ProductivityStatsService { state.snippetsExpanded += 1; todayBucket.snippetsExpanded += 1; - this.aggregator.incrementSnippetUsageCounter(state.snippetUsage, normalizedSnippetKey, { + this.recordSnippetUsage(state, todayBucket, normalizedSnippetKey, { countDelta: 1, charsSavedDelta: charactersSaved, }); - this.aggregator.incrementSnippetUsageCounter( - todayBucket.snippetUsage, - normalizedSnippetKey, - { - countDelta: 1, - charsSavedDelta: charactersSaved, - }, - ); break; } @@ -127,16 +152,9 @@ export class ProductivityStatsService { state.charsInsertedFromSnippet += insertedChars; todayBucket.charsInsertedFromSnippet += insertedChars; - this.aggregator.incrementSnippetUsageCounter(state.snippetUsage, normalizedSnippetKey, { + this.recordSnippetUsage(state, todayBucket, normalizedSnippetKey, { charsInsertedDelta: insertedChars, }); - this.aggregator.incrementSnippetUsageCounter( - todayBucket.snippetUsage, - normalizedSnippetKey, - { - charsInsertedDelta: insertedChars, - }, - ); break; } @@ -153,16 +171,9 @@ export class ProductivityStatsService { state.charsTypedForTrigger += typedChars; todayBucket.charsTypedForTrigger += typedChars; - this.aggregator.incrementSnippetUsageCounter(state.snippetUsage, normalizedSnippetKey, { + this.recordSnippetUsage(state, todayBucket, normalizedSnippetKey, { charsTypedDelta: typedChars, }); - this.aggregator.incrementSnippetUsageCounter( - todayBucket.snippetUsage, - normalizedSnippetKey, - { - charsTypedDelta: typedChars, - }, - ); break; } @@ -180,8 +191,7 @@ export class ProductivityStatsService { const state = await this.loadState(); const now = this.now(); - const todayKey = this.sanitizer.toLocalDateKey(now); - const todayBucket = state.daily[todayKey] || this.sanitizer.createDailyState(); + const { todayBucket } = this.getTodayBucket(state, now); const today = this.aggregator.metricsFromCounters( todayBucket.acceptedSuggestions, todayBucket.charactersSaved, @@ -225,7 +235,6 @@ export class ProductivityStatsService { const previousWeekStart = this.sanitizer.addDays(currentWeekStart, -7); const currentWeek = this.recapPolicy.summarizeWeek(state.daily, currentWeekStart); const previousWeek = this.recapPolicy.summarizeWeek(state.daily, previousWeekStart); - const weeklyRecap = previousWeek; const weekOverWeekDeltaPct = previousWeek.estimatedMinutesSaved > 0 @@ -238,7 +247,7 @@ export class ProductivityStatsService { const shouldShowWeeklyRecapCard = this.recapPolicy.shouldShowWeeklyRecap( state, - weeklyRecap, + previousWeek, now, ); @@ -254,13 +263,13 @@ export class ProductivityStatsService { topSnippets, weekOverWeekDeltaPct, milestoneProgress: this.aggregator.getMilestoneProgress(lifetime.estimatedMinutesSaved), - weeklyRecap, + weeklyRecap: previousWeek, shouldShowWeeklyRecap: shouldShowWeeklyRecapCard, donationPrompt: this.donationPromptPolicy.toDonationPrompt( state, lifetime, now, - weeklyRecap, + previousWeek, shouldShowWeeklyRecapCard, ), }; @@ -332,7 +341,7 @@ export class ProductivityStatsService { } private async loadState(): Promise { - return this.repository.loadState(); + return this.sanitizer.sanitizeStatsState(await this.repository.loadState()); } private async saveState(state: ProductivityStatsState): Promise { diff --git a/src/core/application/productivityStats/StatsRepository.ts b/src/core/application/productivityStats/StatsRepository.ts index 993b4ade..c2d168fe 100644 --- a/src/core/application/productivityStats/StatsRepository.ts +++ b/src/core/application/productivityStats/StatsRepository.ts @@ -1,20 +1,15 @@ import type { JsonValue, SettingsManager } from "@core/application/settingsManager"; import { getSettingStorageKey } from "@core/domain/contracts/settings"; -import type { StatsSanitizer } from "@core/domain/productivityStats/StatsSanitizer"; import type { ProductivityStatsState } from "@core/domain/productivityStats/types"; -import { readSettingWithAliases } from "../settings/SettingsMigrationV3"; +import { readSettingWithAliases } from "../settings/settingsAccess"; const PRODUCTIVITY_STATS_KEY = getSettingStorageKey("productivityStats"); export class StatsRepository { - constructor( - private readonly settingsManager: SettingsManager, - private readonly sanitizer: StatsSanitizer, - ) {} + constructor(private readonly settingsManager: SettingsManager) {} - async loadState(): Promise { - const rawState = await readSettingWithAliases(this.settingsManager, "productivityStats"); - return this.sanitizer.sanitizeStatsState(rawState); + async loadState(): Promise { + return readSettingWithAliases(this.settingsManager, "productivityStats"); } async saveState(state: ProductivityStatsState): Promise { diff --git a/src/core/application/repositories/SettingsRepositoryBase.ts b/src/core/application/repositories/SettingsRepositoryBase.ts index 6d6924db..57a8f48a 100644 --- a/src/core/application/repositories/SettingsRepositoryBase.ts +++ b/src/core/application/repositories/SettingsRepositoryBase.ts @@ -2,7 +2,7 @@ import type { JsonValue } from "../settingsManager"; import { SettingsManager } from "../settingsManager"; import type { SettingField, SettingsSchema } from "@core/domain/contracts/settings"; import { getSettingStorageKey } from "@core/domain/contracts/settings"; -import { readSettingWithAliases } from "../settings/SettingsMigrationV3"; +import { readSettingWithAliases } from "../settings/settingsAccess"; export class SettingsRepositoryBase { protected readonly settings: SettingsManager; diff --git a/src/core/application/settings/SettingsMigrationV3.ts b/src/core/application/settings/SettingsMigrationV3.ts index 9c9f50c6..670312de 100644 --- a/src/core/application/settings/SettingsMigrationV3.ts +++ b/src/core/application/settings/SettingsMigrationV3.ts @@ -5,57 +5,15 @@ import { getSettingStorageKey, } from "@core/domain/contracts/settings"; import { KEY_AUTO_CAPITALIZE, KEY_LEGACY_APPLY_SPACING_RULES } from "@core/domain/constants"; -import type { JsonValue } from "../settingsManager"; import type { SettingsManager } from "../settingsManager"; +import { + readFirstDefinedSetting, + readRawSetting, + removeRawSetting, + writeRawSetting, +} from "./settingsAccess"; -type RawSettingsAccess = { - getRaw?: (key: string) => Promise; - setRaw?: (key: string, value: JsonValue) => Promise; - removeRaw?: (key: string) => Promise; -}; - -async function readRaw(settings: SettingsManager, key: string): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.getRaw === "function") { - return maybeRawSettings.getRaw(key); - } - return settings.get(key); -} - -async function writeRaw(settings: SettingsManager, key: string, value: JsonValue): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.setRaw === "function") { - await maybeRawSettings.setRaw(key, value); - return; - } - await settings.set(key, value); -} - -async function removeRaw(settings: SettingsManager, key: string): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.removeRaw === "function") { - await maybeRawSettings.removeRaw(key); - return; - } - await settings.set(key, undefined as unknown as JsonValue); -} - -async function readFirstDefined(settings: SettingsManager, keys: string[]): Promise { - for (const key of keys) { - const value = await readRaw(settings, key); - if (typeof value !== "undefined") { - return value; - } - } - return undefined; -} - -export async function readSettingWithAliases( - settings: SettingsManager, - field: SettingField, -): Promise { - return readFirstDefined(settings, getSettingStorageAliases(field)); -} +export { readSettingWithAliases } from "./settingsAccess"; export async function migrateSettingsV3(settings: SettingsManager): Promise { try { @@ -64,49 +22,49 @@ export async function migrateSettingsV3(settings: SettingsManager): Promise String(rule)) : []; if (!currentRules.includes("capitalizeFirstLetter")) { - await writeRaw(settings, grammarKey, [ + await writeRawSetting(settings, grammarKey, [ ...currentRules, "capitalizeFirstLetter", - ] as JsonValue); + ] as never); } - await writeRaw(settings, KEY_AUTO_CAPITALIZE, false); + await writeRawSetting(settings, KEY_AUTO_CAPITALIZE, false as never); } } catch (error) { console.warn("[SettingsMigrationV3] Failed to migrate settings:", error); diff --git a/src/core/application/settings/SettingsMigrationV4.ts b/src/core/application/settings/SettingsMigrationV4.ts index 677e3925..a58ed4fa 100644 --- a/src/core/application/settings/SettingsMigrationV4.ts +++ b/src/core/application/settings/SettingsMigrationV4.ts @@ -4,50 +4,13 @@ import { KEY_GRAMMAR_RULES_V1_MIGRATED, } from "@core/domain/constants"; import { RECOMMENDED_V1_GRAMMAR_RULES } from "@core/domain/grammar/ruleCatalog"; -import type { JsonValue } from "../settingsManager"; import type { SettingsManager } from "../settingsManager"; - -type RawSettingsAccess = { - getRaw?: (key: string) => Promise; - setRaw?: (key: string, value: JsonValue) => Promise; -}; - -async function readRaw(settings: SettingsManager, key: string): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.getRaw === "function") { - return maybeRawSettings.getRaw(key); - } - return settings.get(key); -} - -async function writeRaw(settings: SettingsManager, key: string, value: JsonValue): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.setRaw === "function") { - await maybeRawSettings.setRaw(key, value); - return; - } - await settings.set(key, value); -} - -function getRawGrammarRulesSnapshot(existing: unknown): string[] { - if (!Array.isArray(existing)) { - return []; - } - // Preserve exact pre-migration order and duplicates for string entries. - return existing.filter((item): item is string => typeof item === "string"); -} - -function arraysEqual(left: string[], right: string[]): boolean { - if (left.length !== right.length) { - return false; - } - for (let i = 0; i < left.length; i += 1) { - if (left[i] !== right[i]) { - return false; - } - } - return true; -} +import { + areStringArraysEqual, + readRawSetting, + readStringArraySnapshot, + writeRawSetting, +} from "./settingsAccess"; function shouldForceV1Defaults(rawSnapshot: string[]): boolean { if (rawSnapshot.length === 0) { @@ -60,29 +23,29 @@ function shouldForceV1Defaults(rawSnapshot: string[]): boolean { export async function migrateSettingsV4(settings: SettingsManager): Promise { try { - const migrated = await readRaw(settings, KEY_GRAMMAR_RULES_V1_MIGRATED); + const migrated = await readRawSetting(settings, KEY_GRAMMAR_RULES_V1_MIGRATED); if (migrated === true) { return; } - const existing = await readRaw(settings, KEY_ENABLED_GRAMMAR_RULES); - const rawSnapshot = getRawGrammarRulesSnapshot(existing); + const existing = await readRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES); + const rawSnapshot = readStringArraySnapshot(existing); - const backup = await readRaw(settings, KEY_GRAMMAR_RULES_V1_BACKUP); + const backup = await readRawSetting(settings, KEY_GRAMMAR_RULES_V1_BACKUP); if (!Array.isArray(backup)) { - await writeRaw(settings, KEY_GRAMMAR_RULES_V1_BACKUP, rawSnapshot as JsonValue); + await writeRawSetting(settings, KEY_GRAMMAR_RULES_V1_BACKUP, rawSnapshot as never); } - const current = await readRaw(settings, KEY_ENABLED_GRAMMAR_RULES); - const currentSnapshot = getRawGrammarRulesSnapshot(current); - if (arraysEqual(currentSnapshot, rawSnapshot) && shouldForceV1Defaults(rawSnapshot)) { - await writeRaw( + const current = await readRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES); + const currentSnapshot = readStringArraySnapshot(current); + if (areStringArraysEqual(currentSnapshot, rawSnapshot) && shouldForceV1Defaults(rawSnapshot)) { + await writeRawSetting( settings, KEY_ENABLED_GRAMMAR_RULES, - RECOMMENDED_V1_GRAMMAR_RULES as JsonValue, + RECOMMENDED_V1_GRAMMAR_RULES as never, ); } - await writeRaw(settings, KEY_GRAMMAR_RULES_V1_MIGRATED, true as JsonValue); + await writeRawSetting(settings, KEY_GRAMMAR_RULES_V1_MIGRATED, true as never); } catch (error) { console.warn("[SettingsMigrationV4] Failed to migrate settings:", error); } diff --git a/src/core/application/settings/SettingsMigrationV5.ts b/src/core/application/settings/SettingsMigrationV5.ts index 353504ba..8e55f52a 100644 --- a/src/core/application/settings/SettingsMigrationV5.ts +++ b/src/core/application/settings/SettingsMigrationV5.ts @@ -7,82 +7,46 @@ import { RECOMMENDED_V1_GRAMMAR_RULES, RECOMMENDED_V2_GRAMMAR_RULES, } from "@core/domain/grammar/ruleCatalog"; -import type { JsonValue } from "../settingsManager"; import type { SettingsManager } from "../settingsManager"; - -type RawSettingsAccess = { - getRaw?: (key: string) => Promise; - setRaw?: (key: string, value: JsonValue) => Promise; -}; - -async function readRaw(settings: SettingsManager, key: string): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.getRaw === "function") { - return maybeRawSettings.getRaw(key); - } - return settings.get(key); -} - -async function writeRaw(settings: SettingsManager, key: string, value: JsonValue): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.setRaw === "function") { - await maybeRawSettings.setRaw(key, value); - return; - } - await settings.set(key, value); -} - -function getRawGrammarRulesSnapshot(existing: unknown): string[] { - if (!Array.isArray(existing)) { - return []; - } - return existing.filter((item): item is string => typeof item === "string"); -} - -function arraysEqual(left: string[], right: string[]): boolean { - if (left.length !== right.length) { - return false; - } - for (let i = 0; i < left.length; i += 1) { - if (left[i] !== right[i]) { - return false; - } - } - return true; -} +import { + areStringArraysEqual, + readRawSetting, + readStringArraySnapshot, + writeRawSetting, +} from "./settingsAccess"; function shouldForceV2Defaults(rawSnapshot: string[]): boolean { if (rawSnapshot.length === 0) { return true; } - return arraysEqual(rawSnapshot, RECOMMENDED_V1_GRAMMAR_RULES); + return areStringArraysEqual(rawSnapshot, RECOMMENDED_V1_GRAMMAR_RULES); } export async function migrateSettingsV5(settings: SettingsManager): Promise { try { - const migrated = await readRaw(settings, KEY_GRAMMAR_RULES_V2_MIGRATED); + const migrated = await readRawSetting(settings, KEY_GRAMMAR_RULES_V2_MIGRATED); if (migrated === true) { return; } - const existing = await readRaw(settings, KEY_ENABLED_GRAMMAR_RULES); - const rawSnapshot = getRawGrammarRulesSnapshot(existing); + const existing = await readRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES); + const rawSnapshot = readStringArraySnapshot(existing); - const backup = await readRaw(settings, KEY_GRAMMAR_RULES_V2_BACKUP); + const backup = await readRawSetting(settings, KEY_GRAMMAR_RULES_V2_BACKUP); if (!Array.isArray(backup)) { - await writeRaw(settings, KEY_GRAMMAR_RULES_V2_BACKUP, rawSnapshot as JsonValue); + await writeRawSetting(settings, KEY_GRAMMAR_RULES_V2_BACKUP, rawSnapshot as never); } - const current = await readRaw(settings, KEY_ENABLED_GRAMMAR_RULES); - const currentSnapshot = getRawGrammarRulesSnapshot(current); - if (arraysEqual(currentSnapshot, rawSnapshot) && shouldForceV2Defaults(rawSnapshot)) { - await writeRaw( + const current = await readRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES); + const currentSnapshot = readStringArraySnapshot(current); + if (areStringArraysEqual(currentSnapshot, rawSnapshot) && shouldForceV2Defaults(rawSnapshot)) { + await writeRawSetting( settings, KEY_ENABLED_GRAMMAR_RULES, - RECOMMENDED_V2_GRAMMAR_RULES as JsonValue, + RECOMMENDED_V2_GRAMMAR_RULES as never, ); } - await writeRaw(settings, KEY_GRAMMAR_RULES_V2_MIGRATED, true as JsonValue); + await writeRawSetting(settings, KEY_GRAMMAR_RULES_V2_MIGRATED, true as never); } catch (error) { console.warn("[SettingsMigrationV5] Failed to migrate settings:", error); } diff --git a/src/core/application/settings/SettingsMigrationV6.ts b/src/core/application/settings/SettingsMigrationV6.ts index 0de0bb91..d1d0c5bd 100644 --- a/src/core/application/settings/SettingsMigrationV6.ts +++ b/src/core/application/settings/SettingsMigrationV6.ts @@ -8,78 +8,42 @@ import { PRE_V3_RECOMMENDED_GRAMMAR_RULES, normalizeGrammarRuleSelection, } from "@core/domain/grammar/ruleCatalog"; -import type { JsonValue } from "../settingsManager"; import type { SettingsManager } from "../settingsManager"; - -type RawSettingsAccess = { - getRaw?: (key: string) => Promise; - setRaw?: (key: string, value: JsonValue) => Promise; -}; - -async function readRaw(settings: SettingsManager, key: string): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.getRaw === "function") { - return maybeRawSettings.getRaw(key); - } - return settings.get(key); -} - -async function writeRaw(settings: SettingsManager, key: string, value: JsonValue): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.setRaw === "function") { - await maybeRawSettings.setRaw(key, value); - return; - } - await settings.set(key, value); -} - -function getRawGrammarRulesSnapshot(existing: unknown): string[] { - if (!Array.isArray(existing)) { - return []; - } - return existing.filter((item): item is string => typeof item === "string"); -} - -function arraysEqual(left: string[], right: string[]): boolean { - if (left.length !== right.length) { - return false; - } - for (let i = 0; i < left.length; i += 1) { - if (left[i] !== right[i]) { - return false; - } - } - return true; -} +import { + areStringArraysEqual, + readRawSetting, + readStringArraySnapshot, + writeRawSetting, +} from "./settingsAccess"; export async function migrateSettingsV6(settings: SettingsManager): Promise { try { - const migrated = await readRaw(settings, KEY_GRAMMAR_RULES_V3_MIGRATED); + const migrated = await readRawSetting(settings, KEY_GRAMMAR_RULES_V3_MIGRATED); if (migrated === true) { return; } - const existing = await readRaw(settings, KEY_ENABLED_GRAMMAR_RULES); - const rawSnapshot = getRawGrammarRulesSnapshot(existing); + const existing = await readRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES); + const rawSnapshot = readStringArraySnapshot(existing); - const backup = await readRaw(settings, KEY_GRAMMAR_RULES_V3_BACKUP); + const backup = await readRawSetting(settings, KEY_GRAMMAR_RULES_V3_BACKUP); if (!Array.isArray(backup)) { - await writeRaw(settings, KEY_GRAMMAR_RULES_V3_BACKUP, rawSnapshot as JsonValue); + await writeRawSetting(settings, KEY_GRAMMAR_RULES_V3_BACKUP, rawSnapshot as never); } - const current = await readRaw(settings, KEY_ENABLED_GRAMMAR_RULES); - const currentSnapshot = getRawGrammarRulesSnapshot(current); - const rulesChangedSinceSnapshot = !arraysEqual(currentSnapshot, rawSnapshot); + const current = await readRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES); + const currentSnapshot = readStringArraySnapshot(current); + const rulesChangedSinceSnapshot = !areStringArraysEqual(currentSnapshot, rawSnapshot); const normalizedExisting = normalizeGrammarRuleSelection(rawSnapshot); if ( !rulesChangedSinceSnapshot && - arraysEqual(normalizedExisting, PRE_V3_RECOMMENDED_GRAMMAR_RULES) + areStringArraysEqual(normalizedExisting, PRE_V3_RECOMMENDED_GRAMMAR_RULES) ) { - await writeRaw(settings, KEY_ENABLED_GRAMMAR_RULES, DEFAULT_V3_GRAMMAR_RULES as JsonValue); + await writeRawSetting(settings, KEY_ENABLED_GRAMMAR_RULES, DEFAULT_V3_GRAMMAR_RULES as never); } - await writeRaw(settings, KEY_GRAMMAR_RULES_V3_MIGRATED, true as JsonValue); + await writeRawSetting(settings, KEY_GRAMMAR_RULES_V3_MIGRATED, true as never); } catch (error) { console.warn("[SettingsMigrationV6] Failed to migrate settings:", error); } diff --git a/src/core/application/settings/SettingsMigrationV7.ts b/src/core/application/settings/SettingsMigrationV7.ts index 0a8a2cf2..98521ab7 100644 --- a/src/core/application/settings/SettingsMigrationV7.ts +++ b/src/core/application/settings/SettingsMigrationV7.ts @@ -4,13 +4,8 @@ import { KEY_SUGGESTION_THEME_V1_MIGRATED, } from "@core/domain/constants"; import { DEFAULT_SUGGESTION_THEME_SETTINGS } from "@core/domain/themeDefaults"; -import type { JsonValue } from "../settingsManager"; import type { SettingsManager } from "../settingsManager"; - -type RawSettingsAccess = { - getRaw?: (key: string) => Promise; - setRaw?: (key: string, value: JsonValue) => Promise; -}; +import { readRawSetting, writeRawSetting } from "./settingsAccess"; const LEGACY_LIGHT_HIGHLIGHT_DEFAULTS = [ { @@ -27,23 +22,6 @@ const LEGACY_LIGHT_HIGHLIGHT_DEFAULTS = [ }, ] as const; -async function readRaw(settings: SettingsManager, key: string): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.getRaw === "function") { - return maybeRawSettings.getRaw(key); - } - return settings.get(key); -} - -async function writeRaw(settings: SettingsManager, key: string, value: JsonValue): Promise { - const maybeRawSettings = settings as unknown as RawSettingsAccess; - if (typeof maybeRawSettings.setRaw === "function") { - await maybeRawSettings.setRaw(key, value); - return; - } - await settings.set(key, value); -} - function normalizeString(value: unknown): string | null { return typeof value === "string" ? value.trim().toLowerCase() : null; } @@ -64,30 +42,30 @@ function matchesAnyLegacyLightDefault(background: unknown, text: unknown): boole export async function migrateSettingsV7(settings: SettingsManager): Promise { try { - const migrated = await readRaw(settings, KEY_SUGGESTION_THEME_V1_MIGRATED); + const migrated = await readRawSetting(settings, KEY_SUGGESTION_THEME_V1_MIGRATED); if (migrated === true) { return; } const [highlightBgLight, highlightTextLight] = await Promise.all([ - readRaw(settings, KEY_SUGGESTION_HIGHLIGHT_BG_LIGHT), - readRaw(settings, KEY_SUGGESTION_HIGHLIGHT_TEXT_LIGHT), + readRawSetting(settings, KEY_SUGGESTION_HIGHLIGHT_BG_LIGHT), + readRawSetting(settings, KEY_SUGGESTION_HIGHLIGHT_TEXT_LIGHT), ]); if (matchesAnyLegacyLightDefault(highlightBgLight, highlightTextLight)) { - await writeRaw( + await writeRawSetting( settings, KEY_SUGGESTION_HIGHLIGHT_BG_LIGHT, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightBgLight as JsonValue, + DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightBgLight as never, ); - await writeRaw( + await writeRawSetting( settings, KEY_SUGGESTION_HIGHLIGHT_TEXT_LIGHT, - DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightTextLight as JsonValue, + DEFAULT_SUGGESTION_THEME_SETTINGS.suggestionHighlightTextLight as never, ); } - await writeRaw(settings, KEY_SUGGESTION_THEME_V1_MIGRATED, true as JsonValue); + await writeRawSetting(settings, KEY_SUGGESTION_THEME_V1_MIGRATED, true as never); } catch (error) { console.warn("[SettingsMigrationV7] Failed to migrate settings:", error); } diff --git a/src/core/application/settings/settingsAccess.ts b/src/core/application/settings/settingsAccess.ts new file mode 100644 index 00000000..a20b0016 --- /dev/null +++ b/src/core/application/settings/settingsAccess.ts @@ -0,0 +1,81 @@ +import { getSettingStorageAliases, type SettingField } from "@core/domain/contracts/settings"; +import type { JsonValue, SettingsManager } from "../settingsManager"; + +type RawSettingsAccess = { + getRaw?: (key: string) => Promise; + setRaw?: (key: string, value: JsonValue) => Promise; + removeRaw?: (key: string) => Promise; +}; + +function getRawSettingsAccess(settings: SettingsManager): RawSettingsAccess { + return settings as unknown as RawSettingsAccess; +} + +export async function readRawSetting(settings: SettingsManager, key: string): Promise { + const maybeRawSettings = getRawSettingsAccess(settings); + if (typeof maybeRawSettings.getRaw === "function") { + return maybeRawSettings.getRaw(key); + } + return settings.get(key); +} + +export async function writeRawSetting( + settings: SettingsManager, + key: string, + value: JsonValue, +): Promise { + const maybeRawSettings = getRawSettingsAccess(settings); + if (typeof maybeRawSettings.setRaw === "function") { + await maybeRawSettings.setRaw(key, value); + return; + } + await settings.set(key, value); +} + +export async function removeRawSetting(settings: SettingsManager, key: string): Promise { + const maybeRawSettings = getRawSettingsAccess(settings); + if (typeof maybeRawSettings.removeRaw === "function") { + await maybeRawSettings.removeRaw(key); + return; + } + await settings.set(key, undefined as unknown as JsonValue); +} + +export async function readFirstDefinedSetting( + settings: SettingsManager, + keys: string[], +): Promise { + for (const key of keys) { + const value = await readRawSetting(settings, key); + if (typeof value !== "undefined") { + return value; + } + } + return undefined; +} + +export async function readSettingWithAliases( + settings: SettingsManager, + field: SettingField, +): Promise { + return readFirstDefinedSetting(settings, getSettingStorageAliases(field)); +} + +export function readStringArraySnapshot(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === "string"); +} + +export function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} diff --git a/src/core/domain/autoLanguageDetection.ts b/src/core/domain/autoLanguageDetection.ts index 7d6de3a4..e8f7a2f2 100644 --- a/src/core/domain/autoLanguageDetection.ts +++ b/src/core/domain/autoLanguageDetection.ts @@ -65,6 +65,7 @@ const QUALIFIED_TOKEN_THRESHOLD = 3; const DOCUMENT_HINT_BONUS = 0.15; const PAGE_HINT_BONUS = 0.1; const SITE_PRIOR_MAX_BONUS = 0.1; +const MAX_SITE_PRIOR_ENTRIES = 3; const STICKY_BONUS = 0.05; const GREEK_SCRIPT_REGEX = /[\u0370-\u03FF\u1F00-\u1FFF]/u; const LETTER_REGEX = /\p{L}/u; @@ -134,6 +135,10 @@ function compareCandidateScores( return left.language.localeCompare(right.language); } +function keepTopSitePriorEntries(entries: Array<[string, number]>): Array<[string, number]> { + return entries.sort((left, right) => right[1] - left[1]).slice(0, MAX_SITE_PRIOR_ENTRIES); +} + export function extractAutoLanguageSample(text: string): string { if (typeof text !== "string" || text.length === 0) { return ""; @@ -182,12 +187,11 @@ export function sanitizeAutoLanguageSitePriors( ) .map(([language, weight]) => [language, clampProbability(weight)] as const) .filter(([, weight]) => weight > 0) - .sort((left, right) => right[1] - left[1]) - .slice(0, 3); + .map(([language, weight]) => [language, weight] as [string, number]); if (normalizedEntries.length === 0) { continue; } - result[domain] = Object.fromEntries(normalizedEntries); + result[domain] = Object.fromEntries(keepTopSitePriorEntries(normalizedEntries)); } return result; } @@ -205,10 +209,9 @@ export function recordAutoLanguageSitePrior( } const increment = strong ? 0.35 : 0.15; current[language] = Math.min(1, clampProbability(current[language]) + increment); - const limited = Object.entries(current) - .filter(([, weight]) => weight > 0.01) - .sort((left, right) => right[1] - left[1]) - .slice(0, 3); + const limited = keepTopSitePriorEntries( + Object.entries(current).filter(([, weight]) => weight > 0.01), + ); if (limited.length === 0) { const withoutDomain = { ...next }; delete withoutDomain[domain]; diff --git a/src/core/domain/contracts/settings.ts b/src/core/domain/contracts/settings.ts index 0a1d80f5..441c0d57 100644 --- a/src/core/domain/contracts/settings.ts +++ b/src/core/domain/contracts/settings.ts @@ -170,6 +170,17 @@ const ALIASES_BY_CANONICAL: Record = { [SETTINGS_KEYS.suggestionPaddingHorizontal]: ["tributePaddingHorizontal"], }; +const CANONICAL_BY_STORAGE_KEY: Record = Object.entries(ALIASES_BY_CANONICAL).reduce( + (lookup, [canonical, aliases]) => { + lookup[canonical] = canonical; + for (const alias of aliases) { + lookup[alias] = canonical; + } + return lookup; + }, + {} as Record, +); + export function getSettingStorageKey(field: SettingField): string { return SETTINGS_KEYS[field]; } @@ -180,12 +191,7 @@ export function getSettingStorageAliases(field: SettingField): string[] { } export function resolveCanonicalSettingKey(key: string): string { - for (const [canonical, aliases] of Object.entries(ALIASES_BY_CANONICAL)) { - if (key === canonical || aliases.includes(key)) { - return canonical; - } - } - return key; + return CANONICAL_BY_STORAGE_KEY[key] || key; } export function getAliasesForCanonicalSettingKey(canonicalKey: string): string[] { diff --git a/src/core/domain/grammar/GrammarEditSequencing.ts b/src/core/domain/grammar/GrammarEditSequencing.ts index 92843309..f699ea8a 100644 --- a/src/core/domain/grammar/GrammarEditSequencing.ts +++ b/src/core/domain/grammar/GrammarEditSequencing.ts @@ -78,7 +78,3 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] }, ]; } - -export function mergeSequentialGrammarEdit(edits: GrammarEdit[]): GrammarEdit | null { - return mergeSequentialGrammarEdits(edits)[0] ?? null; -} diff --git a/src/core/domain/grammar/GrammarRuleEngine.ts b/src/core/domain/grammar/GrammarRuleEngine.ts index ecd5fd38..cfcdf611 100644 --- a/src/core/domain/grammar/GrammarRuleEngine.ts +++ b/src/core/domain/grammar/GrammarRuleEngine.ts @@ -1,10 +1,18 @@ import { applyGrammarEditToContext, - mergeSequentialGrammarEdit, mergeSequentialGrammarEdits, } from "./GrammarEditSequencing"; import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "./types"; +const MAX_PROCESS_ITERATIONS = 5; +const RULE_ERROR_THROTTLE_MS = 60_000; +const LEGACY_SOURCE_RULE_IDS = new Set(["spacingRule", "capitalizeFirstLetter"]); +type LegacyGrammarRuleId = "spacingRule" | "capitalizeFirstLetter"; + +function isLegacySourceRuleId(ruleId: GrammarRule["id"]): ruleId is LegacyGrammarRuleId { + return LEGACY_SOURCE_RULE_IDS.has(ruleId as LegacyGrammarRuleId); +} + export class GrammarRuleEngine { private rules: Map = new Map(); private pipelines: Map = new Map(); @@ -12,22 +20,15 @@ export class GrammarRuleEngine { private lastErrorTime: Map = new Map(); constructor() { - this.pipelines.set("insertChar", []); - this.pipelines.set("wordBoundary", []); - this.pipelines.set("idle", []); - this.pipelines.set("paste", []); + for (const trigger of ["insertChar", "wordBoundary", "idle", "paste"] as const) { + this.pipelines.set(trigger, []); + } } registerRule(rule: GrammarRule) { this.rules.set(rule.id, rule); for (const trigger of rule.triggers) { - if (!this.pipelines.has(trigger)) { - this.pipelines.set(trigger, []); - } - const pipeline = this.pipelines.get(trigger); - if (pipeline) { - pipeline.push(rule.id); - } + this.getPipeline(trigger).push(rule.id); } } @@ -36,21 +37,16 @@ export class GrammarRuleEngine { context: GrammarContext, enabledRules?: string[], ): GrammarEdit[] { - const pipeline = this.pipelines.get(event) || []; + const pipeline = this.getPipeline(event); let currentContext = { ...context }; const appliedEdits: GrammarEdit[] = []; - // Iterate to a steady state (max 5 iterations to prevent infinite loops) - let iteration = 0; - const MAX_ITERATIONS = 5; - let madeChanges = true; - - while (madeChanges && iteration < MAX_ITERATIONS) { - madeChanges = false; - iteration++; + // Iterate to a steady state, but stop after a small fixed budget to avoid loops. + for (let iteration = 0; iteration < MAX_PROCESS_ITERATIONS; iteration += 1) { + let madeChanges = false; for (const ruleId of pipeline) { - if (enabledRules && !enabledRules.includes(ruleId)) { + if (!this.shouldRunRule(ruleId, enabledRules)) { continue; } @@ -73,39 +69,20 @@ export class GrammarRuleEngine { for (const edit of edits) { const enrichedEdit: GrammarEdit = { ...edit, - sourceRuleId: - edit.sourceRuleId ?? - (rule.id === "spacingRule" || rule.id === "capitalizeFirstLetter" - ? undefined - : rule.id), + sourceRuleId: this.getSourceRuleId(rule, edit), }; appliedEdits.push(enrichedEdit); currentContext = applyGrammarEditToContext(currentContext, enrichedEdit); madeChanges = true; } } catch (error) { - // Rule evaluation failed, emit throttled warning to maintain observability - // without spamming the console and breaking prediction flow. - const errorCount = (this.errorCounters.get(ruleId) || 0) + 1; - this.errorCounters.set(ruleId, errorCount); - - const now = Date.now(); - const lastError = this.lastErrorTime.get(ruleId) || 0; - const THROTTLE_MS = 60000; // 1 minute per rule - - if (now - lastError > THROTTLE_MS) { - console.warn( - `[GrammarRuleEngine] Rule '${ruleId}' failed (occurrences: ${errorCount}):`, - error, - ); - this.lastErrorTime.set(ruleId, now); - } + this.recordRuleError(ruleId, error); } } - } - if (iteration === MAX_ITERATIONS) { - // Reached max iterations, possible infinite loop detected. Silently return what we have. + if (!madeChanges) { + break; + } } return mergeSequentialGrammarEdits(appliedEdits); @@ -131,7 +108,7 @@ export class GrammarRuleEngine { } } - return mergeSequentialGrammarEdit(accumulatedEdits); + return mergeSequentialGrammarEdits(accumulatedEdits)[0] ?? null; } getDebugSnapshot(): { errorCounters: Record } { @@ -139,4 +116,48 @@ export class GrammarRuleEngine { errorCounters: Object.fromEntries(this.errorCounters), }; } + + private getPipeline(event: GrammarEventType): string[] { + const pipeline = this.pipelines.get(event); + if (pipeline) { + return pipeline; + } + + const nextPipeline: string[] = []; + this.pipelines.set(event, nextPipeline); + return nextPipeline; + } + + private shouldRunRule(ruleId: string, enabledRules?: string[]): boolean { + return !enabledRules || enabledRules.includes(ruleId); + } + + private getSourceRuleId( + rule: GrammarRule, + edit: GrammarEdit, + ): GrammarEdit["sourceRuleId"] { + if (edit.sourceRuleId) { + return edit.sourceRuleId; + } + if (isLegacySourceRuleId(rule.id)) { + return undefined; + } + return rule.id; + } + + private recordRuleError(ruleId: string, error: unknown): void { + // Rule evaluation failures are throttled per rule so one bad rule does not spam logs. + const errorCount = (this.errorCounters.get(ruleId) || 0) + 1; + this.errorCounters.set(ruleId, errorCount); + + const now = Date.now(); + const lastError = this.lastErrorTime.get(ruleId) || 0; + if (now - lastError > RULE_ERROR_THROTTLE_MS) { + console.warn( + `[GrammarRuleEngine] Rule '${ruleId}' failed (occurrences: ${errorCount}):`, + error, + ); + this.lastErrorTime.set(ruleId, now); + } + } } diff --git a/src/core/domain/grammar/implementations/CapitalizeAfterLineBreakRule.ts b/src/core/domain/grammar/implementations/CapitalizeAfterLineBreakRule.ts index 043f87e8..08c138f6 100644 --- a/src/core/domain/grammar/implementations/CapitalizeAfterLineBreakRule.ts +++ b/src/core/domain/grammar/implementations/CapitalizeAfterLineBreakRule.ts @@ -1,5 +1,6 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { SPACE_CHARS } from "../../spacingRules"; +import { isLowercaseLetter } from "./helpers/GenericRuleShared"; export class CapitalizeAfterLineBreakRule implements GrammarRule { readonly id = "capitalizeAfterLineBreak" as const; @@ -13,7 +14,7 @@ export class CapitalizeAfterLineBreakRule implements GrammarRule { } const lastChar = text[text.length - 1]; - if (lastChar.toLowerCase() === lastChar.toUpperCase() || lastChar !== lastChar.toLowerCase()) { + if (!isLowercaseLetter(lastChar)) { return null; } diff --git a/src/core/domain/grammar/implementations/CapitalizeFirstLetterRule.ts b/src/core/domain/grammar/implementations/CapitalizeFirstLetterRule.ts index 1055c5e6..f2f24e7c 100644 --- a/src/core/domain/grammar/implementations/CapitalizeFirstLetterRule.ts +++ b/src/core/domain/grammar/implementations/CapitalizeFirstLetterRule.ts @@ -1,5 +1,6 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { SPACE_CHARS } from "../../spacingRules"; +import { isLowercaseLetter } from "./helpers/GenericRuleShared"; export class CapitalizeFirstLetterRule implements GrammarRule { readonly id = "capitalizeFirstLetter"; @@ -14,8 +15,7 @@ export class CapitalizeFirstLetterRule implements GrammarRule { const lastChar = text[text.length - 1]; - // We only capitalize valid lowercase alphabetic letters - if (lastChar.toLowerCase() === lastChar.toUpperCase() || lastChar !== lastChar.toLowerCase()) { + if (!isLowercaseLetter(lastChar)) { return null; } diff --git a/src/core/domain/grammar/implementations/CapitalizeSentenceStartRule.ts b/src/core/domain/grammar/implementations/CapitalizeSentenceStartRule.ts index 42fc2edf..6645b08c 100644 --- a/src/core/domain/grammar/implementations/CapitalizeSentenceStartRule.ts +++ b/src/core/domain/grammar/implementations/CapitalizeSentenceStartRule.ts @@ -1,5 +1,6 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { SPACE_CHARS } from "../../spacingRules"; +import { isLowercaseLetter } from "./helpers/GenericRuleShared"; const SENTENCE_ENDING_CHARS = new Set([".", "!", "?"]); const CLOSING_CHARS = new Set([")", "]", "}", '"', "'", "”", "’"]); @@ -16,7 +17,7 @@ export class CapitalizeSentenceStartRule implements GrammarRule { } const lastChar = text[text.length - 1]; - if (lastChar.toLowerCase() === lastChar.toUpperCase() || lastChar !== lastChar.toLowerCase()) { + if (!isLowercaseLetter(lastChar)) { return null; } diff --git a/src/core/domain/grammar/implementations/EnglishAlotCorrectionRule.ts b/src/core/domain/grammar/implementations/EnglishAlotCorrectionRule.ts index e697143b..3a5fbc17 100644 --- a/src/core/domain/grammar/implementations/EnglishAlotCorrectionRule.ts +++ b/src/core/domain/grammar/implementations/EnglishAlotCorrectionRule.ts @@ -1,11 +1,9 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { - hasTrailingTokenBoundary, - isEnglishLanguageContext, + normalizeWordSet, isLikelyCodeLikeContext, - resolveInputAction, + resolveEnglishBoundaryContext, resolveUserDictionarySet, - splitTrailingDelimiters, } from "./helpers/EnglishRuleShared"; import { detectWordCase } from "./helpers/GenericRuleShared"; @@ -19,32 +17,23 @@ export class EnglishAlotCorrectionRule implements GrammarRule { private readonly fallbackUserDictionary: Set; constructor(userDictionaryList: string[] = []) { - this.fallbackUserDictionary = new Set( - userDictionaryList.map((entry) => entry.trim().toLowerCase()).filter(Boolean), - ); + this.fallbackUserDictionary = normalizeWordSet(userDictionaryList); } apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const input = context.beforeCursor; - const { core, trailing } = splitTrailingDelimiters(input); - const match = core.match(ALOT_REGEX); + const match = boundaryContext.core.match(ALOT_REGEX); if (!match) { return null; } const phrase = match[0]; - const phraseStart = core.length - phrase.length; - if (isLikelyCodeLikeContext(core, phraseStart, core.length)) { + const phraseStart = boundaryContext.core.length - phrase.length; + if (isLikelyCodeLikeContext(boundaryContext.core, phraseStart, boundaryContext.core.length)) { return null; } @@ -57,8 +46,8 @@ export class EnglishAlotCorrectionRule implements GrammarRule { const replacementPhrase = style === "upper" ? "A LOT" : style === "title" ? "A lot" : "a lot"; return { - replacement: `${replacementPhrase}${trailing}`, - deleteBackwards: input.length - phraseStart, + replacement: `${replacementPhrase}${boundaryContext.trailing}`, + deleteBackwards: boundaryContext.input.length - phraseStart, deleteForwards: 0, confidence: "high", safetyTier: "safe", diff --git a/src/core/domain/grammar/implementations/EnglishContractionNormalizationRule.ts b/src/core/domain/grammar/implementations/EnglishContractionNormalizationRule.ts index 28e2561b..34fa6efb 100644 --- a/src/core/domain/grammar/implementations/EnglishContractionNormalizationRule.ts +++ b/src/core/domain/grammar/implementations/EnglishContractionNormalizationRule.ts @@ -2,10 +2,8 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from import { applyCasePattern, findTrailingLetterToken, - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - resolveInputAction, + resolveEnglishBoundaryContext, } from "./helpers/EnglishRuleShared"; const ENGLISH_CONTRACTION_MAP: Record = { @@ -38,17 +36,12 @@ export class EnglishContractionNormalizationRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["insertChar", "wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const tokenInfo = findTrailingLetterToken(context.beforeCursor); + const tokenInfo = findTrailingLetterToken(boundaryContext.input); if (!tokenInfo) { return null; } @@ -75,7 +68,7 @@ export class EnglishContractionNormalizationRule implements GrammarRule { return { replacement: `${normalizedToken}${tokenInfo.trailing}`, - deleteBackwards: context.beforeCursor.length - tokenInfo.tokenStart, + deleteBackwards: boundaryContext.input.length - tokenInfo.tokenStart, deleteForwards: 0, confidence: "high", description: "Normalized English contraction", diff --git a/src/core/domain/grammar/implementations/EnglishModalOfCorrectionRule.ts b/src/core/domain/grammar/implementations/EnglishModalOfCorrectionRule.ts index 07321882..cf6c757d 100644 --- a/src/core/domain/grammar/implementations/EnglishModalOfCorrectionRule.ts +++ b/src/core/domain/grammar/implementations/EnglishModalOfCorrectionRule.ts @@ -1,10 +1,7 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - resolveInputAction, - splitTrailingDelimiters, + resolveEnglishBoundaryContext, } from "./helpers/EnglishRuleShared"; import { applyWordCase, detectWordCase } from "./helpers/GenericRuleShared"; @@ -16,27 +13,20 @@ export class EnglishModalOfCorrectionRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const input = context.beforeCursor; - const { core, trailing } = splitTrailingDelimiters(input); - const match = core.match(MODAL_OF_REGEX); + const match = boundaryContext.core.match(MODAL_OF_REGEX); if (!match) { return null; } const phrase = match[0]; const modal = match[1]; - const phraseStart = core.length - phrase.length; - if (isLikelyCodeLikeContext(core, phraseStart, core.length)) { + const phraseStart = boundaryContext.core.length - phrase.length; + if (isLikelyCodeLikeContext(boundaryContext.core, phraseStart, boundaryContext.core.length)) { return null; } @@ -45,8 +35,8 @@ export class EnglishModalOfCorrectionRule implements GrammarRule { const haveWord = style === "upper" ? "HAVE" : "have"; return { - replacement: `${normalizedModal} ${haveWord}${trailing}`, - deleteBackwards: input.length - phraseStart, + replacement: `${normalizedModal} ${haveWord}${boundaryContext.trailing}`, + deleteBackwards: boundaryContext.input.length - phraseStart, deleteForwards: 0, confidence: "high", safetyTier: "safe", diff --git a/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts b/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts index 1f83e285..5b9b0ab6 100644 --- a/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts +++ b/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts @@ -1,10 +1,9 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { + type EnglishBoundaryContext, + resolveEnglishBoundaryContext, findTrailingLetterToken, - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - splitTrailingDelimiters, } from "./helpers/EnglishRuleShared"; const ENGLISH_APOSTROPHE_PRONOUN_REGEX = /(^|[^A-Za-z0-9_])(i)(['’](?:m|ve|ll|d))$/; @@ -15,23 +14,17 @@ export class EnglishPronounICapitalizationRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["insertChar", "wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - const inputStr = context.beforeCursor; - if (inputStr.length === 0) { - return null; - } - if (!hasTrailingTokenBoundary(inputStr)) { + const boundaryContext = resolveEnglishBoundaryContext(context, { ignoreDeleteInputAction: true }); + if (!boundaryContext) { return null; } - const apostropheCorrection = this.applyApostrophePronoun(inputStr); + const apostropheCorrection = this.applyApostrophePronoun(boundaryContext); if (apostropheCorrection) { return apostropheCorrection; } - const tokenInfo = findTrailingLetterToken(inputStr); + const tokenInfo = findTrailingLetterToken(boundaryContext.input); if (!tokenInfo || tokenInfo.token !== "i") { return null; } @@ -42,18 +35,15 @@ export class EnglishPronounICapitalizationRule implements GrammarRule { const replacement = `I${tokenInfo.trailing}`; return { replacement, - deleteBackwards: inputStr.length - tokenInfo.tokenStart, + deleteBackwards: boundaryContext.input.length - tokenInfo.tokenStart, deleteForwards: 0, confidence: "high", description: "Capitalized English pronoun I", }; } - private applyApostrophePronoun(inputStr: string): GrammarEdit | null { - const { core, trailing } = splitTrailingDelimiters(inputStr); - if (core.length === 0 || trailing.length === 0) { - return null; - } + private applyApostrophePronoun(boundaryContext: EnglishBoundaryContext): GrammarEdit | null { + const { core, trailing, input } = boundaryContext; const match = core.match(ENGLISH_APOSTROPHE_PRONOUN_REGEX); if (!match) { return null; @@ -71,7 +61,7 @@ export class EnglishPronounICapitalizationRule implements GrammarRule { const replacement = `I${core.slice(replaceStart + 1)}${trailing}`; return { replacement, - deleteBackwards: inputStr.length - replaceStart, + deleteBackwards: input.length - replaceStart, deleteForwards: 0, confidence: "high", description: "Capitalized English pronoun in contraction", diff --git a/src/core/domain/grammar/implementations/EnglishPronounVerbWhitelistAgreementRule.ts b/src/core/domain/grammar/implementations/EnglishPronounVerbWhitelistAgreementRule.ts index ead19f09..14c86833 100644 --- a/src/core/domain/grammar/implementations/EnglishPronounVerbWhitelistAgreementRule.ts +++ b/src/core/domain/grammar/implementations/EnglishPronounVerbWhitelistAgreementRule.ts @@ -1,10 +1,7 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - resolveInputAction, - splitTrailingDelimiters, + resolveEnglishBoundaryContext, } from "./helpers/EnglishRuleShared"; import { applyWordCase, detectWordCase } from "./helpers/GenericRuleShared"; @@ -35,26 +32,19 @@ export class EnglishPronounVerbWhitelistAgreementRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const input = context.beforeCursor; - const { core, trailing } = splitTrailingDelimiters(input); - const match = core.match(AGREEMENT_REGEX); + const match = boundaryContext.core.match(AGREEMENT_REGEX); if (!match) { return null; } const phrase = match[0]; - const phraseStart = core.length - phrase.length; - if (isLikelyCodeLikeContext(core, phraseStart, core.length)) { + const phraseStart = boundaryContext.core.length - phrase.length; + if (isLikelyCodeLikeContext(boundaryContext.core, phraseStart, boundaryContext.core.length)) { return null; } @@ -70,8 +60,8 @@ export class EnglishPronounVerbWhitelistAgreementRule implements GrammarRule { pronounStyle === "upper" && (inputPronoun || "").toLowerCase() !== "i" ? "upper" : "lower"; return { - replacement: `${applyWordCase(pronoun, pronounStyle)} ${applyWordCase(verb, verbStyle)}${trailing}`, - deleteBackwards: input.length - phraseStart, + replacement: `${applyWordCase(pronoun, pronounStyle)} ${applyWordCase(verb, verbStyle)}${boundaryContext.trailing}`, + deleteBackwards: boundaryContext.input.length - phraseStart, deleteForwards: 0, confidence: "high", safetyTier: "safe", diff --git a/src/core/domain/grammar/implementations/EnglishTheirThereBeVerbRule.ts b/src/core/domain/grammar/implementations/EnglishTheirThereBeVerbRule.ts index 6f50e234..6d125c2d 100644 --- a/src/core/domain/grammar/implementations/EnglishTheirThereBeVerbRule.ts +++ b/src/core/domain/grammar/implementations/EnglishTheirThereBeVerbRule.ts @@ -1,10 +1,7 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - resolveInputAction, - splitTrailingDelimiters, + resolveEnglishBoundaryContext, } from "./helpers/EnglishRuleShared"; import { applyWordCase, detectWordCase } from "./helpers/GenericRuleShared"; @@ -16,26 +13,19 @@ export class EnglishTheirThereBeVerbRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const input = context.beforeCursor; - const { core, trailing } = splitTrailingDelimiters(input); - const match = core.match(THEIR_THERE_BE_REGEX); + const match = boundaryContext.core.match(THEIR_THERE_BE_REGEX); if (!match) { return null; } const phrase = match[0]; - const phraseStart = core.length - phrase.length; - if (isLikelyCodeLikeContext(core, phraseStart, core.length)) { + const phraseStart = boundaryContext.core.length - phrase.length; + if (isLikelyCodeLikeContext(boundaryContext.core, phraseStart, boundaryContext.core.length)) { return null; } @@ -44,8 +34,8 @@ export class EnglishTheirThereBeVerbRule implements GrammarRule { const style = detectWordCase(firstToken); return { - replacement: `${applyWordCase("there", style)} ${applyWordCase(verb.toLowerCase(), detectWordCase(verb))}${trailing}`, - deleteBackwards: input.length - phraseStart, + replacement: `${applyWordCase("there", style)} ${applyWordCase(verb.toLowerCase(), detectWordCase(verb))}${boundaryContext.trailing}`, + deleteBackwards: boundaryContext.input.length - phraseStart, deleteForwards: 0, confidence: "high", safetyTier: "safe", diff --git a/src/core/domain/grammar/implementations/EnglishTypoWhitelistCorrectionRule.ts b/src/core/domain/grammar/implementations/EnglishTypoWhitelistCorrectionRule.ts index 5da6bfe5..aa533c21 100644 --- a/src/core/domain/grammar/implementations/EnglishTypoWhitelistCorrectionRule.ts +++ b/src/core/domain/grammar/implementations/EnglishTypoWhitelistCorrectionRule.ts @@ -2,10 +2,9 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from import { applyCasePattern, findTrailingLetterToken, - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - resolveInputAction, + normalizeWordSet, + resolveEnglishBoundaryContext, resolveUserDictionarySet, } from "./helpers/EnglishRuleShared"; @@ -30,23 +29,16 @@ export class EnglishTypoWhitelistCorrectionRule implements GrammarRule { private readonly fallbackUserDictionary: Set; constructor(userDictionaryList: string[] = []) { - this.fallbackUserDictionary = new Set( - userDictionaryList.map((entry) => entry.trim().toLowerCase()).filter(Boolean), - ); + this.fallbackUserDictionary = normalizeWordSet(userDictionaryList); } apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const tokenInfo = findTrailingLetterToken(context.beforeCursor); + const tokenInfo = findTrailingLetterToken(boundaryContext.input); if (!tokenInfo) { return null; } @@ -72,7 +64,7 @@ export class EnglishTypoWhitelistCorrectionRule implements GrammarRule { return { replacement: `${replacementToken}${tokenInfo.trailing}`, - deleteBackwards: context.beforeCursor.length - tokenInfo.tokenStart, + deleteBackwards: boundaryContext.input.length - tokenInfo.tokenStart, deleteForwards: 0, confidence: "high", description: "Corrected common English typo", diff --git a/src/core/domain/grammar/implementations/EnglishYourWelcomeCorrectionRule.ts b/src/core/domain/grammar/implementations/EnglishYourWelcomeCorrectionRule.ts index 56a9d0da..8a1e8cdf 100644 --- a/src/core/domain/grammar/implementations/EnglishYourWelcomeCorrectionRule.ts +++ b/src/core/domain/grammar/implementations/EnglishYourWelcomeCorrectionRule.ts @@ -1,10 +1,7 @@ import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; import { - hasTrailingTokenBoundary, - isEnglishLanguageContext, isLikelyCodeLikeContext, - resolveInputAction, - splitTrailingDelimiters, + resolveEnglishBoundaryContext, } from "./helpers/EnglishRuleShared"; import { applyWordCase, detectWordCase } from "./helpers/GenericRuleShared"; @@ -16,26 +13,19 @@ export class EnglishYourWelcomeCorrectionRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - if (!isEnglishLanguageContext(context)) { - return null; - } - if (resolveInputAction(context) === "delete") { - return null; - } - if (!hasTrailingTokenBoundary(context.beforeCursor)) { + const boundaryContext = resolveEnglishBoundaryContext(context); + if (!boundaryContext) { return null; } - const input = context.beforeCursor; - const { core, trailing } = splitTrailingDelimiters(input); - const match = core.match(YOUR_WELCOME_REGEX); + const match = boundaryContext.core.match(YOUR_WELCOME_REGEX); if (!match) { return null; } const phrase = match[0]; - const phraseStart = core.length - phrase.length; - if (isLikelyCodeLikeContext(core, phraseStart, core.length)) { + const phraseStart = boundaryContext.core.length - phrase.length; + if (isLikelyCodeLikeContext(boundaryContext.core, phraseStart, boundaryContext.core.length)) { return null; } @@ -44,8 +34,8 @@ export class EnglishYourWelcomeCorrectionRule implements GrammarRule { const correctedFirst = style === "upper" ? "YOU'RE" : style === "title" ? "You're" : "you're"; return { - replacement: `${correctedFirst} ${applyWordCase("welcome", style === "upper" ? "upper" : "lower")}${trailing}`, - deleteBackwards: input.length - phraseStart, + replacement: `${correctedFirst} ${applyWordCase("welcome", style === "upper" ? "upper" : "lower")}${boundaryContext.trailing}`, + deleteBackwards: boundaryContext.input.length - phraseStart, deleteForwards: 0, confidence: "high", safetyTier: "safe", diff --git a/src/core/domain/grammar/implementations/SpacingRule.ts b/src/core/domain/grammar/implementations/SpacingRule.ts index aeb57abb..2c7b59bf 100644 --- a/src/core/domain/grammar/implementations/SpacingRule.ts +++ b/src/core/domain/grammar/implementations/SpacingRule.ts @@ -5,21 +5,15 @@ import { Spacing, type SpacingRule as SpacingPolicy, } from "../../spacingRules"; +import { SpacingRuleShared } from "./helpers/SpacingRuleShared"; -export class SpacingRule implements GrammarRule { +export class SpacingRule extends SpacingRuleShared implements GrammarRule { readonly id = "spacingRule"; readonly name = "Spacing Rule"; readonly triggers: GrammarEventType[] = ["insertChar", "wordBoundary"]; - private static readonly CODE_CUE_CHARS = new Set("=([{:+-*/%&|!<>?,".split("")); - private static readonly MATH_OPERATORS = new Set(["=", "+", "*"]); - private static readonly OPENING_BRACKETS = new Set(["(", "[", "{"]); - private static readonly CLOSING_BRACKETS = new Set([")", "]", "}"]); - private static readonly CONTROL_KEYWORDS = new Set(["if", "for", "while", "switch", "catch"]); - - private insertSpaceAfterAutocomplete: boolean; constructor(insertSpaceAfterAutocomplete: boolean = true) { - this.insertSpaceAfterAutocomplete = insertSpaceAfterAutocomplete; + super(insertSpaceAfterAutocomplete); } apply(context: GrammarContext): GrammarEdit | null { @@ -40,73 +34,56 @@ export class SpacingRule implements GrammarRule { const length = inputStr.length; const lastChar = inputStr[length - 1]; - const lastCharMin1 = inputStr[length - 2]; - const lastCharMin2 = inputStr[length - 3]; + const previousChar = inputStr[length - 2]; + const charBeforePrevious = inputStr[length - 3]; - if (!lastCharMin1) { - return null; - } - if (!SPACING_RULES[lastChar]) { + if (!previousChar || !SPACING_RULES[lastChar]) { return null; } + const effectivePolicy = this.resolveEffectiveSpacingRule(inputStr, lastChar, length - 1); - if (!effectivePolicy) { - return null; - } - if (SPACE_CHARS.includes(lastCharMin2)) { + if ( + !effectivePolicy || + (charBeforePrevious !== undefined && SPACE_CHARS.includes(charBeforePrevious)) + ) { return null; } const requiresSpaceBefore = effectivePolicy.spaceBefore === Spacing.INSERT_SPACE; const requiresNoSpaceBefore = effectivePolicy.spaceBefore === Spacing.REMOVE_SPACE; - const hasSpaceBefore = SPACE_CHARS.includes(lastCharMin1); - + const hasSpaceBefore = SPACE_CHARS.includes(previousChar); const insertSpaceAfter = this.insertSpaceAfterAutocomplete && effectivePolicy.spaceAfter === Spacing.INSERT_SPACE; - const spaceBeforeViolated = (requiresSpaceBefore && !hasSpaceBefore) || (requiresNoSpaceBefore && hasSpaceBefore); - const inputAction = this.resolveInputAction(context); - // Respect explicit user deletion of the auto-inserted trailing space. - if (inputAction === "delete" && !spaceBeforeViolated && insertSpaceAfter) { + if (this.resolveInputAction(context) === "delete" && !spaceBeforeViolated && insertSpaceAfter) { return null; } - if (!spaceBeforeViolated && !insertSpaceAfter) { return null; } - let deleteBackwards: number; - let replacement: string; - if (spaceBeforeViolated) { - deleteBackwards = hasSpaceBefore ? 2 : 1; - const idealPrefix = requiresSpaceBefore ? "\xA0" : ""; - const idealSuffix = insertSpaceAfter ? "\xA0" : ""; - replacement = `${idealPrefix}${lastChar}${idealSuffix}`; - } else { - deleteBackwards = 1; - replacement = `${lastChar}\xA0`; + const replacement = `${requiresSpaceBefore ? "\xA0" : ""}${lastChar}${insertSpaceAfter ? "\xA0" : ""}`; + return { + replacement, + deleteBackwards: hasSpaceBefore ? 2 : 1, + deleteForwards: 0, + confidence: "high", + description: "Applied standard spacing rules for punctuation", + }; } return { - replacement, - deleteBackwards, + replacement: `${lastChar}\xA0`, + deleteBackwards: 1, deleteForwards: 0, confidence: "high", description: "Applied standard spacing rules for punctuation", }; } - private resolveInputAction(context: GrammarContext): "insert" | "delete" | "other" | null { - const candidate = context.hints?.inputAction; - if (candidate === "insert" || candidate === "delete" || candidate === "other") { - return candidate; - } - return null; - } - private applyTechnicalCompaction(inputStr: string): GrammarEdit | null { const length = inputStr.length; if (length < 4) { @@ -164,7 +141,7 @@ export class SpacingRule implements GrammarRule { const rightChar = inputStr[rightIndex]; const operatorIndex = rightIndex - 1; const operatorChar = inputStr[operatorIndex]; - if (!SpacingRule.MATH_OPERATORS.has(operatorChar)) { + if (!SpacingRuleShared.MATH_OPERATORS.has(operatorChar)) { return null; } @@ -181,9 +158,8 @@ export class SpacingRule implements GrammarRule { return null; } - const replacement = `${leftOperand.text}\xA0${operatorChar}\xA0${rightChar}`; return { - replacement, + replacement: `${leftOperand.text}\xA0${operatorChar}\xA0${rightChar}`, deleteBackwards: inputStr.length - leftOperand.start, deleteForwards: 0, confidence: "high", @@ -191,142 +167,6 @@ export class SpacingRule implements GrammarRule { }; } - private readLeftOperand( - inputStr: string, - operatorIndex: number, - ): { start: number; text: string; kind: "identifier" | "number" | "closingBracket" } | null { - const leftIndex = this.findPreviousSignificantIndex(inputStr, operatorIndex - 1); - if (leftIndex === null) { - return null; - } - - const leftChar = inputStr[leftIndex]; - if (SpacingRule.CLOSING_BRACKETS.has(leftChar)) { - return { start: leftIndex, text: leftChar, kind: "closingBracket" }; - } - - if (this.isDigit(leftChar)) { - const numericBounds = this.readNumericTokenBoundsAt(inputStr, leftIndex); - if (!numericBounds) { - return null; - } - return { - start: numericBounds.start, - text: inputStr.slice(numericBounds.start, numericBounds.end + 1), - kind: "number", - }; - } - - const tokenBounds = this.readIdentifierTokenBoundsAt(inputStr, leftIndex); - if (!tokenBounds) { - return null; - } - - return { - start: tokenBounds.start, - text: inputStr.slice(tokenBounds.start, tokenBounds.end + 1), - kind: "identifier", - }; - } - - private isEqualsRightOperandLike(ch: string | undefined): boolean { - if (!ch) { - return false; - } - if (this.isIdentifierStartChar(ch) || this.isDigit(ch)) { - return true; - } - if (["'", '"', "`"].includes(ch)) { - return true; - } - return SpacingRule.OPENING_BRACKETS.has(ch); - } - - private isArithmeticOperatorContext( - operatorChar: string, - leftOperand: { text: string; kind: "identifier" | "number" | "closingBracket" }, - rightChar: string, - ): boolean { - if (!["+", "*"].includes(operatorChar)) { - return false; - } - - const leftNumeric = leftOperand.kind === "number"; - const rightNumeric = this.isDigit(rightChar); - if (leftNumeric || rightNumeric) { - return true; - } - - const leftSingleIdentifier = leftOperand.kind === "identifier" && leftOperand.text.length === 1; - const rightSingleIdentifier = this.isIdentifierStartChar(rightChar); - return leftSingleIdentifier && rightSingleIdentifier; - } - - private readNumericTokenBoundsAt( - inputStr: string, - index: number, - ): { start: number; end: number } | null { - if (!this.isDigit(inputStr[index])) { - return null; - } - - let start = index; - while (start > 0 && /[0-9.]/.test(inputStr[start - 1])) { - start -= 1; - } - - let end = index; - while (end + 1 < inputStr.length && /[0-9.]/.test(inputStr[end + 1])) { - end += 1; - } - - return { start, end }; - } - - private shouldCompactAccessor(inputStr: string, punctIndex: number): boolean { - const tokenEnd = punctIndex - 1; - let tokenStart = tokenEnd; - - while (tokenStart >= 0 && this.isIdentifierChar(inputStr[tokenStart])) { - tokenStart -= 1; - } - - tokenStart += 1; - if (tokenStart > tokenEnd) { - return false; - } - - const previousSignificantIndex = this.findPreviousSignificantIndex(inputStr, tokenStart - 1); - if (previousSignificantIndex === null) { - const token = inputStr.slice(tokenStart, tokenEnd + 1); - return /\d/.test(token) || token.startsWith("$"); - } - - // Treat cue chars as code context only when tightly attached to the token - // before the dot (e.g. "obj.user. x"), not across sentence whitespace. - if (previousSignificantIndex !== tokenStart - 1) { - return false; - } - - const previousSignificant = inputStr[previousSignificantIndex]; - return previousSignificant === "." || SpacingRule.CODE_CUE_CHARS.has(previousSignificant); - } - - private findPreviousSignificantChar(inputStr: string, startIndex: number): string | null { - const index = this.findPreviousSignificantIndex(inputStr, startIndex); - return index === null ? null : inputStr[index]; - } - - private findPreviousSignificantIndex(inputStr: string, startIndex: number): number | null { - for (let i = startIndex; i >= 0; i -= 1) { - const ch = inputStr[i]; - if (!SPACE_CHARS.includes(ch)) { - return i; - } - } - return null; - } - private resolveEffectiveSpacingRule( inputStr: string, lastChar: string, @@ -337,14 +177,12 @@ export class SpacingRule implements GrammarRule { return null; } - if (SpacingRule.OPENING_BRACKETS.has(lastChar)) { + if (SpacingRuleShared.OPENING_BRACKETS.has(lastChar)) { return this.resolveOpeningBracketRule(inputStr, lastChar, lastIndex, baseRule); } - - if (SpacingRule.CLOSING_BRACKETS.has(lastChar)) { + if (SpacingRuleShared.CLOSING_BRACKETS.has(lastChar)) { return this.resolveClosingBracketRule(inputStr, lastChar, lastIndex); } - if (lastChar === "/") { return this.resolveSlashRule(inputStr, lastIndex, baseRule); } @@ -361,18 +199,15 @@ export class SpacingRule implements GrammarRule { if (openingBracket === "(" && this.isControlKeywordBeforeIndex(inputStr, openingIndex)) { return baseRule; } - if ( openingBracket === "{" && this.findPreviousSignificantChar(inputStr, openingIndex - 1) === ")" ) { return baseRule; } - if (this.isTightlyAttached(inputStr, openingIndex)) { return { ...baseRule, spaceBefore: Spacing.NO_CHANGE }; } - return baseRule; } @@ -381,11 +216,7 @@ export class SpacingRule implements GrammarRule { closingBracket: string, closingIndex: number, ): SpacingPolicy { - const shouldInsertAfter = this.isProseLikeClosingContext( - inputStr, - closingBracket, - closingIndex, - ); + const shouldInsertAfter = this.isProseLikeClosingContext(inputStr, closingBracket, closingIndex); return { spaceBefore: Spacing.REMOVE_SPACE, spaceAfter: shouldInsertAfter ? Spacing.INSERT_SPACE : Spacing.NO_CHANGE, @@ -403,14 +234,12 @@ export class SpacingRule implements GrammarRule { spaceAfter: Spacing.NO_CHANGE, }; } - if (this.isSlashOperatorContext(inputStr, slashIndex)) { return { spaceBefore: Spacing.INSERT_SPACE, spaceAfter: Spacing.INSERT_SPACE, }; } - return baseRule; } @@ -459,54 +288,6 @@ export class SpacingRule implements GrammarRule { return /[\p{L}\p{N}]/u.test(ch); } - private isTightlyAttached(inputStr: string, index: number): boolean { - if (index <= 0) { - return false; - } - return !SPACE_CHARS.includes(inputStr[index - 1]); - } - - private isControlKeywordBeforeIndex(inputStr: string, index: number): boolean { - const previousIndex = this.findPreviousSignificantIndex(inputStr, index - 1); - if (previousIndex === null) { - return false; - } - - const tokenBounds = this.readIdentifierTokenBoundsAt(inputStr, previousIndex); - if (!tokenBounds) { - return false; - } - - const token = inputStr.slice(tokenBounds.start, tokenBounds.end + 1).toLowerCase(); - if (!SpacingRule.CONTROL_KEYWORDS.has(token)) { - return false; - } - - const charBeforeToken = tokenBounds.start > 0 ? inputStr[tokenBounds.start - 1] : undefined; - return !this.isIdentifierChar(charBeforeToken); - } - - private readIdentifierTokenBoundsAt( - inputStr: string, - index: number, - ): { start: number; end: number } | null { - if (!this.isIdentifierChar(inputStr[index])) { - return null; - } - - let start = index; - while (start > 0 && this.isIdentifierChar(inputStr[start - 1])) { - start -= 1; - } - - let end = index; - while (end + 1 < inputStr.length && this.isIdentifierChar(inputStr[end + 1])) { - end += 1; - } - - return { start, end }; - } - private isProseLikeClosingContext( inputStr: string, closingBracket: string, @@ -550,55 +331,17 @@ export class SpacingRule implements GrammarRule { return false; } - private findMatchingOpeningIndex( - inputStr: string, - closingIndex: number, - openingBracket: string, - closingBracket: string, - ): number | null { - let depth = 0; - for (let i = closingIndex; i >= 0; i -= 1) { - const ch = inputStr[i]; - if (ch === closingBracket) { - depth += 1; - continue; - } - if (ch === openingBracket) { - depth -= 1; - if (depth === 0) { - return i; - } - } + // Preserve the legacy spacing rule's narrower straight-quote heuristic. + protected override isEqualsRightOperandLike(ch: string | undefined): boolean { + if (!ch) { + return false; } - return null; - } - - private getOpeningBracket(closingBracket: string): string | null { - switch (closingBracket) { - case ")": - return "("; - case "]": - return "["; - case "}": - return "{"; - default: - return null; + if (this.isIdentifierStartChar(ch) || this.isDigit(ch)) { + return true; } - } - - private isLikelyCodeContinuationChar(ch: string): boolean { - return this.isIdentifierChar(ch) || [")", "]", "}", ".", "'", '"', "`"].includes(ch); - } - - private isDigit(ch: string | undefined): boolean { - return typeof ch === "string" && ch >= "0" && ch <= "9"; - } - - private isIdentifierChar(ch: string | undefined): boolean { - return typeof ch === "string" && /[A-Za-z0-9_$]/.test(ch); - } - - private isIdentifierStartChar(ch: string | undefined): boolean { - return typeof ch === "string" && /[A-Za-z_$]/.test(ch); + if (["'", '"', "`"].includes(ch)) { + return true; + } + return SpacingRuleShared.OPENING_BRACKETS.has(ch); } } diff --git a/src/core/domain/grammar/implementations/helpers/EnglishRuleShared.ts b/src/core/domain/grammar/implementations/helpers/EnglishRuleShared.ts index 5e1e0893..cd0e468b 100644 --- a/src/core/domain/grammar/implementations/helpers/EnglishRuleShared.ts +++ b/src/core/domain/grammar/implementations/helpers/EnglishRuleShared.ts @@ -1,9 +1,19 @@ import type { GrammarContext } from "../../types"; +import { + normalizeWordSet as normalizeWordSetEntries, + resolveInputAction as resolveGrammarInputAction, +} from "./GenericRuleShared"; const TRAILING_DELIMITER_REGEX = /[\s.,!?;:)\]"}]/; const LETTER_REGEX = /[A-Za-z]/; const CODE_CONTEXT_CHARS = new Set(["=", "(", "[", "{", ":", "+", "-", "*", "/", "%", "&", "|"]); +export interface EnglishBoundaryContext { + input: string; + core: string; + trailing: string; +} + export interface TrailingTokenInfo { core: string; trailing: string; @@ -16,6 +26,8 @@ export function isEnglishLanguageContext(context: GrammarContext): boolean { return context.hints?.lang === "en_US"; } +export { normalizeWordSetEntries as normalizeWordSet }; + export function splitTrailingDelimiters(input: string): { core: string; trailing: string } { let coreEnd = input.length; while (coreEnd > 0 && TRAILING_DELIMITER_REGEX.test(input[coreEnd - 1])) { @@ -27,8 +39,26 @@ export function splitTrailingDelimiters(input: string): { core: string; trailing }; } -export function hasTrailingTokenBoundary(input: string): boolean { - return splitTrailingDelimiters(input).trailing.length > 0; +export function resolveEnglishBoundaryContext( + context: GrammarContext, + options: { ignoreDeleteInputAction?: boolean } = {}, +): EnglishBoundaryContext | null { + // Returns only edit-worthy English word-boundary contexts and keeps the + // original input plus the split core/trailing slices for rule-specific logic. + if (!isEnglishLanguageContext(context)) { + return null; + } + if (!options.ignoreDeleteInputAction && resolveGrammarInputAction(context) === "delete") { + return null; + } + + const input = context.beforeCursor; + const { core, trailing } = splitTrailingDelimiters(input); + if (trailing.length === 0) { + return null; + } + + return { input, core, trailing }; } export function findTrailingLetterToken(input: string): TrailingTokenInfo | null { @@ -98,14 +128,6 @@ export function applyCasePattern(inputWord: string, replacementWord: string): st return replacementWord.toLowerCase(); } -export function resolveInputAction(context: GrammarContext): "insert" | "delete" | "other" | null { - const action = context.hints?.inputAction; - if (action === "insert" || action === "delete" || action === "other") { - return action; - } - return null; -} - export function resolveUserDictionarySet( context: GrammarContext, fallbackSet: Set, @@ -114,5 +136,5 @@ export function resolveUserDictionarySet( if (!Array.isArray(dictionary)) { return fallbackSet; } - return new Set(dictionary.map((entry) => entry.trim().toLowerCase()).filter(Boolean)); + return normalizeWordSetEntries(dictionary); } diff --git a/src/core/domain/grammar/implementations/helpers/GenericRuleShared.ts b/src/core/domain/grammar/implementations/helpers/GenericRuleShared.ts index dd6d814e..e1422839 100644 --- a/src/core/domain/grammar/implementations/helpers/GenericRuleShared.ts +++ b/src/core/domain/grammar/implementations/helpers/GenericRuleShared.ts @@ -6,7 +6,15 @@ const EMAIL_LIKE_REGEX = /[^\s@]+@[^\s@]+\.[^\s@]+/; const CODE_TOKEN_REGEX = /[\\/_=<>`$]|::|->|=>|\w+\.\w+/; export function isDeleteInputAction(context: GrammarContext): boolean { - return context.hints?.inputAction === "delete"; + return resolveInputAction(context) === "delete"; +} + +export function resolveInputAction(context: GrammarContext): "insert" | "delete" | "other" | null { + const action = context.hints?.inputAction; + if (action === "insert" || action === "delete" || action === "other") { + return action; + } + return null; } export function splitTrailingSpaces(input: string): { core: string; trailingSpaces: string } { @@ -72,6 +80,14 @@ export function applyWordCase(word: string, style: "upper" | "title" | "lower"): return word.toLowerCase(); } +export function isLowercaseLetter(ch: string): boolean { + return ch.toLowerCase() !== ch.toUpperCase() && ch === ch.toLowerCase(); +} + +export function normalizeWordSet(entries: readonly string[]): Set { + return new Set(entries.map((entry) => entry.trim().toLowerCase()).filter(Boolean)); +} + export function isLikelyApostropheContext(inputBeforeQuote: string): boolean { if (inputBeforeQuote.length === 0) { return false; diff --git a/src/core/domain/grammar/implementations/helpers/SpacingRuleShared.ts b/src/core/domain/grammar/implementations/helpers/SpacingRuleShared.ts index 2ec8f510..665b21c0 100644 --- a/src/core/domain/grammar/implementations/helpers/SpacingRuleShared.ts +++ b/src/core/domain/grammar/implementations/helpers/SpacingRuleShared.ts @@ -1,5 +1,6 @@ import type { GrammarContext, GrammarEdit } from "../../types"; import { SPACE_CHARS } from "../../../spacingRules"; +import { resolveInputAction as resolveGrammarInputAction } from "./GenericRuleShared"; export abstract class SpacingRuleShared { protected static readonly CODE_CUE_CHARS = new Set("=([{:+-*/%&|!<>?,".split("")); @@ -16,11 +17,7 @@ export abstract class SpacingRuleShared { } protected resolveInputAction(context: GrammarContext): "insert" | "delete" | "other" | null { - const candidate = context.hints?.inputAction; - if (candidate === "insert" || candidate === "delete" || candidate === "other") { - return candidate; - } - return null; + return resolveGrammarInputAction(context); } protected createEdit( diff --git a/src/core/domain/grammar/ruleCatalog.ts b/src/core/domain/grammar/ruleCatalog.ts index 0f287bf4..b1213ca7 100644 --- a/src/core/domain/grammar/ruleCatalog.ts +++ b/src/core/domain/grammar/ruleCatalog.ts @@ -346,31 +346,28 @@ const V1_RECOMMENDED_RULES: CatalogRuleId[] = [ "neutralPunctuationPolicy", ]; -// This is the pre-v3 recommended set (current users migrated by V5). -const V2_RECOMMENDED_RULES: CatalogRuleId[] = [ - "capitalizeSentenceStart", - "capitalizeAfterLineBreak", +const V2_RECOMMENDED_MIDDLE_RULES: CatalogRuleId[] = [ "englishPronounICapitalization", "englishContractionNormalization", "englishTypoWhitelistCorrection", - "technicalTokenCompaction", - "mathOperatorSpacing", - "slashContextSpacing", - "openingBracketSpacing", - "closingBracketSpacing", - "commaPeriodSpacing", - "collapseRepeatedSpaces", - "trimSpaceBeforeLineBreak", - "neutralPunctuationPolicy", ]; -export const DEFAULT_V1_GRAMMAR_RULES: CatalogRuleId[] = V1_RECOMMENDED_RULES.slice(); +// This is the pre-v3 recommended set (current users migrated by V5). +const V2_RECOMMENDED_RULES: CatalogRuleId[] = [ + ...V1_RECOMMENDED_RULES.slice(0, 2), + ...V2_RECOMMENDED_MIDDLE_RULES, + ...V1_RECOMMENDED_RULES.slice(2), +]; + +const copyRuleIds = (ruleIds: readonly CatalogRuleId[]): CatalogRuleId[] => [...ruleIds]; + +export const DEFAULT_V1_GRAMMAR_RULES: CatalogRuleId[] = copyRuleIds(V1_RECOMMENDED_RULES); -export const RECOMMENDED_V1_GRAMMAR_RULES: CatalogRuleId[] = V1_RECOMMENDED_RULES.slice(); +export const RECOMMENDED_V1_GRAMMAR_RULES: CatalogRuleId[] = copyRuleIds(V1_RECOMMENDED_RULES); -export const DEFAULT_V2_GRAMMAR_RULES: CatalogRuleId[] = V2_RECOMMENDED_RULES.slice(); +export const DEFAULT_V2_GRAMMAR_RULES: CatalogRuleId[] = copyRuleIds(V2_RECOMMENDED_RULES); -export const RECOMMENDED_V2_GRAMMAR_RULES: CatalogRuleId[] = V2_RECOMMENDED_RULES.slice(); +export const RECOMMENDED_V2_GRAMMAR_RULES: CatalogRuleId[] = copyRuleIds(V2_RECOMMENDED_RULES); export const DEFAULT_V3_GRAMMAR_RULES: CatalogRuleId[] = GRAMMAR_RULE_CATALOG.filter( (entry) => entry.defaultRollout === "on", diff --git a/src/core/domain/grammar/ruleFactory.ts b/src/core/domain/grammar/ruleFactory.ts index d6998f8d..b9c8f169 100644 --- a/src/core/domain/grammar/ruleFactory.ts +++ b/src/core/domain/grammar/ruleFactory.ts @@ -30,11 +30,10 @@ export function createGrammarRuleCatalogRuntime(options: { insertSpaceAfterAutocomplete: boolean; userDictionaryList: string[]; }): GrammarRule[] { - const spacingOptions = { - insertSpaceAfterAutocomplete: options.insertSpaceAfterAutocomplete, - }; + const insertSpaceAfterAutocomplete = options.insertSpaceAfterAutocomplete; const ruleById: Record = { + // Core v1/v2 language rules. capitalizeSentenceStart: new CapitalizeSentenceStartRule(), capitalizeAfterLineBreak: new CapitalizeAfterLineBreakRule(), englishPronounICapitalization: new EnglishPronounICapitalizationRule(), @@ -48,21 +47,19 @@ export function createGrammarRuleCatalogRuntime(options: { englishTheirThereBeVerb: new EnglishTheirThereBeVerbRule(), englishAlotCorrection: new EnglishAlotCorrectionRule(options.userDictionaryList), englishPronounVerbWhitelistAgreement: new EnglishPronounVerbWhitelistAgreementRule(), - commaPeriodSpacing: new CommaPeriodSpacingRule(spacingOptions.insertSpaceAfterAutocomplete), - openingBracketSpacing: new OpeningBracketSpacingRule( - spacingOptions.insertSpaceAfterAutocomplete, - ), - closingBracketSpacing: new ClosingBracketSpacingRule( - spacingOptions.insertSpaceAfterAutocomplete, - ), - slashContextSpacing: new SlashContextSpacingRule(spacingOptions.insertSpaceAfterAutocomplete), - mathOperatorSpacing: new MathOperatorSpacingRule(spacingOptions.insertSpaceAfterAutocomplete), - technicalTokenCompaction: new TechnicalTokenCompactionRule( - spacingOptions.insertSpaceAfterAutocomplete, - ), + + // Spacing and punctuation rules share the autocomplete spacing toggle. + commaPeriodSpacing: new CommaPeriodSpacingRule(insertSpaceAfterAutocomplete), + openingBracketSpacing: new OpeningBracketSpacingRule(insertSpaceAfterAutocomplete), + closingBracketSpacing: new ClosingBracketSpacingRule(insertSpaceAfterAutocomplete), + slashContextSpacing: new SlashContextSpacingRule(insertSpaceAfterAutocomplete), + mathOperatorSpacing: new MathOperatorSpacingRule(insertSpaceAfterAutocomplete), + technicalTokenCompaction: new TechnicalTokenCompactionRule(insertSpaceAfterAutocomplete), collapseRepeatedSpaces: new CollapseRepeatedSpacesRule(), trimSpaceBeforeLineBreak: new TrimSpaceBeforeLineBreakRule(), neutralPunctuationPolicy: new NeutralPunctuationPolicyRule(), + + // Advanced rules stay grouped together so the catalog order is the only priority source. ellipsisShortcut: new EllipsisShortcutRule(), emdashShortcut: new EmdashShortcutRule(), smartQuoteNormalization: new SmartQuoteNormalizationRule(), diff --git a/src/core/domain/messageTypes.d.ts b/src/core/domain/messageTypes.d.ts index dca0be1d..bdbd8a72 100644 --- a/src/core/domain/messageTypes.d.ts +++ b/src/core/domain/messageTypes.d.ts @@ -5,6 +5,22 @@ import type { } from "./observability"; // Context for CMD_BACKGROUND_PAGE_SET_CONFIG +export interface SuggestionThemeConfig { + suggestionBgLight: string; + suggestionTextLight: string; + suggestionHighlightBgLight: string; + suggestionHighlightTextLight: string; + suggestionBorderLight: string; + suggestionBgDark: string; + suggestionTextDark: string; + suggestionHighlightBgDark: string; + suggestionHighlightTextDark: string; + suggestionBorderDark: string; + suggestionFontSize: string; + suggestionPaddingVertical: string; + suggestionPaddingHorizontal: string; +} + export interface SetConfigContext { autocomplete: boolean; autocompleteOnEnter: boolean; @@ -19,22 +35,8 @@ export interface SetConfigContext { displayLangHeader: boolean; enabledGrammarRules: string[]; userDictionaryList: string[]; - // Theme configuration - themeConfig?: { - suggestionBgLight: string; - suggestionTextLight: string; - suggestionHighlightBgLight: string; - suggestionHighlightTextLight: string; - suggestionBorderLight: string; - suggestionBgDark: string; - suggestionTextDark: string; - suggestionHighlightBgDark: string; - suggestionHighlightTextDark: string; - suggestionBorderDark: string; - suggestionFontSize: string; - suggestionPaddingVertical: string; - suggestionPaddingHorizontal: string; - }; + // Theme configuration is reused by settings and options payloads. + themeConfig?: SuggestionThemeConfig; observability?: ObservabilityConfig; } diff --git a/src/core/domain/productivityStats/DonationPromptPolicy.ts b/src/core/domain/productivityStats/DonationPromptPolicy.ts index 053fd354..87f0e263 100644 --- a/src/core/domain/productivityStats/DonationPromptPolicy.ts +++ b/src/core/domain/productivityStats/DonationPromptPolicy.ts @@ -29,6 +29,7 @@ export class DonationPromptPolicy { return null; } + // Weekly recap cards reuse the same donation surface, so they take priority. if (shouldShowWeeklyRecapCard) { return { promptId: `weekly_recap_${weeklyRecap.weekKey}`, @@ -57,6 +58,7 @@ export class DonationPromptPolicy { (lifetime.acceptedSuggestions >= DONATION_FIRST_VALUE_ACCEPTS || lifetime.estimatedMinutesSaved >= DONATION_FIRST_VALUE_MINUTES) ) { + // The first-value ask appears once the user has clearly seen the product save time. return { promptId: "first_value", kind: "first_value", diff --git a/src/core/domain/productivityStats/RecapPolicy.ts b/src/core/domain/productivityStats/RecapPolicy.ts index fb3ea07c..e17197ec 100644 --- a/src/core/domain/productivityStats/RecapPolicy.ts +++ b/src/core/domain/productivityStats/RecapPolicy.ts @@ -14,6 +14,10 @@ export class RecapPolicy { private readonly aggregator: StatsAggregator, ) {} + private estimateHoursSaved(acceptedSuggestions: number, charactersSaved: number): number { + return this.aggregator.estimateMinutesSaved(acceptedSuggestions, charactersSaved) / 60; + } + summarizeWeek( daily: Record, weekStart: Date, @@ -26,16 +30,14 @@ export class RecapPolicy { ); const throughWeek = this.aggregator.aggregateThroughDate(daily, weekEnd); - const beforeWeekHours = - this.aggregator.estimateMinutesSaved( - beforeWeek.acceptedSuggestions, - beforeWeek.charactersSaved, - ) / 60; - const throughWeekHours = - this.aggregator.estimateMinutesSaved( - throughWeek.acceptedSuggestions, - throughWeek.charactersSaved, - ) / 60; + const beforeWeekHours = this.estimateHoursSaved( + beforeWeek.acceptedSuggestions, + beforeWeek.charactersSaved, + ); + const throughWeekHours = this.estimateHoursSaved( + throughWeek.acceptedSuggestions, + throughWeek.charactersSaved, + ); const milestonesCrossedHours = DONATION_MILESTONE_HOURS.filter( (milestone) => beforeWeekHours < milestone && throughWeekHours >= milestone, @@ -71,6 +73,7 @@ export class RecapPolicy { const expectedRecapWeekKey = this.sanitizer.toLocalDateKey( this.sanitizer.addDays(currentWeekStart, -7), ); + // Only surface the previous completed week, and only after the local reveal hour. if (weeklyRecap.weekKey !== expectedRecapWeekKey) { return false; } diff --git a/src/core/domain/productivityStats/StatsAggregator.ts b/src/core/domain/productivityStats/StatsAggregator.ts index beb10a83..c09d1e50 100644 --- a/src/core/domain/productivityStats/StatsAggregator.ts +++ b/src/core/domain/productivityStats/StatsAggregator.ts @@ -22,6 +22,40 @@ import type { export class StatsAggregator { constructor(private readonly sanitizer: StatsSanitizer) {} + private createAggregatedCounters(): AggregatedCounters { + return { + acceptedSuggestions: 0, + charactersSaved: 0, + suggestionsShown: 0, + snippetsExpanded: 0, + charsInsertedFromSnippet: 0, + charsTypedForTrigger: 0, + snippetUsage: {}, + languageUsage: {}, + }; + } + + private createLanguageUsageCounters(): LanguageUsageCounters { + return { + acceptedSuggestions: 0, + charactersSaved: 0, + }; + } + + private addLanguageUsageCounters( + usageMap: Record, + language: string, + acceptedSuggestions: number, + charactersSaved: number, + ): void { + if (!usageMap[language]) { + usageMap[language] = this.createLanguageUsageCounters(); + } + + usageMap[language].acceptedSuggestions += acceptedSuggestions; + usageMap[language].charactersSaved += charactersSaved; + } + estimateMinutesSaved(acceptedSuggestions: number, charactersSaved: number): number { const typingMinutes = charactersSaved / TYPING_CHARACTERS_PER_MINUTE; const acceptanceMinutes = (acceptedSuggestions * ACCEPTANCE_BONUS_SECONDS) / 60; @@ -78,15 +112,7 @@ export class StatsAggregator { language: string, charactersSaved: number, ): void { - if (!usageMap[language]) { - usageMap[language] = { - acceptedSuggestions: 0, - charactersSaved: 0, - }; - } - - usageMap[language].acceptedSuggestions += 1; - usageMap[language].charactersSaved += charactersSaved; + this.addLanguageUsageCounters(usageMap, language, 1, charactersSaved); } aggregateRange( @@ -94,22 +120,14 @@ export class StatsAggregator { start: Date, end: Date, ): AggregatedCounters { - const counters: AggregatedCounters = { - acceptedSuggestions: 0, - charactersSaved: 0, - suggestionsShown: 0, - snippetsExpanded: 0, - charsInsertedFromSnippet: 0, - charsTypedForTrigger: 0, - snippetUsage: {}, - languageUsage: {}, - }; + const counters = this.createAggregatedCounters(); const cursor = this.sanitizer.startOfLocalDay(start); const endKey = this.sanitizer.toLocalDateKey(end); while (this.sanitizer.toLocalDateKey(cursor) <= endKey) { - const entry = daily[this.sanitizer.toLocalDateKey(cursor)]; + const dayKey = this.sanitizer.toLocalDateKey(cursor); + const entry = daily[dayKey]; if (entry) { counters.acceptedSuggestions += entry.acceptedSuggestions; counters.charactersSaved += entry.charactersSaved; @@ -128,14 +146,12 @@ export class StatsAggregator { } for (const [language, values] of Object.entries(entry.languageUsage)) { - if (!counters.languageUsage[language]) { - counters.languageUsage[language] = { - acceptedSuggestions: 0, - charactersSaved: 0, - }; - } - counters.languageUsage[language].acceptedSuggestions += values.acceptedSuggestions; - counters.languageUsage[language].charactersSaved += values.charactersSaved; + this.addLanguageUsageCounters( + counters.languageUsage, + language, + values.acceptedSuggestions, + values.charactersSaved, + ); } } @@ -250,16 +266,14 @@ export class StatsAggregator { ): ProductivityDashboardStats["milestoneProgress"] { const lifetimeHoursSaved = this.sanitizer.roundMetric(lifetimeMinutesSaved / 60); const previousMilestoneHours = - DONATION_MILESTONE_HOURS.filter((milestone) => lifetimeHoursSaved >= milestone).sort( - (left, right) => right - left, - )[0] || 0; + [...DONATION_MILESTONE_HOURS].reverse().find((milestone) => lifetimeHoursSaved >= milestone) || + 0; + const highestDefinedMilestone = + DONATION_MILESTONE_HOURS[DONATION_MILESTONE_HOURS.length - 1] || 0; let nextMilestoneHours = DONATION_MILESTONE_HOURS.find((milestone) => lifetimeHoursSaved < milestone) || - Math.max( - DONATION_MILESTONE_HOURS[DONATION_MILESTONE_HOURS.length - 1] + 5, - Math.ceil(lifetimeHoursSaved / 5) * 5, - ); + Math.max(highestDefinedMilestone + 5, Math.ceil(lifetimeHoursSaved / 5) * 5); if (nextMilestoneHours <= previousMilestoneHours) { nextMilestoneHours = previousMilestoneHours + 5; diff --git a/src/core/domain/productivityStats/StatsSanitizer.ts b/src/core/domain/productivityStats/StatsSanitizer.ts index 2adeeda5..355a5df9 100644 --- a/src/core/domain/productivityStats/StatsSanitizer.ts +++ b/src/core/domain/productivityStats/StatsSanitizer.ts @@ -11,6 +11,13 @@ export class StatsSanitizer { return typeof value === "object" && value !== null && !Array.isArray(value); } + private normalizeTrimmedString(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + return value.trim(); + } + clampCount(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) { return 0; @@ -26,17 +33,11 @@ export class StatsSanitizer { } normalizeSnippetKey(value: unknown): string { - if (typeof value !== "string") { - return ""; - } - return value.trim().toLocaleLowerCase().slice(0, 80); + return this.normalizeTrimmedString(value).toLocaleLowerCase().slice(0, 80); } normalizeLanguageKey(value: unknown): string { - if (typeof value !== "string") { - return "unknown"; - } - const normalized = value.trim(); + const normalized = this.normalizeTrimmedString(value); if (!normalized) { return "unknown"; } @@ -93,7 +94,7 @@ export class StatsSanitizer { }; } - createDailyState(): DailyProductivityState { + private createZeroCounters(): DailyProductivityState { return { acceptedSuggestions: 0, charactersSaved: 0, @@ -106,17 +107,14 @@ export class StatsSanitizer { }; } + createDailyState(): DailyProductivityState { + return this.createZeroCounters(); + } + createDefaultStatsState(): ProductivityStatsState { return { schemaVersion: STATS_SCHEMA_VERSION, - acceptedSuggestions: 0, - charactersSaved: 0, - suggestionsShown: 0, - snippetsExpanded: 0, - charsInsertedFromSnippet: 0, - charsTypedForTrigger: 0, - snippetUsage: {}, - languageUsage: {}, + ...this.createZeroCounters(), daily: {}, shownMilestones: [], firstValuePromptAcknowledged: false, @@ -167,12 +165,8 @@ export class StatsSanitizer { if (typeof rawValue === "number") { const count = this.clampCount(rawValue); if (count > 0) { - counters = { - count, - charactersSaved: 0, - charsInserted: 0, - charsTyped: 0, - }; + counters = this.createSnippetCounters(); + counters.count = count; } } else if (this.isObjectRecord(rawValue)) { const count = this.clampCount(rawValue.count); @@ -180,12 +174,11 @@ export class StatsSanitizer { const charsInserted = this.clampCount(rawValue.charsInserted); const charsTyped = this.clampCount(rawValue.charsTyped); if (count > 0 || charactersSaved > 0 || charsInserted > 0 || charsTyped > 0) { - counters = { - count, - charactersSaved, - charsInserted, - charsTyped, - }; + counters = this.createSnippetCounters(); + counters.count = count; + counters.charactersSaved = charactersSaved; + counters.charsInserted = charsInserted; + counters.charsTyped = charsTyped; } } @@ -250,6 +243,9 @@ export class StatsSanitizer { return this.createDefaultStatsState(); } + const lastDonationPromptAt = this.parseIsoDate(value.lastDonationPromptAt); + const donationSnoozedUntil = this.parseIsoDate(value.donationSnoozedUntil); + return { schemaVersion: STATS_SCHEMA_VERSION, acceptedSuggestions: this.clampCount(value.acceptedSuggestions), @@ -269,12 +265,8 @@ export class StatsSanitizer { firstValuePromptAcknowledged: value.firstValuePromptAcknowledged === true, lastWeeklyRecapWeek: typeof value.lastWeeklyRecapWeek === "string" ? value.lastWeeklyRecapWeek : null, - lastDonationPromptAt: this.parseIsoDate(value.lastDonationPromptAt) - ? (value.lastDonationPromptAt as string) - : null, - donationSnoozedUntil: this.parseIsoDate(value.donationSnoozedUntil) - ? (value.donationSnoozedUntil as string) - : null, + lastDonationPromptAt: lastDonationPromptAt ? (value.lastDonationPromptAt as string) : null, + donationSnoozedUntil: donationSnoozedUntil ? (value.donationSnoozedUntil as string) : null, }; } } diff --git a/src/core/domain/siteProfiles.ts b/src/core/domain/siteProfiles.ts index 756c3fbb..7c826f73 100644 --- a/src/core/domain/siteProfiles.ts +++ b/src/core/domain/siteProfiles.ts @@ -9,6 +9,13 @@ export interface SiteProfile { export type SiteProfiles = Record; +function toRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + export function normalizeDomainHost(domainOrUrl: string): string | undefined { if (typeof domainOrUrl !== "string") { return undefined; @@ -62,10 +69,10 @@ export function sanitizeSiteProfile( profileRaw: unknown, enabledLanguages: string[], ): SiteProfile | undefined { - if (!profileRaw || typeof profileRaw !== "object" || Array.isArray(profileRaw)) { + const profile = toRecord(profileRaw); + if (!profile) { return undefined; } - const profile = profileRaw as Record; const language = normalizeLanguage(profile.language, enabledLanguages); if (!language) { return undefined; @@ -88,10 +95,10 @@ export function resolveSiteProfiles( profilesRaw: unknown, enabledLanguages: string[], ): SiteProfiles { - if (!profilesRaw || typeof profilesRaw !== "object" || Array.isArray(profilesRaw)) { + const profiles = toRecord(profilesRaw); + if (!profiles) { return {}; } - const profiles = profilesRaw as Record; const resolvedProfiles: SiteProfiles = {}; for (const [domainKey, profileRaw] of Object.entries(profiles)) { const normalizedDomain = normalizeDomainHost(domainKey); diff --git a/src/ui/options/AboutWorkspacePanel.ts b/src/ui/options/AboutWorkspacePanel.ts index bc3b32ad..20f16c9c 100644 --- a/src/ui/options/AboutWorkspacePanel.ts +++ b/src/ui/options/AboutWorkspacePanel.ts @@ -1,5 +1,6 @@ -import { setSafeHtmlContent } from "@ui/settings-engine/dom/safeHtml.js"; +import { createWorkspaceCard, createWorkspaceShell } from "./workspacePanelUtils.js"; import { formatTranslation, i18n } from "./fluenttyperI18n.js"; +import { setSafeHtmlContent } from "@ui/settings-engine/dom/safeHtml.js"; const EXTENSION_VERSION = typeof chrome !== "undefined" && typeof chrome.runtime?.getManifest === "function" @@ -38,6 +39,37 @@ function createActionLink( return anchor; } +function appendSupportActions(container: HTMLElement): void { + [ + [ + "https://github.com/bartekplus/FluentTyper/issues/new?template=bug_report.yml", + i18n.get("popup_report_issue"), + i18n.get("support_report_bug_desc"), + "!", + ], + [ + "https://github.com/bartekplus/FluentTyper/issues/new?template=feature_request.yml", + i18n.get("support_request_feature_label"), + i18n.get("support_request_feature_desc"), + "+", + ], + [ + "https://github.com/bartekplus/FluentTyper#readme", + i18n.get("support_read_docs_label"), + i18n.get("support_read_docs_desc"), + "DOC", + ], + [ + "https://github.com/bartekplus/FluentTyper/blob/main/SECURITY.md", + i18n.get("support_security_policy_label"), + i18n.get("support_security_policy_desc"), + "SEC", + ], + ].forEach(([href, label, description, iconText]) => { + container.appendChild(createActionLink(href, label, description, iconText)); + }); +} + export class AboutWorkspacePanel { private readonly root: HTMLElement; @@ -47,13 +79,9 @@ export class AboutWorkspacePanel { } render(): void { - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; + const shell = createWorkspaceShell(); - const productCard = document.createElement("section"); - productCard.className = "settings-inline-card workspace-overview-card"; - const productTitle = document.createElement("h4"); - productTitle.textContent = i18n.get("about_fluent_typer_group"); + const productCard = createWorkspaceCard(i18n.get("about_fluent_typer_group")); const productCopy = document.createElement("p"); productCopy.className = "settings-inline-help"; setSafeHtmlContent(productCopy, i18n.get("x-FluentTyper")); @@ -75,66 +103,27 @@ export class AboutWorkspacePanel { pill.textContent = i18n.get(key); highlightRow.appendChild(pill); }); - productCard.append(productTitle, productCopy, version, highlightRow); - - const supportCard = document.createElement("section"); - supportCard.className = "settings-inline-card workspace-overview-card"; - const supportTitle = document.createElement("h4"); - supportTitle.textContent = i18n.get("support_development_group"); - supportCard.appendChild(supportTitle); - supportCard.appendChild( - createActionLink( - "https://github.com/bartekplus/FluentTyper/issues/new?template=bug_report.yml", - i18n.get("popup_report_issue"), - i18n.get("support_report_bug_desc"), - "!", - ), - ); - supportCard.appendChild( - createActionLink( - "https://github.com/bartekplus/FluentTyper/issues/new?template=feature_request.yml", - i18n.get("support_request_feature_label"), - i18n.get("support_request_feature_desc"), - "+", - ), - ); - supportCard.appendChild( - createActionLink( - "https://github.com/bartekplus/FluentTyper#readme", - i18n.get("support_read_docs_label"), - i18n.get("support_read_docs_desc"), - "DOC", - ), - ); - supportCard.appendChild( - createActionLink( - "https://github.com/bartekplus/FluentTyper/blob/main/SECURITY.md", - i18n.get("support_security_policy_label"), - i18n.get("support_security_policy_desc"), - "SEC", - ), - ); + productCard.body.append(productCopy, version, highlightRow); + + const supportCard = createWorkspaceCard(i18n.get("support_development_group")); + appendSupportActions(supportCard.body); - const donateCard = document.createElement("section"); - donateCard.className = "settings-inline-card workspace-overview-card"; - const donateTitle = document.createElement("h4"); - donateTitle.textContent = i18n.get("support_donate_link"); - const donateCopy = document.createElement("p"); - donateCopy.className = "settings-inline-help"; - donateCopy.textContent = i18n.get("support_donate_note"); + const donateCard = createWorkspaceCard( + i18n.get("support_donate_link"), + i18n.get("support_donate_note"), + ); const donateLink = document.createElement("a"); donateLink.className = "support-donate-link"; donateLink.href = "https://www.buymeacoffee.com/FluentTyper"; donateLink.target = "_blank"; donateLink.rel = "noopener noreferrer"; donateLink.textContent = i18n.get("support_donate_link"); - donateCard.append(donateTitle, donateCopy, donateLink); + donateCard.body.append(donateLink); - const secondaryGrid = document.createElement("div"); - secondaryGrid.className = "workspace-card-grid"; - secondaryGrid.append(supportCard, donateCard); + const secondaryGrid = createWorkspaceShell("workspace-card-grid"); + secondaryGrid.append(supportCard.card, donateCard.card); - shell.append(productCard, secondaryGrid); + shell.append(productCard.card, secondaryGrid); this.root.replaceChildren(shell); } } diff --git a/src/ui/options/AppearanceStudio.ts b/src/ui/options/AppearanceStudio.ts index f55253b6..72d1eb7e 100644 --- a/src/ui/options/AppearanceStudio.ts +++ b/src/ui/options/AppearanceStudio.ts @@ -16,6 +16,12 @@ import { KEY_SUGGESTION_TEXT_LIGHT, } from "@core/domain/constants"; import { i18n } from "./fluenttyperI18n.js"; +import { + bindRerender, + createStackField, + createWorkspaceGrid, + createWorkspaceShell, +} from "./workspacePanelUtils.js"; type ThemePreset = Record; type RGBAColor = { r: number; g: number; b: number; a: number }; @@ -252,24 +258,19 @@ export class AppearanceStudio { this.registry = registry; this.presets = presets; THEME_KEYS.forEach((key) => { - this.registry[key]?.addEvent("action", () => this.render()); - this.registry[key]?.addEvent("change", () => this.render()); + bindRerender(this.registry[key], () => this.render()); }); - this.registry[KEY_SELECT_BY_DIGIT]?.addEvent("action", () => this.render()); - this.registry[KEY_SELECT_BY_DIGIT]?.addEvent("change", () => this.render()); + bindRerender(this.registry[KEY_SELECT_BY_DIGIT], () => this.render()); this.render(); } render(): void { const theme = this.readThemeValues(); - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; - const topGrid = document.createElement("div"); - topGrid.className = "workspace-main-grid"; + const shell = createWorkspaceShell(); + const topGrid = createWorkspaceGrid("workspace-main-grid"); topGrid.append(this.createPresetCards(), this.createPreviewCard(theme)); - const lowerGrid = document.createElement("div"); - lowerGrid.className = "workspace-main-grid"; + const lowerGrid = createWorkspaceGrid("workspace-main-grid"); lowerGrid.append(this.createTypographyCard(theme), this.createContrastWarnings(theme)); shell.append(topGrid, lowerGrid, this.createAdvancedColors(theme)); @@ -498,10 +499,6 @@ export class AppearanceStudio { onChange: (value: string) => void, onInput?: (value: string) => void, ): HTMLElement { - const field = document.createElement("label"); - field.className = "settings-stack-field"; - const title = document.createElement("span"); - title.textContent = labelText; const select = document.createElement("select"); select.className = "input"; options.forEach(([optionValue, optionLabel]) => { @@ -513,8 +510,7 @@ export class AppearanceStudio { select.value = value; select.addEventListener("input", () => onInput?.(select.value)); select.addEventListener("change", () => onChange(select.value)); - field.append(title, select); - return field; + return createStackField(labelText, select); } private createHelperText(copy: string): HTMLElement { @@ -540,10 +536,6 @@ export class AppearanceStudio { group.appendChild(this.createHelperText(copy)); fields.forEach(([key, label]) => { - const field = document.createElement("label"); - field.className = "settings-stack-field"; - const fieldTitle = document.createElement("span"); - fieldTitle.textContent = label; const inputs = document.createElement("div"); inputs.className = "is-flex is-align-items-center"; inputs.style.gap = "0.75rem"; @@ -578,8 +570,7 @@ export class AppearanceStudio { }); inputs.append(rawInput, pickerInput); - field.append(fieldTitle, inputs); - group.appendChild(field); + group.appendChild(createStackField(label, inputs)); }); return group; diff --git a/src/ui/options/DataDiagnosticsPanel.ts b/src/ui/options/DataDiagnosticsPanel.ts index adb50f16..d6e9ce03 100644 --- a/src/ui/options/DataDiagnosticsPanel.ts +++ b/src/ui/options/DataDiagnosticsPanel.ts @@ -1,5 +1,6 @@ import type { SettingsRegistry } from "@ui/settings-engine/SettingsEngine.js"; import { i18n } from "./fluenttyperI18n.js"; +import { createWorkspaceShell } from "./workspacePanelUtils.js"; import { createWorkspaceCard, moveControlToBody, @@ -17,8 +18,7 @@ export class DataDiagnosticsPanel { } render(): void { - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; + const shell = createWorkspaceShell(); const productivity = createWorkspaceCard( i18n.get("productivity_dashboard_group"), diff --git a/src/ui/options/EssentialsWorkspacePanel.ts b/src/ui/options/EssentialsWorkspacePanel.ts index 650ba160..5bd246ab 100644 --- a/src/ui/options/EssentialsWorkspacePanel.ts +++ b/src/ui/options/EssentialsWorkspacePanel.ts @@ -12,6 +12,7 @@ import { KEY_SELECT_BY_DIGIT, } from "@core/domain/constants"; import { i18n } from "./fluenttyperI18n.js"; +import { createWorkspaceShell } from "./workspacePanelUtils.js"; import { createWorkspaceCard, moveControlToBody, @@ -31,8 +32,7 @@ export class EssentialsWorkspacePanel { } render(): void { - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; + const shell = createWorkspaceShell(); const general = createWorkspaceCard(i18n.get("General")); moveControlToBody(this.registry, "enable", general.body); diff --git a/src/ui/options/GrammarWorkspacePanel.ts b/src/ui/options/GrammarWorkspacePanel.ts index 1e6e54a2..288e1c26 100644 --- a/src/ui/options/GrammarWorkspacePanel.ts +++ b/src/ui/options/GrammarWorkspacePanel.ts @@ -1,6 +1,6 @@ import type { SettingsRegistry } from "@ui/settings-engine/SettingsEngine.js"; import { KEY_ENABLED_GRAMMAR_RULES } from "@core/domain/constants"; -import { pruneEmptySettingsGroups } from "./workspacePanelUtils.js"; +import { createWorkspaceShell, pruneEmptySettingsGroups } from "./workspacePanelUtils.js"; export class GrammarWorkspacePanel { private readonly root: HTMLElement; @@ -17,8 +17,7 @@ export class GrammarWorkspacePanel { if (!control?.rootElement) { return; } - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; + const shell = createWorkspaceShell(); const card = document.createElement("section"); card.className = "settings-inline-card"; diff --git a/src/ui/options/LanguageSettingsPanel.ts b/src/ui/options/LanguageSettingsPanel.ts index b034a171..e43ff2f0 100644 --- a/src/ui/options/LanguageSettingsPanel.ts +++ b/src/ui/options/LanguageSettingsPanel.ts @@ -17,7 +17,10 @@ import { import { resolveSiteProfiles } from "@core/domain/siteProfiles"; import { formatTranslation, i18n } from "./fluenttyperI18n.js"; import { + bindRerender, createWorkspaceCard, + createWorkspaceGrid, + createWorkspaceShell, moveControlToBody, pruneEmptySettingsGroups, } from "./workspacePanelUtils.js"; @@ -32,21 +35,11 @@ export class LanguageSettingsPanel { this.registry = registry; this.store = store; - this.registry[KEY_LANGUAGE]?.addEvent("action", () => { - void this.render(); - }); - this.registry[KEY_EXTENSION_LANGUAGE]?.addEvent("action", () => { - void this.render(); - }); - this.registry[KEY_ENABLED_LANGUAGES]?.addEvent("action", () => { - void this.render(); - }); - this.registry[KEY_FALLBACK_LANGUAGE]?.addEvent("action", () => { - void this.render(); - }); - this.registry[KEY_SITE_PROFILES]?.addEvent("action", () => { - void this.render(); - }); + bindRerender(this.registry[KEY_LANGUAGE], () => this.render()); + bindRerender(this.registry[KEY_EXTENSION_LANGUAGE], () => this.render()); + bindRerender(this.registry[KEY_ENABLED_LANGUAGES], () => this.render()); + bindRerender(this.registry[KEY_FALLBACK_LANGUAGE], () => this.render()); + bindRerender(this.registry[KEY_SITE_PROFILES], () => this.render()); void this.render(); } @@ -72,18 +65,15 @@ export class LanguageSettingsPanel { const autoLanguageStatus = language === "auto_detect" ? await this.fetchAutoLanguageStatus() : null; - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; + const shell = createWorkspaceShell(); - const topGrid = document.createElement("div"); - topGrid.className = "workspace-top-grid"; + const topGrid = createWorkspaceGrid("workspace-top-grid"); topGrid.append( this.createExtensionUiCard(), this.createSummary(enabledLanguages, language, fallbackLanguage, autoLanguageStatus), ); - const lowerGrid = document.createElement("div"); - lowerGrid.className = "workspace-main-grid"; + const lowerGrid = createWorkspaceGrid("workspace-main-grid"); const languageDisplayCard = this.createLanguageDisplayCard(); languageDisplayCard.classList.add("workspace-span-full"); const languageGridSection = this.createLanguageGridSection(enabledLanguages, usageCounts); diff --git a/src/ui/options/ObservabilityWorkspacePanel.ts b/src/ui/options/ObservabilityWorkspacePanel.ts index 281ac23e..89f5ec05 100644 --- a/src/ui/options/ObservabilityWorkspacePanel.ts +++ b/src/ui/options/ObservabilityWorkspacePanel.ts @@ -9,6 +9,7 @@ import { KEY_OBSERVABILITY_MODULE_OVERRIDES, } from "@core/domain/constants"; import { i18n } from "./fluenttyperI18n.js"; +import { createWorkspaceShell } from "./workspacePanelUtils.js"; import { createWorkspaceCard, moveControlToBody, @@ -26,8 +27,7 @@ export class ObservabilityWorkspacePanel { } render(): void { - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; + const shell = createWorkspaceShell(); const controls = createWorkspaceCard( i18n.get("observability_controls_group"), diff --git a/src/ui/options/SiteManagementPanel.ts b/src/ui/options/SiteManagementPanel.ts index b1bc5f24..f1ae1f10 100644 --- a/src/ui/options/SiteManagementPanel.ts +++ b/src/ui/options/SiteManagementPanel.ts @@ -10,6 +10,11 @@ import { import { normalizeDomainHost } from "@core/domain/siteProfiles"; import { SiteProfilesManager } from "./siteProfiles.js"; import { i18n } from "./fluenttyperI18n.js"; +import { + bindRerender, + createWorkspaceCard, + createWorkspaceShell, +} from "./workspacePanelUtils.js"; type DomainListMode = "blackList" | "whiteList"; @@ -43,18 +48,12 @@ export class SiteManagementPanel { this.onConfigChange, ); - this.registry[KEY_DOMAIN_LIST_MODE]?.addEvent("action", () => void this.render()); - this.registry.domainBlackList?.addEvent("action", () => void this.render()); - this.registry[KEY_ENABLED_LANGUAGES]?.addEvent("action", () => void this.render()); - this.registry[KEY_SITE_PROFILES]?.addEvent("action", () => void this.render()); - this.registry[KEY_NUM_SUGGESTIONS]?.addEvent( - "action", - () => void this.siteProfilesManager.render(), - ); - this.registry[KEY_INLINE_SUGGESTION]?.addEvent( - "action", - () => void this.siteProfilesManager.render(), - ); + bindRerender(this.registry[KEY_DOMAIN_LIST_MODE], () => this.render()); + bindRerender(this.registry.domainBlackList, () => this.render()); + bindRerender(this.registry[KEY_ENABLED_LANGUAGES], () => this.render()); + bindRerender(this.registry[KEY_SITE_PROFILES], () => this.render()); + bindRerender(this.registry[KEY_NUM_SUGGESTIONS], () => this.siteProfilesManager.render()); + bindRerender(this.registry[KEY_INLINE_SUGGESTION], () => this.siteProfilesManager.render()); void this.render(); } @@ -73,23 +72,14 @@ export class SiteManagementPanel { : []; const accessCard = this.createAccessCard(mode, domainList); - const profileCard = document.createElement("section"); - profileCard.className = "settings-inline-card"; - const header = document.createElement("div"); - header.className = "site-profile-card-header"; - const title = document.createElement("h4"); - title.textContent = i18n.get("site_profiles"); - header.appendChild(title); - const note = document.createElement("p"); - note.className = "settings-inline-help"; - note.textContent = i18n.get("site_profiles_desc"); - header.appendChild(note); - profileCard.appendChild(header); - profileCard.appendChild(this.siteProfilesRoot); - - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; - shell.append(accessCard, profileCard); + const profileCard = createWorkspaceCard( + i18n.get("site_profiles"), + i18n.get("site_profiles_desc"), + ); + profileCard.body.appendChild(this.siteProfilesRoot); + + const shell = createWorkspaceShell(); + shell.append(accessCard, profileCard.card); this.root.replaceChildren(shell); await this.siteProfilesManager.render(); diff --git a/src/ui/options/TextAssetsPanel.ts b/src/ui/options/TextAssetsPanel.ts index 71b08fb5..59fb7a7d 100644 --- a/src/ui/options/TextAssetsPanel.ts +++ b/src/ui/options/TextAssetsPanel.ts @@ -10,6 +10,12 @@ import { } from "@core/domain/constants"; import { resolveDynamicVariable } from "@core/domain/variables"; import { formatTranslation, i18n } from "./fluenttyperI18n.js"; +import { + bindControlEvents, + createStackField, + createWorkspaceGrid, + createWorkspaceShell, +} from "./workspacePanelUtils.js"; type TextExpansionEntry = [string, string]; type SnippetRow = { @@ -70,20 +76,20 @@ export class TextAssetsPanel { this.registry = registry; this.store = store; - this.registry[KEY_TEXT_EXPANSIONS]?.addEvent("action", () => void this.load()); - this.registry[KEY_USER_DICTIONARY_LIST]?.addEvent("action", () => void this.load()); - this.registry[KEY_DATE_FORMAT]?.addEvent("action", () => void this.render()); - this.registry[KEY_TIME_FORMAT]?.addEvent("action", () => void this.render()); - this.registry[KEY_DATE_FORMAT]?.addEvent("change", () => { + bindControlEvents(this.registry[KEY_TEXT_EXPANSIONS], [["action", () => void this.load()]]); + bindControlEvents(this.registry[KEY_USER_DICTIONARY_LIST], [["action", () => void this.load()]]); + bindControlEvents(this.registry[KEY_DATE_FORMAT], [["action", () => void this.render()]]); + bindControlEvents(this.registry[KEY_TIME_FORMAT], [["action", () => void this.render()]]); + bindControlEvents(this.registry[KEY_DATE_FORMAT], [["change", () => { this.liveDateFormat = toTextValue(this.registry[KEY_DATE_FORMAT].get()); this.refreshActiveSnippetPreview(); void this.render(); - }); - this.registry[KEY_TIME_FORMAT]?.addEvent("change", () => { + }]]); + bindControlEvents(this.registry[KEY_TIME_FORMAT], [["change", () => { this.liveTimeFormat = toTextValue(this.registry[KEY_TIME_FORMAT].get()); this.refreshActiveSnippetPreview(); void this.render(); - }); + }]]); this.liveDateFormat = toTextValue(this.registry[KEY_DATE_FORMAT]?.get()); this.liveTimeFormat = toTextValue(this.registry[KEY_TIME_FORMAT]?.get()); @@ -116,10 +122,8 @@ export class TextAssetsPanel { } render(): void { - const shell = document.createElement("div"); - shell.className = "workspace-panel-stack"; - const lowerGrid = document.createElement("div"); - lowerGrid.className = "workspace-main-grid"; + const shell = createWorkspaceShell(); + const lowerGrid = createWorkspaceGrid("workspace-main-grid"); lowerGrid.append(this.createDictionaryWorkspace(), this.createVariableWorkspace()); shell.append(this.createSnippetWorkspaceCard(), lowerGrid); this.root.replaceChildren(shell); @@ -420,10 +424,10 @@ export class TextAssetsPanel { ); editor.append( - this.createLabeledField(i18n.get("text_expander_shortcut_placeholder"), shortcut), - this.createLabeledField(i18n.get("text_assets_expansion_label"), body), + createStackField(i18n.get("text_expander_shortcut_placeholder"), shortcut), + createStackField(i18n.get("text_assets_expansion_label"), body), variables, - this.createLabeledField(i18n.get("text_assets_preview_label"), preview), + createStackField(i18n.get("text_assets_preview_label"), preview), actions, status, ); @@ -689,8 +693,8 @@ export class TextAssetsPanel { docs.appendChild(exampleList); shell.append( - this.createLabeledField(i18n.get("custom_date_format_label"), dateInput), - this.createLabeledField(i18n.get("custom_time_format_label"), timeInput), + createStackField(i18n.get("custom_date_format_label"), dateInput), + createStackField(i18n.get("custom_time_format_label"), timeInput), docs, ); return shell; @@ -705,16 +709,6 @@ export class TextAssetsPanel { return button; } - private createLabeledField(labelText: string, field: HTMLElement): HTMLElement { - const wrapper = document.createElement("label"); - wrapper.className = "settings-stack-field"; - const label = document.createElement("span"); - label.textContent = labelText; - wrapper.appendChild(label); - wrapper.appendChild(field); - return wrapper; - } - private persistSnippetRows(): void { this.registry[KEY_TEXT_EXPANSIONS].set(this.getPersistedExpansions()); } diff --git a/src/ui/options/fluenttyperI18n.ts b/src/ui/options/fluenttyperI18n.ts index a5a54a80..158cb2ca 100644 --- a/src/ui/options/fluenttyperI18n.ts +++ b/src/ui/options/fluenttyperI18n.ts @@ -2,30 +2,31 @@ import { I18n } from "@ui/settings-engine/i18n/I18n.js"; import { KEY_EXTENSION_LANGUAGE } from "@core/domain/constants"; const i18n = new I18n(); -// Override language from localStorage if extension language is set. -// Uses synchronous localStorage (available in page contexts like options page) -// instead of async chrome.storage.local to avoid top-level await which breaks -// service-worker entry bundling. -try { - if (typeof localStorage !== "undefined") { +function applyStoredExtensionLanguage(target: I18n): void { + if (typeof localStorage === "undefined") { + return; + } + + try { const storageKey = `store.settings.${KEY_EXTENSION_LANGUAGE}`; const rawValue = localStorage.getItem(storageKey); - if (rawValue) { - const parsedLanguage: unknown = JSON.parse(rawValue); - if (typeof parsedLanguage === "string" && parsedLanguage !== "auto_detect") { - // Locale codes use underscore (e.g. en_US), i18n uses short codes (e.g. en) - let shortCode = parsedLanguage.split("_")[0]; - // Map pt -> pr to match i18n translation keys for Portuguese - if (shortCode === "pt") { - shortCode = "pr"; - } - i18n.lang = shortCode; - } + if (!rawValue) { + return; } + + const parsedLanguage: unknown = JSON.parse(rawValue); + if (typeof parsedLanguage !== "string" || parsedLanguage === "auto_detect") { + return; + } + + const localePrefix = parsedLanguage.split("_")[0]; + const shortCode = localePrefix === "pt" ? "pr" : localePrefix; + target.lang = shortCode; + } catch { + // Ignore malformed storage entries and keep the browser default. } -} catch { - // Silently ignore - use default navigator language } +applyStoredExtensionLanguage(i18n); i18n.extend({ add_domain: { diff --git a/src/ui/options/settings.ts b/src/ui/options/settings.ts index 31c5e08d..f72027c3 100644 --- a/src/ui/options/settings.ts +++ b/src/ui/options/settings.ts @@ -1,4 +1,5 @@ import { SettingsEngine } from "@ui/settings-engine/SettingsEngine.js"; +import type { SettingsRegistry } from "@ui/settings-engine/SettingsEngine.js"; import { createLogger, getRegisteredObservabilityModules, @@ -167,6 +168,171 @@ function optionsPageConfigChange() { void chrome.runtime.sendMessage(message); } +const CONFIG_REFRESH_KEYS = [ + KEY_AUTOCOMPLETE, + KEY_AUTOCOMPLETE_ON_ENTER, + KEY_AUTOCOMPLETE_ON_TAB, + KEY_LANGUAGE, + KEY_ENABLED_LANGUAGES, + KEY_DOMAIN_LIST_MODE, + KEY_FALLBACK_LANGUAGE, + KEY_NUM_SUGGESTIONS, + KEY_MIN_WORD_LENGTH_TO_PREDICT, + KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, + KEY_AUTO_CAPITALIZE, + KEY_SELECT_BY_DIGIT, + KEY_ENABLED_GRAMMAR_RULES, + KEY_TIME_FORMAT, + KEY_DATE_FORMAT, + KEY_TEXT_EXPANSIONS, + KEY_USER_DICTIONARY_LIST, + KEY_DISPLAY_LANG_HEADER, + KEY_INLINE_SUGGESTION, + KEY_EXTENSION_LANGUAGE, + KEY_AI_PREDICTOR_ENABLED, + KEY_AI_MODEL_ID, + KEY_AI_PREDICTION_TIMEOUT_MS, + KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED, + KEY_DEBUG_AI_PREDICTOR_ENABLED, + KEY_OBSERVABILITY_ENABLED, + KEY_OBSERVABILITY_DEFAULT_LEVEL, + KEY_SUGGESTION_BG_LIGHT, + KEY_SUGGESTION_TEXT_LIGHT, + KEY_SUGGESTION_HIGHLIGHT_BG_LIGHT, + KEY_SUGGESTION_HIGHLIGHT_TEXT_LIGHT, + KEY_SUGGESTION_BORDER_LIGHT, + KEY_SUGGESTION_BG_DARK, + KEY_SUGGESTION_TEXT_DARK, + KEY_SUGGESTION_HIGHLIGHT_BG_DARK, + KEY_SUGGESTION_HIGHLIGHT_TEXT_DARK, + KEY_SUGGESTION_BORDER_DARK, + KEY_SUGGESTION_FONT_SIZE, + KEY_SUGGESTION_PADDING_VERTICAL, + KEY_SUGGESTION_PADDING_HORIZONTAL, +] as const; + +const PREDICTOR_DEBUG_REFRESH_KEYS = new Set([ + KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED, + KEY_DEBUG_AI_PREDICTOR_ENABLED, + KEY_AI_PREDICTOR_ENABLED, + KEY_AI_MODEL_ID, + KEY_AI_PREDICTION_TIMEOUT_MS, +]); + +const OBSERVABILITY_REFRESH_KEYS = new Set([ + KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED, + KEY_DEBUG_AI_PREDICTOR_ENABLED, + KEY_AI_MODEL_ID, + KEY_AI_PREDICTION_TIMEOUT_MS, + KEY_OBSERVABILITY_ENABLED, + KEY_OBSERVABILITY_DEFAULT_LEVEL, +]); + +function bindActionHandler( + registry: SettingsRegistry, + key: string, + handler: () => void, +): void { + registry[key]?.addEvent("action", handler); +} + +function refreshPredictorDebug(rootId: string): void { + const root = document.getElementById(rootId); + if (!root) { + return; + } + predictorDebugLastSignature = ""; + void loadPredictorDebugSnapshot(root); +} + +function refreshObservabilitySnapshot(rootId: string): void { + const root = document.getElementById(rootId); + if (!root) { + return; + } + observabilityLastSignature = ""; + void loadObservabilitySnapshot(root); +} + +function handleConfigRefreshTrigger(registry: SettingsRegistry, key: string): void { + if (key === KEY_OBSERVABILITY_ENABLED || key === KEY_OBSERVABILITY_DEFAULT_LEVEL) { + applyOptionsObservabilityRuntime(registry); + } + + optionsPageConfigChange(); + + if (PREDICTOR_DEBUG_REFRESH_KEYS.has(key)) { + refreshPredictorDebug("predictorDebugRoot"); + } + if (OBSERVABILITY_REFRESH_KEYS.has(key)) { + refreshObservabilitySnapshot("observabilityRoot"); + } +} + +function wireValidationHandlers(registry: SettingsRegistry, store: Store): void { + bindActionHandler(registry, KEY_LANGUAGE, () => { + void validateLanguageSettings(registry, store); + }); + bindActionHandler(registry, KEY_ENABLED_LANGUAGES, () => { + void validateLanguageSettings(registry, store); + }); +} + +function wireImportExportHandlers(registry: SettingsRegistry): void { + registry.exportSettingButton.addEvent("action", function () { + chrome.storage.local.get(null, function (items) { + const result = JSON.stringify(items); + const blob = new Blob([result], { type: "application/json" }); + const exportFilename = "FluentTyperSettings.json"; + const dlink = document.createElement("a"); + dlink.href = window.URL.createObjectURL(blob); + dlink.download = exportFilename; + dlink.onclick = function () { + const that = this as HTMLAnchorElement; + setTimeout(function () { + window.URL.revokeObjectURL(that.href); + }, 1500); + }; + + dlink.click(); + dlink.remove(); + }); + }); + registry.exportSettingButton.addEvent("action", function () { + dispatchSettingsSaveStatus("saved", { message: i18n.get("settings_exported") }); + }); + + const importInputElem = registry.importSettingButton.element as HTMLInputElement; + importInputElem.type = "file"; + importInputElem.accept = ".json"; + importInputElem.addEventListener("input", importSettingButtonFileSelected.bind(null, registry)); +} + +function wireRuntimeSettingsHandlers(registry: SettingsRegistry): void { + bindActionHandler(registry, KEY_INLINE_SUGGESTION, () => { + if (registry[KEY_INLINE_SUGGESTION].get()) { + registry[KEY_AUTOCOMPLETE_ON_TAB].set(true); + registry[KEY_NUM_SUGGESTIONS].set(10); + } + }); + + bindActionHandler(registry, KEY_EXTENSION_LANGUAGE, () => { + const langValue = registry[KEY_EXTENSION_LANGUAGE].get(); + const storageKey = `store.settings.${KEY_EXTENSION_LANGUAGE}`; + localStorage.setItem(storageKey, JSON.stringify(langValue)); + optionsPageConfigChange(); + setTimeout(() => location.reload(), 100); + }); + + for (const key of CONFIG_REFRESH_KEYS) { + const setting = registry[key]; + if (!setting || typeof setting.addEvent !== "function") { + continue; + } + setting.addEvent("action", () => handleConfigRefreshTrigger(registry, key)); + } +} + function arraysEqual(a: unknown, b: unknown): boolean { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { return false; @@ -2774,12 +2940,7 @@ window.addEventListener("DOMContentLoaded", function () { new AboutWorkspacePanel(registry.aboutWorkspacePanel.element); applyOptionsObservabilityRuntime(registry); - registry[KEY_LANGUAGE].addEvent("action", function () { - void validateLanguageSettings(registry, store); - }); - registry[KEY_ENABLED_LANGUAGES].addEvent("action", function () { - void validateLanguageSettings(registry, store); - }); + wireValidationHandlers(registry, store); await validateLanguageSettings(registry, store); setupProductivityInsights(); setupObservabilityDashboard(registry); @@ -2796,131 +2957,7 @@ window.addEventListener("DOMContentLoaded", function () { })(); }); - registry.exportSettingButton.addEvent("action", function () { - chrome.storage.local.get(null, function (items) { - const result = JSON.stringify(items); - const blob = new Blob([result], { type: "application/json" }); - const exportFilename = "FluentTyperSettings.json"; - const dlink = document.createElement("a"); - dlink.href = window.URL.createObjectURL(blob); - dlink.download = exportFilename; - dlink.onclick = function () { - const that = this as HTMLAnchorElement; - setTimeout(function () { - window.URL.revokeObjectURL(that.href); - }, 1500); - }; - - dlink.click(); - dlink.remove(); - }); - }); - registry.exportSettingButton.addEvent("action", function () { - dispatchSettingsSaveStatus("saved", { message: i18n.get("settings_exported") }); - }); - - const importInputElem = registry.importSettingButton.element as HTMLInputElement; - importInputElem.type = "file"; - importInputElem.accept = ".json"; - importInputElem.addEventListener("input", importSettingButtonFileSelected.bind(null, registry)); - - registry[KEY_INLINE_SUGGESTION].addEvent("action", function () { - if (registry[KEY_INLINE_SUGGESTION].get()) { - registry[KEY_AUTOCOMPLETE_ON_TAB].set(true); - registry[KEY_NUM_SUGGESTIONS].set(10); - } - }); - - registry[KEY_EXTENSION_LANGUAGE].addEvent("action", function () { - const langValue = registry[KEY_EXTENSION_LANGUAGE].get(); - const storageKey = `store.settings.${KEY_EXTENSION_LANGUAGE}`; - localStorage.setItem(storageKey, JSON.stringify(langValue)); - optionsPageConfigChange(); - setTimeout(() => location.reload(), 100); - }); - - // Update presage config on change - [ - KEY_AUTOCOMPLETE, - KEY_AUTOCOMPLETE_ON_ENTER, - KEY_AUTOCOMPLETE_ON_TAB, - KEY_LANGUAGE, - KEY_ENABLED_LANGUAGES, - KEY_DOMAIN_LIST_MODE, - KEY_FALLBACK_LANGUAGE, - KEY_NUM_SUGGESTIONS, - KEY_MIN_WORD_LENGTH_TO_PREDICT, - KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, - KEY_AUTO_CAPITALIZE, - KEY_SELECT_BY_DIGIT, - KEY_ENABLED_GRAMMAR_RULES, - - KEY_TIME_FORMAT, - KEY_DATE_FORMAT, - KEY_TEXT_EXPANSIONS, - KEY_USER_DICTIONARY_LIST, - KEY_DISPLAY_LANG_HEADER, - KEY_INLINE_SUGGESTION, - KEY_EXTENSION_LANGUAGE, - KEY_AI_PREDICTOR_ENABLED, - KEY_AI_MODEL_ID, - KEY_AI_PREDICTION_TIMEOUT_MS, - KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED, - KEY_DEBUG_AI_PREDICTOR_ENABLED, - KEY_OBSERVABILITY_ENABLED, - KEY_OBSERVABILITY_DEFAULT_LEVEL, - // Theme settings - KEY_SUGGESTION_BG_LIGHT, - KEY_SUGGESTION_TEXT_LIGHT, - KEY_SUGGESTION_HIGHLIGHT_BG_LIGHT, - KEY_SUGGESTION_HIGHLIGHT_TEXT_LIGHT, - KEY_SUGGESTION_BORDER_LIGHT, - KEY_SUGGESTION_BG_DARK, - KEY_SUGGESTION_TEXT_DARK, - KEY_SUGGESTION_HIGHLIGHT_BG_DARK, - KEY_SUGGESTION_HIGHLIGHT_TEXT_DARK, - KEY_SUGGESTION_BORDER_DARK, - KEY_SUGGESTION_FONT_SIZE, - KEY_SUGGESTION_PADDING_VERTICAL, - KEY_SUGGESTION_PADDING_HORIZONTAL, - ].forEach((element) => { - const setting = registry[element]; - if (!setting || typeof setting.addEvent !== "function") { - return; - } - setting.addEvent("action", function () { - if (element === KEY_OBSERVABILITY_ENABLED || element === KEY_OBSERVABILITY_DEFAULT_LEVEL) { - applyOptionsObservabilityRuntime(registry); - } - optionsPageConfigChange(); - if ( - element === KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED || - element === KEY_DEBUG_AI_PREDICTOR_ENABLED || - element === KEY_AI_PREDICTOR_ENABLED || - element === KEY_AI_MODEL_ID || - element === KEY_AI_PREDICTION_TIMEOUT_MS - ) { - const root = document.getElementById("predictorDebugRoot"); - if (root) { - predictorDebugLastSignature = ""; - void loadPredictorDebugSnapshot(root); - } - } - if ( - element === KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED || - element === KEY_DEBUG_AI_PREDICTOR_ENABLED || - element === KEY_AI_MODEL_ID || - element === KEY_AI_PREDICTION_TIMEOUT_MS || - element === KEY_OBSERVABILITY_ENABLED || - element === KEY_OBSERVABILITY_DEFAULT_LEVEL - ) { - const root = document.getElementById("observabilityRoot"); - if (root) { - observabilityLastSignature = ""; - void loadObservabilitySnapshot(root); - } - } - }); - }); + wireImportExportHandlers(registry); + wireRuntimeSettingsHandlers(registry); })(); }); diff --git a/src/ui/options/settingsManifest.ts b/src/ui/options/settingsManifest.ts index f97c7c44..e6fcf1c1 100644 --- a/src/ui/options/settingsManifest.ts +++ b/src/ui/options/settingsManifest.ts @@ -1,5 +1,10 @@ import { i18n } from "./fluenttyperI18n.js"; -import type { FieldConfig, ManifestDefinition, OptionTuple } from "@ui/settings-engine/types.js"; +import type { + FieldConfig, + ManifestDefinition, + OptionTuple, + TabConfig, +} from "@ui/settings-engine/types.js"; import { SUPPORTED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS } from "@core/domain/lang"; import { KEY_AUTOCOMPLETE, @@ -82,15 +87,31 @@ function buildFieldLabel(label: string, description: string): string { return `${normalizedLabel}: ${description}`; } +function createTab( + id: string, + labelKey: string, + shortDescriptionKey: string, + icon: string, + keywordKeys: string[], +): TabConfig { + return { + id, + label: i18n.get(labelKey), + title: i18n.get(labelKey), + shortDescription: i18n.get(shortDescriptionKey), + icon, + keywords: keywordKeys.map((key) => i18n.get(key)), + }; +} + const DEV_TABS: ManifestDefinition["tabs"] = [ - { - id: "observability_tab", - label: i18n.get("observability_tab"), - title: i18n.get("observability_tab"), - shortDescription: i18n.get("observability_tab_desc"), - icon: "OB", - keywords: [i18n.get("observability_tab"), i18n.get("observability_dashboard_group")], - }, + createTab( + "observability_tab", + "observability_tab", + "observability_tab_desc", + "OB", + ["observability_tab", "observability_dashboard_group"], + ), ]; const DEV_PREDICTOR_SETTINGS: FieldConfig[] = [ @@ -255,71 +276,63 @@ const manifest: ManifestDefinition = { name: i18n.get("options_page_title"), icon: "/icon/icon128.png", tabs: [ - { - id: "core_settings", - label: i18n.get("options_tab_essentials"), - title: i18n.get("options_tab_essentials"), - shortDescription: i18n.get("options_tab_essentials_desc"), - icon: "ES", - keywords: [i18n.get("options_tab_essentials"), i18n.get("prediction_engine")], - }, - { - id: "grammar_tab", - label: i18n.get("grammar_tab"), - title: i18n.get("grammar_tab"), - shortDescription: i18n.get("options_tab_grammar_desc"), - icon: "GR", - keywords: [i18n.get("grammar_rules"), i18n.get("grammar_tab")], - }, - { - id: "language_tab", - label: i18n.get("options_tab_languages"), - title: i18n.get("options_tab_languages"), - shortDescription: i18n.get("options_tab_languages_desc"), - icon: "LA", - keywords: [i18n.get("options_tab_languages"), i18n.get("language_selection")], - }, - { - id: "shortcuts_expansions_tab", - label: i18n.get("options_tab_snippets"), - title: i18n.get("options_tab_snippets"), - shortDescription: i18n.get("options_tab_snippets_desc"), - icon: "SD", - keywords: [i18n.get("options_tab_snippets"), i18n.get("text_expander")], - }, - { - id: "site_mgmt_tab", - label: i18n.get("options_tab_sites"), - title: i18n.get("options_tab_sites"), - shortDescription: i18n.get("options_tab_sites_desc"), - icon: "SI", - keywords: [i18n.get("options_tab_sites"), i18n.get("site_profiles")], - }, - { - id: "theming_tab", - label: i18n.get("theming_tab"), - title: i18n.get("theming_tab"), - shortDescription: i18n.get("options_tab_appearance_desc"), - icon: "AP", - keywords: [i18n.get("theming_tab"), i18n.get("theme_presets")], - }, - { - id: "advanced_tab", - label: i18n.get("options_tab_data"), - title: i18n.get("options_tab_data"), - shortDescription: i18n.get("options_tab_data_desc"), - icon: "DD", - keywords: [i18n.get("options_tab_data"), i18n.get("config_data")], - }, + createTab( + "core_settings", + "options_tab_essentials", + "options_tab_essentials_desc", + "ES", + ["options_tab_essentials", "prediction_engine"], + ), + createTab( + "grammar_tab", + "grammar_tab", + "options_tab_grammar_desc", + "GR", + ["grammar_rules", "grammar_tab"], + ), + createTab( + "language_tab", + "options_tab_languages", + "options_tab_languages_desc", + "LA", + ["options_tab_languages", "language_selection"], + ), + createTab( + "shortcuts_expansions_tab", + "options_tab_snippets", + "options_tab_snippets_desc", + "SD", + ["options_tab_snippets", "text_expander"], + ), + createTab( + "site_mgmt_tab", + "options_tab_sites", + "options_tab_sites_desc", + "SI", + ["options_tab_sites", "site_profiles"], + ), + createTab( + "theming_tab", + "theming_tab", + "options_tab_appearance_desc", + "AP", + ["theming_tab", "theme_presets"], + ), + createTab( + "advanced_tab", + "options_tab_data", + "options_tab_data_desc", + "DD", + ["options_tab_data", "config_data"], + ), ...(IS_DEV_BUILD ? DEV_TABS : []), - { - id: "about_support_tab", - label: i18n.get("options_tab_about"), - title: i18n.get("options_tab_about"), - shortDescription: i18n.get("options_tab_about_desc"), - icon: "AB", - keywords: [i18n.get("options_tab_about"), i18n.get("support_development_group")], - }, + createTab( + "about_support_tab", + "options_tab_about", + "options_tab_about_desc", + "AB", + ["options_tab_about", "support_development_group"], + ), ], settings: [ // ========================================================================= diff --git a/src/ui/options/siteProfiles.ts b/src/ui/options/siteProfiles.ts index 8be7f5c0..30e3ab1b 100644 --- a/src/ui/options/siteProfiles.ts +++ b/src/ui/options/siteProfiles.ts @@ -23,6 +23,7 @@ import { type SiteProfiles, } from "@core/domain/siteProfiles"; import { formatTranslation, i18n } from "./fluenttyperI18n.js"; +import { createStackField } from "./workspacePanelUtils.js"; interface FancierSettingsLike { siteProfilesEditor: { @@ -219,23 +220,19 @@ export class SiteProfilesManager { } private createField(labelText: string, inputId: string, placeholder: string): HTMLElement { - const wrapper = createElement("label", { className: "settings-stack-field" }); - wrapper.appendChild(createElement("span", { textContent: labelText })); - wrapper.appendChild( - createElement("input", { - id: inputId, - className: "input", - attributes: { type: "text", placeholder }, - }), - ); - return wrapper; + const input = createElement("input", { + id: inputId, + className: "input", + attributes: { type: "text", placeholder }, + }); + return createStackField(labelText, input); } private createSelectField(labelText: string, selectId: string): HTMLElement { - const wrapper = createElement("label", { className: "settings-stack-field" }); - wrapper.appendChild(createElement("span", { textContent: labelText })); - wrapper.appendChild(createElement("select", { id: selectId, className: "input" })); - return wrapper; + return createStackField( + labelText, + createElement("select", { id: selectId, className: "input" }), + ); } private cacheElements(): void { diff --git a/src/ui/options/textExpander.ts b/src/ui/options/textExpander.ts index eef0cecc..f57937ce 100644 --- a/src/ui/options/textExpander.ts +++ b/src/ui/options/textExpander.ts @@ -21,6 +21,11 @@ interface ElementWrapperLike { set(key: string, value: string | number | boolean): void; } +interface AddShortcutInputs { + shortcut: HTMLInputElement | null; + shortcutText: HTMLTextAreaElement | null; +} + function toElementString(value: unknown): string | null { if (typeof value === "string") { return value; @@ -98,10 +103,17 @@ function createElementWrapper(tag: string, props: Record): Elem return wrapper; } +function getAddShortcutInputs(ids: [string, string]): AddShortcutInputs { + return { + shortcut: document.getElementById(ids[0]) as HTMLInputElement | null, + shortcutText: document.getElementById(ids[1]) as HTMLTextAreaElement | null, + }; +} + export class TextExpander { private readonly callbackFn: () => void; private readonly textExpansionsStoreKey = "textExpansions"; - private readonly addNewShortcutIDs = ["newShortcut", "newShortcatText"]; + private readonly addNewShortcutIDs: [string, string] = ["newShortcut", "newShortcatText"]; private readonly store: Store; private readonly settingsWithManifest: FancierSettingsLike; private importedElemCount = 0; @@ -171,12 +183,9 @@ export class TextExpander { skip_empty_lines: true, }) as unknown[][]; - const shortcutElem = document.getElementById( - this.addNewShortcutIDs[0], - ) as HTMLInputElement | null; - const shortcutTextElem = document.getElementById( - this.addNewShortcutIDs[1], - ) as HTMLTextAreaElement | null; + const { shortcut: shortcutElem, shortcutText: shortcutTextElem } = getAddShortcutInputs( + this.addNewShortcutIDs, + ); if (!shortcutElem || !shortcutTextElem) { return; } @@ -390,10 +399,10 @@ export class TextExpander { private shortcutInputChange(): boolean { let isValid = true; - [ - document.getElementById(this.addNewShortcutIDs[0]) as HTMLInputElement | null, - document.getElementById(this.addNewShortcutIDs[1]) as HTMLTextAreaElement | null, - ].forEach((element, index) => { + const { shortcut: shortcutElem, shortcutText: shortcutTextElem } = getAddShortcutInputs( + this.addNewShortcutIDs, + ); + [shortcutElem, shortcutTextElem].forEach((element, index) => { if (!element) { return; } @@ -423,12 +432,9 @@ export class TextExpander { } private addNewShortcut(renderAndSave = true): boolean { - const shortcutElem = document.getElementById( - this.addNewShortcutIDs[0], - ) as HTMLInputElement | null; - const shortcutTextElem = document.getElementById( - this.addNewShortcutIDs[1], - ) as HTMLTextAreaElement | null; + const { shortcut: shortcutElem, shortcutText: shortcutTextElem } = getAddShortcutInputs( + this.addNewShortcutIDs, + ); if (!shortcutElem || !shortcutTextElem) { return false; diff --git a/src/ui/options/workspacePanelUtils.ts b/src/ui/options/workspacePanelUtils.ts index ae8474d2..37da8f72 100644 --- a/src/ui/options/workspacePanelUtils.ts +++ b/src/ui/options/workspacePanelUtils.ts @@ -1,5 +1,21 @@ import type { SettingsRegistry } from "@ui/settings-engine/SettingsEngine.js"; +type ControlEventTarget = { + addEvent?: (type: string, fn: () => void) => void; +}; + +export function createWorkspaceShell(className = "workspace-panel-stack"): HTMLDivElement { + const shell = document.createElement("div"); + shell.className = className; + return shell; +} + +export function createWorkspaceGrid(className: "workspace-top-grid" | "workspace-main-grid") { + const grid = document.createElement("div"); + grid.className = className; + return grid; +} + export function createWorkspaceCard(titleText?: string, bodyText?: string) { const card = document.createElement("section"); card.className = "settings-inline-card workspace-section-card"; @@ -24,6 +40,40 @@ export function createWorkspaceCard(titleText?: string, bodyText?: string) { return { card, body }; } +export function createStackField(labelText: string, control: HTMLElement): HTMLLabelElement { + const wrapper = document.createElement("label"); + wrapper.className = "settings-stack-field"; + + const label = document.createElement("span"); + label.textContent = labelText; + + wrapper.append(label, control); + return wrapper; +} + +export function bindControlEvents( + control: ControlEventTarget | undefined, + events: Array<["action" | "change", () => void]>, +): void { + if (!control?.addEvent) { + return; + } + + for (const [type, handler] of events) { + control.addEvent(type, handler); + } +} + +export function bindRerender( + control: ControlEventTarget | undefined, + render: () => void | Promise, +): void { + bindControlEvents(control, [ + ["action", () => void render()], + ["change", () => void render()], + ]); +} + export function moveControlToBody( registry: SettingsRegistry, key: string, diff --git a/src/ui/popup/popup.ts b/src/ui/popup/popup.ts index fa91cc19..50713a99 100644 --- a/src/ui/popup/popup.ts +++ b/src/ui/popup/popup.ts @@ -93,6 +93,8 @@ function getPageStateElements() { }; } +type PageStateElements = ReturnType; + function setNodeTextAndTitle(node: HTMLElement | null, value: string): void { if (!node) { return; @@ -105,6 +107,47 @@ function setNodeTextAndTitle(node: HTMLElement | null, value: string): void { } } +function clearPageStateSupplementalContent(elements: PageStateElements): void { + if (elements.language) { + setNodeTextAndTitle(elements.language, ""); + } + if (elements.profile) { + setNodeTextAndTitle(elements.profile, ""); + } + elements.meta?.classList.add("is-hidden"); + if (elements.hint) { + setNodeTextAndTitle(elements.hint, ""); + } +} + +function renderNonActionablePageState( + state: Pick, "badge" | "body">, + titleText: string, + panelState: "restricted" | "non_actionable" | "paused", + showDomainSection: boolean, + clearDomainToggle = false, +): void { + const elements = getPageStateElements(); + const { badge, title, body, panel, section } = elements; + if (!badge || !title || !body) { + return; + } + + badge.textContent = state.badge; + setNodeTextAndTitle(title, titleText); + body.textContent = state.body; + clearPageStateSupplementalContent(elements); + panel?.setAttribute("data-page-state", panelState); + setSiteSpecificControlsEnabled(false); + if (clearDomainToggle) { + const domainToggle = document.getElementById("checkboxDomainInput") as HTMLInputElement | null; + if (domainToggle) { + domainToggle.checked = false; + } + } + section?.classList.toggle("is-hidden", !showDomainSection); +} + function setSiteSpecificControlsEnabled(enabled: boolean): void { const domainToggle = document.getElementById("checkboxDomainInput") as HTMLInputElement | null; const profileToggle = document.getElementById( @@ -259,37 +302,15 @@ async function getActiveAutoLanguageStatus(): Promise<{ function renderStaticPageState( state: Extract, ): void { - const { badge, body, meta, panel, section, title, hint, language, profile } = - getPageStateElements(); - const domainToggle = document.getElementById("checkboxDomainInput") as HTMLInputElement | null; const siteProfileSection = document.getElementById("siteProfileSection"); - if (!badge || !title || !body) { - return; - } - badge.textContent = state.badge; - setNodeTextAndTitle(title, state.title); - body.textContent = state.body; - meta?.classList.add("is-hidden"); - if (language) { - setNodeTextAndTitle(language, ""); - } - if (profile) { - setNodeTextAndTitle(profile, ""); - } - panel?.setAttribute("data-page-state", state.kind); - setSiteSpecificControlsEnabled(false); - if (state.kind === "restricted") { - if (domainToggle) { - domainToggle.checked = false; - } - section?.classList.remove("is-hidden"); - } else { - section?.classList.add("is-hidden"); - } + renderNonActionablePageState( + state, + state.title, + state.kind, + state.kind === "restricted", + state.kind === "restricted", + ); siteProfileSection?.classList.add("is-hidden"); - if (hint) { - setNodeTextAndTitle(hint, ""); - } } function renderPermissionBlockedPageState(state: WebsiteAccessPermissionState): void { @@ -318,27 +339,7 @@ function renderPermissionBlockedPageState(state: WebsiteAccessPermissionState): ), kind: "non_actionable" as const, }; - const { badge, body, meta, panel, section, title, hint, language, profile } = - getPageStateElements(); - if (!badge || !title || !body) { - return; - } - badge.textContent = permissionBlockedState.badge; - setNodeTextAndTitle(title, currentDomainURL); - body.textContent = permissionBlockedState.body; - meta?.classList.add("is-hidden"); - if (language) { - setNodeTextAndTitle(language, ""); - } - if (profile) { - setNodeTextAndTitle(profile, ""); - } - panel?.setAttribute("data-page-state", permissionBlockedState.kind); - section?.classList.add("is-hidden"); - setSiteSpecificControlsEnabled(false); - if (hint) { - setNodeTextAndTitle(hint, ""); - } + renderNonActionablePageState(permissionBlockedState, currentDomainURL, permissionBlockedState.kind, false); } function applyPopupThemeMode(theme: "light" | "dark"): void { @@ -471,15 +472,15 @@ async function refreshThisSiteSection(pageState: PopupPageState | null = null): function getSiteProfileElements() { return { - toggle: document.getElementById("checkboxSiteProfileInput") as HTMLInputElement, - language: document.getElementById("siteLanguageSelect") as HTMLSelectElement, - suggestions: document.getElementById("siteNumSuggestionsSelect") as HTMLSelectElement, - inline: document.getElementById("siteInlineModeSelect") as HTMLSelectElement, + toggle: document.getElementById("checkboxSiteProfileInput") as HTMLInputElement | null, + language: document.getElementById("siteLanguageSelect") as HTMLSelectElement | null, + suggestions: document.getElementById("siteNumSuggestionsSelect") as HTMLSelectElement | null, + inline: document.getElementById("siteInlineModeSelect") as HTMLSelectElement | null, preferNativeAutocomplete: document.getElementById( "sitePreferNativeAutocompleteSelect", - ) as HTMLSelectElement, - section: document.getElementById("siteProfileSection") as HTMLElement, - status: document.getElementById("siteProfileStatus") as HTMLElement, + ) as HTMLSelectElement | null, + section: document.getElementById("siteProfileSection"), + status: document.getElementById("siteProfileStatus"), }; } @@ -526,6 +527,30 @@ function getProfileStatusLabel(profileEnabled: boolean): string { : i18n.get("popup_site_profile_status_global"); } +function createSelectOption(value: string, text: string): HTMLOptionElement { + const option = document.createElement("option"); + option.value = value; + option.textContent = text; + return option; +} + +function populateLanguageOptions(select: HTMLSelectElement, languages: string[]): void { + select.replaceChildren(); + for (const langCode of languages) { + select.appendChild(createSelectOption(langCode, SUPPORTED_LANGUAGES[langCode] || langCode)); + } +} + +function populateSuggestionOptions( + select: HTMLSelectElement, + globalNumSuggestions: number, +): void { + select.replaceChildren(createSelectOption("global", getInheritLabel(String(globalNumSuggestions)))); + for (let idx = 0; idx <= MAX_NUM_SUGGESTIONS; idx += 1) { + select.appendChild(createSelectOption(String(idx), String(idx))); + } +} + function notifyConfigChange(): Promise { const message: OptionsPageConfigChangeMessage = { command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, @@ -566,25 +591,8 @@ async function loadSiteProfileEditor() { return; } - language.innerHTML = ""; - for (const langCode of currentEnabledLanguages) { - const option = document.createElement("option"); - option.value = langCode; - option.textContent = SUPPORTED_LANGUAGES[langCode]; - language.appendChild(option); - } - - suggestions.innerHTML = ""; - const globalSuggestionOption = document.createElement("option"); - globalSuggestionOption.value = "global"; - globalSuggestionOption.textContent = getInheritLabel(String(globalNumSuggestions)); - suggestions.appendChild(globalSuggestionOption); - for (let idx = 0; idx <= MAX_NUM_SUGGESTIONS; idx++) { - const option = document.createElement("option"); - option.value = String(idx); - option.textContent = String(idx); - suggestions.appendChild(option); - } + populateLanguageOptions(language, currentEnabledLanguages); + populateSuggestionOptions(suggestions, globalNumSuggestions); populateBooleanOverrideOptions(inline, globalInlineSuggestion, getOnOffLabel); populateBooleanOverrideOptions( @@ -631,23 +639,23 @@ async function loadSiteProfileEditor() { function readSiteProfileFromEditor(): SiteProfile { const { language, suggestions, inline, preferNativeAutocomplete } = getSiteProfileElements(); - const languageValue = currentEnabledLanguages.includes(language.value) + const languageValue = language && currentEnabledLanguages.includes(language.value) ? language.value : currentProfileLanguageFallback; const profile: SiteProfile = { language: languageValue, }; - const numSuggestions = parseSuggestionsOverride(suggestions.value); + const numSuggestions = suggestions ? parseSuggestionsOverride(suggestions.value) : undefined; if (typeof numSuggestions === "number") { profile.numSuggestions = numSuggestions; } - const inlineSuggestion = parseInlineOverride(inline.value); + const inlineSuggestion = inline ? parseInlineOverride(inline.value) : undefined; if (typeof inlineSuggestion === "boolean") { profile.inline_suggestion = inlineSuggestion; } - const preferNativeAutocompleteOverride = parsePreferNativeAutocompleteOverride( - preferNativeAutocomplete.value, - ); + const preferNativeAutocompleteOverride = preferNativeAutocomplete + ? parsePreferNativeAutocompleteOverride(preferNativeAutocomplete.value) + : undefined; if (typeof preferNativeAutocompleteOverride === "boolean") { profile.preferNativeAutocomplete = preferNativeAutocompleteOverride; } @@ -659,20 +667,11 @@ function populateBooleanOverrideOptions( globalValue: boolean, describeValue: (value: boolean) => string, ): void { - select.innerHTML = ""; - [ - { - value: "global", - text: getInheritLabel(describeValue(globalValue)), - }, - { value: "on", text: describeValue(true) }, - { value: "off", text: describeValue(false) }, - ].forEach((entry) => { - const option = document.createElement("option"); - option.value = entry.value; - option.textContent = entry.text; - select.appendChild(option); - }); + select.replaceChildren( + createSelectOption("global", getInheritLabel(describeValue(globalValue))), + createSelectOption("on", describeValue(true)), + createSelectOption("off", describeValue(false)), + ); } async function saveSiteProfileFromEditor() { @@ -1072,6 +1071,9 @@ function init() { window.document.getElementById("checkboxSiteProfileInput")?.addEventListener("click", () => { void (async () => { const { toggle } = getSiteProfileElements(); + if (!toggle) { + return; + } setSiteProfileInputsDisabled(!toggle.checked); await saveSiteProfileFromEditor(); })(); @@ -1087,7 +1089,7 @@ function init() { element?.addEventListener("change", () => { void (async () => { const { toggle } = getSiteProfileElements(); - if (!toggle.checked) { + if (!toggle || !toggle.checked) { return; } await saveSiteProfileFromEditor(); diff --git a/src/ui/settings-engine/SettingsEngine.ts b/src/ui/settings-engine/SettingsEngine.ts index 3a5be6be..2bdce385 100644 --- a/src/ui/settings-engine/SettingsEngine.ts +++ b/src/ui/settings-engine/SettingsEngine.ts @@ -100,9 +100,7 @@ export class SettingsEngine { for (const params of manifest.settings) { const control = this.createControl(params); - if (params.name !== undefined) { - registry[params.name] = control; - } + this.registerControl(registry, params, control); } // Apply initial hash routing after all tabs are created @@ -194,16 +192,25 @@ export class SettingsEngine { if (params.type === "valueOnly") { return control; } - const container = this.getOrCreateGroup(params.tab, params.group); - const groupRoot = container.closest(".settings-group"); + const groupContainer = this.getOrCreateGroup(params.tab, params.group); control.rootElement.setAttribute("data-search-text", this.buildFieldSearchText(params)); - container.appendChild(control.rootElement); + groupContainer.appendChild(control.rootElement); if (params.type === "customPanel") { - groupRoot?.classList.add("settings-group-panel-only"); + groupContainer.closest(".settings-group")?.classList.add("settings-group-panel-only"); } return control; } + private registerControl( + registry: SettingsRegistry, + params: FieldConfig, + control: FieldControl, + ): void { + if (params.name !== undefined) { + registry[params.name] = control; + } + } + private instantiateControl(params: FieldConfig): FieldControl { switch (params.type) { case "checkbox": diff --git a/src/ui/settings-engine/controls/ButtonControl.ts b/src/ui/settings-engine/controls/ButtonControl.ts index 1772e5ac..5344d8d9 100644 --- a/src/ui/settings-engine/controls/ButtonControl.ts +++ b/src/ui/settings-engine/controls/ButtonControl.ts @@ -1,31 +1,24 @@ import type { ButtonConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createButtonInput, + createControlContainer, + createFieldRoot, +} from "./FieldControl.js"; export class ButtonControl extends BaseControl { constructor(params: ButtonConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; - - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - control.appendChild(label); - } - - const btn = document.createElement("input"); - btn.type = "button"; - btn.className = "button is-primary"; - if (params.text) { - btn.value = params.text; - } + const control = createControlContainer(); + appendLabel(control, params.label); + + const btn = createButtonInput(params.text); btn.addEventListener("click", () => { this.emitter.fireEvent("action", this.get()); diff --git a/src/ui/settings-engine/controls/CheckboxControl.ts b/src/ui/settings-engine/controls/CheckboxControl.ts index a0acbdea..7710e520 100644 --- a/src/ui/settings-engine/controls/CheckboxControl.ts +++ b/src/ui/settings-engine/controls/CheckboxControl.ts @@ -1,33 +1,36 @@ import type { CheckboxConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl, getUniqueID } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createInputElement, + dispatchControlEvent, + getUniqueID, +} from "./FieldControl.js"; export class CheckboxControl extends BaseControl { constructor(params: CheckboxConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); const id = getUniqueID(); - const input = document.createElement("input"); - input.type = "checkbox"; - input.className = "switch"; + const input = createInputElement("checkbox", "switch"); input.id = id; input.name = id; input.value = "true"; input.setAttribute("role", "switch"); - const label = document.createElement("label"); - label.htmlFor = id; - - if (params.label) { - label.innerHTML = params.label; + control.appendChild(input); + const label = appendLabel(control, params.label); + if (label) { + label.htmlFor = id; } input.addEventListener("change", () => { @@ -36,10 +39,6 @@ export class CheckboxControl extends BaseControl { this.emitter.fireEvent("action", value); }); - control.appendChild(input); - if (params.label) { - control.appendChild(label); - } root.appendChild(control); this._element = input; @@ -53,7 +52,7 @@ export class CheckboxControl extends BaseControl { set(value: boolean, silent?: boolean): this { (this._element as HTMLInputElement).checked = Boolean(value); if (!silent) { - this._element.dispatchEvent(new Event("change")); + dispatchControlEvent(this._element, "change"); } return this; } diff --git a/src/ui/settings-engine/controls/FieldControl.ts b/src/ui/settings-engine/controls/FieldControl.ts index eeb2f878..89bd904a 100644 --- a/src/ui/settings-engine/controls/FieldControl.ts +++ b/src/ui/settings-engine/controls/FieldControl.ts @@ -100,6 +100,62 @@ export function dispatchSettingsSaveStatus( ); } +export function createFieldRoot(className = "field"): HTMLDivElement { + const root = document.createElement("div"); + root.className = className; + return root; +} + +export function createControlContainer(className = "control"): HTMLDivElement { + const control = document.createElement("div"); + control.className = className; + return control; +} + +export function appendLabel( + parent: HTMLElement, + label?: string, + className = "label", +): HTMLLabelElement | undefined { + if (!label) { + return undefined; + } + + const element = document.createElement("label"); + element.className = className; + element.innerHTML = label; + parent.appendChild(element); + return element; +} + +export function createInputElement(type: string, className?: string): HTMLInputElement { + const input = document.createElement("input"); + input.type = type; + if (className) { + input.className = className; + } + return input; +} + +export function createButtonInput(text?: string): HTMLInputElement { + const input = createInputElement("button", "button is-primary"); + if (text) { + input.value = text; + } + return input; +} + +export function createOptionElement(value: string, text = value): HTMLOptionElement { + const option = document.createElement("option"); + option.value = value; + option.text = text; + return option; +} + +export function dispatchControlEvent(target: EventTarget, type: string): void { + target.dispatchEvent(new Event(type)); +} + // --- Abstract base control --- export abstract class BaseControl implements FieldControl { diff --git a/src/ui/settings-engine/controls/ListBoxControl.ts b/src/ui/settings-engine/controls/ListBoxControl.ts index e415ed7c..c42358da 100644 --- a/src/ui/settings-engine/controls/ListBoxControl.ts +++ b/src/ui/settings-engine/controls/ListBoxControl.ts @@ -1,7 +1,13 @@ import type { ListBoxConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; import type { ListBoxFieldControl } from "./FieldControl.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createOptionElement, +} from "./FieldControl.js"; export class ListBoxControl extends BaseControl implements ListBoxFieldControl { private readonly selectEl: HTMLSelectElement; @@ -11,19 +17,12 @@ export class ListBoxControl extends BaseControl implements ListBoxFiel constructor(params: ListBoxConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - root.appendChild(label); - } + appendLabel(root, params.label); - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); const wrapper = document.createElement("div"); wrapper.className = "select is-multiple is-fullwidth"; @@ -64,10 +63,14 @@ export class ListBoxControl extends BaseControl implements ListBoxFiel } private appendOption(value: string): void { - const opt = document.createElement("option"); - opt.value = value; - opt.text = value; - this.selectEl.appendChild(opt); + this.selectEl.appendChild(createOptionElement(value)); + } + + private persistOptions(): void { + if (this.name !== undefined) { + void this.storage.set(this.name, this.options); + } + this.emitter.fireEvent("action", this.get()); } add(value: string, storeValue = true): void { @@ -75,16 +78,13 @@ export class ListBoxControl extends BaseControl implements ListBoxFiel this.options.push(value); this.appendOption(value); if (storeValue) { - this.persist(); + this.persistOptions(); } } } persist(): void { - if (this.name !== undefined) { - void this.storage.set(this.name, this.options); - } - this.emitter.fireEvent("action", this.get()); + this.persistOptions(); } remove(): void { @@ -96,22 +96,14 @@ export class ListBoxControl extends BaseControl implements ListBoxFiel } } this.selected = []; - if (this.name !== undefined) { - void this.storage.set(this.name, this.options); - } - this.emitter.fireEvent("action", this.get()); + this.persistOptions(); } removeAll(): void { this.options = []; - while (this.selectEl.firstChild) { - this.selectEl.removeChild(this.selectEl.firstChild); - } + this.selectEl.replaceChildren(); this.selected = []; - if (this.name !== undefined) { - void this.storage.set(this.name, this.options); - } - this.emitter.fireEvent("action", this.get()); + this.persistOptions(); } get(): string[] { diff --git a/src/ui/settings-engine/controls/ListBoxMultiSelectControl.ts b/src/ui/settings-engine/controls/ListBoxMultiSelectControl.ts index dba1ca7e..9e205610 100644 --- a/src/ui/settings-engine/controls/ListBoxMultiSelectControl.ts +++ b/src/ui/settings-engine/controls/ListBoxMultiSelectControl.ts @@ -1,12 +1,15 @@ import type { ListBoxMultiselectConfig, OptionEntry } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createOptionElement, +} from "./FieldControl.js"; function appendOption(select: HTMLSelectElement, option: OptionEntry): void { - const el = document.createElement("option"); - el.value = option.value; - el.text = option.text ?? option.value; - select.appendChild(el); + select.appendChild(createOptionElement(option.value, option.text ?? option.value)); } export class ListBoxMultiSelectControl extends BaseControl { @@ -15,19 +18,12 @@ export class ListBoxMultiSelectControl extends BaseControl { constructor(params: ListBoxMultiselectConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - root.appendChild(label); - } + appendLabel(root, params.label); - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); const wrapper = document.createElement("div"); wrapper.className = "select is-multiple is-fullwidth"; diff --git a/src/ui/settings-engine/controls/ModalButtonControl.ts b/src/ui/settings-engine/controls/ModalButtonControl.ts index 127d1962..2b5340bb 100644 --- a/src/ui/settings-engine/controls/ModalButtonControl.ts +++ b/src/ui/settings-engine/controls/ModalButtonControl.ts @@ -1,7 +1,13 @@ import type { ModalButtonConfig, FieldConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; import type { FieldControl } from "./FieldControl.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createButtonInput, + createControlContainer, + createFieldRoot, +} from "./FieldControl.js"; type ControlFactory = (params: FieldConfig) => FieldControl; @@ -11,26 +17,13 @@ export class ModalButtonControl extends BaseControl { constructor(params: ModalButtonConfig, store: Store, createControl: ControlFactory) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); + appendLabel(control, params.label); - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - control.appendChild(label); - } - - const btn = document.createElement("input"); - btn.type = "button"; - btn.className = "button is-primary"; - if (params.text) { - btn.value = params.text; - } + const btn = createButtonInput(params.text); control.appendChild(btn); root.appendChild(control); @@ -60,10 +53,7 @@ export class ModalButtonControl extends BaseControl { modalBox.appendChild(nestedControl.rootElement); } - const doneBtn = document.createElement("input"); - doneBtn.type = "button"; - doneBtn.className = "button is-primary"; - doneBtn.value = "Done"; + const doneBtn = createButtonInput("Done"); modalBox.appendChild(doneBtn); backdrop.appendChild(modalBox); diff --git a/src/ui/settings-engine/controls/RadioControl.ts b/src/ui/settings-engine/controls/RadioControl.ts index a79d513a..81954259 100644 --- a/src/ui/settings-engine/controls/RadioControl.ts +++ b/src/ui/settings-engine/controls/RadioControl.ts @@ -1,6 +1,14 @@ import type { RadioConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl, getUniqueID } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createInputElement, + dispatchControlEvent, + getUniqueID, +} from "./FieldControl.js"; export class RadioControl extends BaseControl { private readonly radios: HTMLInputElement[] = []; @@ -8,30 +16,22 @@ export class RadioControl extends BaseControl { constructor(params: RadioConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); root.appendChild(control); const groupId = getUniqueID(); - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - control.appendChild(label); - } + appendLabel(control, params.label); for (const [value, text] of params.options ?? []) { const optionId = getUniqueID(); const radioLabel = document.createElement("label"); radioLabel.className = "radio"; - const radio = document.createElement("input"); - radio.type = "radio"; + const radio = createInputElement("radio"); radio.id = optionId; radio.name = groupId; radio.value = value; @@ -70,7 +70,7 @@ export class RadioControl extends BaseControl { target.checked = true; } if (!silent) { - this._element.dispatchEvent(new Event("change")); + dispatchControlEvent(this._element, "change"); } return this; } diff --git a/src/ui/settings-engine/controls/RuleToggleCardsControl.ts b/src/ui/settings-engine/controls/RuleToggleCardsControl.ts index d5c879fb..0b2a8ac2 100644 --- a/src/ui/settings-engine/controls/RuleToggleCardsControl.ts +++ b/src/ui/settings-engine/controls/RuleToggleCardsControl.ts @@ -553,10 +553,6 @@ export class RuleToggleCardsControl extends BaseControl { } } - override destroy(): void { - super.destroy(); - } - get(): string[] { return this.ruleControls.filter((rc) => rc.input.checked).map((rc) => rc.value); } diff --git a/src/ui/settings-engine/controls/SelectControl.ts b/src/ui/settings-engine/controls/SelectControl.ts index f94a2c62..5e4eb22d 100644 --- a/src/ui/settings-engine/controls/SelectControl.ts +++ b/src/ui/settings-engine/controls/SelectControl.ts @@ -1,7 +1,14 @@ import type { SelectConfig, OptionEntry } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; import type { SelectFieldControl } from "./FieldControl.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createOptionElement, + dispatchControlEvent, +} from "./FieldControl.js"; type RawOption = [string, string] | { value: string; text: string; group?: string }; @@ -29,19 +36,12 @@ export class SelectControl extends BaseControl implements SelectFieldCon constructor(params: SelectConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - root.appendChild(label); - } + appendLabel(root, params.label); - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); const wrapper = document.createElement("div"); wrapper.className = "select"; @@ -76,10 +76,7 @@ export class SelectControl extends BaseControl implements SelectFieldCon if (Array.isArray(options)) { for (const opt of options as RawOption[]) { const { value, text } = normalizeOption(opt); - const el = document.createElement("option"); - el.value = value; - el.text = text; - select.appendChild(el); + select.appendChild(createOptionElement(value, text)); } return; } @@ -98,9 +95,7 @@ export class SelectControl extends BaseControl implements SelectFieldCon } for (const opt of optObj.values ?? []) { - const el = document.createElement("option"); - el.value = opt.value; - el.text = opt.text ?? opt.value; + const el = createOptionElement(opt.value, opt.text ?? opt.value); if (opt.group && opt.group in groups) { groups[opt.group].appendChild(el); } else { @@ -119,9 +114,7 @@ export class SelectControl extends BaseControl implements SelectFieldCon for (const opt of options) { const { value, text } = normalizeOption(opt); - const el = document.createElement("option"); - el.value = value; - el.text = text; + const el = createOptionElement(value, text); if (selectedValue !== undefined && value === selectedValue) { el.selected = true; } @@ -136,7 +129,7 @@ export class SelectControl extends BaseControl implements SelectFieldCon set(value: string, silent?: boolean): this { this.selectEl.value = String(value ?? ""); if (!silent) { - this.selectEl.dispatchEvent(new Event("change")); + dispatchControlEvent(this.selectEl, "change"); } return this; } diff --git a/src/ui/settings-engine/controls/SliderControl.ts b/src/ui/settings-engine/controls/SliderControl.ts index a061e9fe..16deeaf2 100644 --- a/src/ui/settings-engine/controls/SliderControl.ts +++ b/src/ui/settings-engine/controls/SliderControl.ts @@ -1,6 +1,14 @@ import type { SliderConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl, getUniqueID } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createInputElement, + dispatchControlEvent, + getUniqueID, +} from "./FieldControl.js"; export class SliderControl extends BaseControl { private display?: HTMLOutputElement; @@ -11,22 +19,14 @@ export class SliderControl extends BaseControl { super(params, store); this.displayModifier = params.displayModifier; - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; - - if (params.label) { - const label = document.createElement("label"); - label.innerHTML = params.label; - control.appendChild(label); - } + const control = createControlContainer(); + appendLabel(control, params.label); const name = getUniqueID(); - const input = document.createElement("input"); - input.type = "range"; + const input = createInputElement("range"); input.name = name; input.className = `slider is-fullwidth${params.display ? " has-output" : ""}`; if (params.min !== undefined) { @@ -120,7 +120,7 @@ export class SliderControl extends BaseControl { } if (!silent) { - this._element.dispatchEvent(new Event("input")); + dispatchControlEvent(this._element, "input"); } return this; diff --git a/src/ui/settings-engine/controls/TextControl.ts b/src/ui/settings-engine/controls/TextControl.ts index e66d8ffb..59927e9d 100644 --- a/src/ui/settings-engine/controls/TextControl.ts +++ b/src/ui/settings-engine/controls/TextControl.ts @@ -1,6 +1,13 @@ import type { TextConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + createInputElement, + dispatchControlEvent, +} from "./FieldControl.js"; export class TextControl extends BaseControl { private hexLabel?: HTMLSpanElement; @@ -8,23 +15,13 @@ export class TextControl extends BaseControl { constructor(params: TextConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; + const control = createControlContainer(); + appendLabel(control, params.label); - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - control.appendChild(label); - } - - const input = document.createElement("input"); - input.type = "text"; - input.className = params.colorPicker ? "color" : "input"; + const input = createInputElement("text", params.colorPicker ? "color" : "input"); if (params.text) { input.placeholder = params.text; @@ -68,13 +65,8 @@ export class TextControl extends BaseControl { const valid = input.checkValidity(); input.classList.toggle("is-success", valid); input.classList.toggle("is-danger", !valid); - - if (!valid) { - errorEl.textContent = input.validationMessage || "Invalid value"; - errorEl.style.display = ""; - } else { - errorEl.style.display = "none"; - } + errorEl.textContent = valid ? "" : input.validationMessage || "Invalid value"; + errorEl.style.display = valid ? "none" : ""; if (params.store !== false) { this.persistToStorage(this.get()); @@ -105,7 +97,7 @@ export class TextControl extends BaseControl { this.hexLabel.textContent = String(value ?? "").toUpperCase(); } if (!silent) { - this._element.dispatchEvent(new Event("change")); + dispatchControlEvent(this._element, "change"); } return this; } diff --git a/src/ui/settings-engine/controls/TextareaControl.ts b/src/ui/settings-engine/controls/TextareaControl.ts index b91fe448..2e2aa700 100644 --- a/src/ui/settings-engine/controls/TextareaControl.ts +++ b/src/ui/settings-engine/controls/TextareaControl.ts @@ -1,24 +1,22 @@ import type { TextareaConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl } from "./FieldControl.js"; +import { + BaseControl, + appendLabel, + createControlContainer, + createFieldRoot, + dispatchControlEvent, +} from "./FieldControl.js"; export class TextareaControl extends BaseControl { constructor(params: TextareaConfig, store: Store) { super(params, store); - const root = document.createElement("div"); - root.className = "field"; + const root = createFieldRoot(); this._rootElement = root; - const control = document.createElement("div"); - control.className = "control"; - - if (params.label) { - const label = document.createElement("label"); - label.className = "label"; - label.innerHTML = params.label; - control.appendChild(label); - } + const control = createControlContainer(); + appendLabel(control, params.label); const textarea = document.createElement("textarea"); textarea.className = "textarea"; @@ -48,7 +46,7 @@ export class TextareaControl extends BaseControl { set(value: string, silent?: boolean): this { (this._element as HTMLTextAreaElement).value = String(value ?? ""); if (!silent) { - this._element.dispatchEvent(new Event("change")); + dispatchControlEvent(this._element, "change"); } return this; } diff --git a/src/ui/settings-engine/controls/ValueOnlyControl.ts b/src/ui/settings-engine/controls/ValueOnlyControl.ts index 28f45e83..fc8eaa28 100644 --- a/src/ui/settings-engine/controls/ValueOnlyControl.ts +++ b/src/ui/settings-engine/controls/ValueOnlyControl.ts @@ -1,6 +1,6 @@ import type { ValueOnlyConfig } from "../types.js"; import type { Store } from "@core/application/storage/Store.js"; -import { BaseControl } from "./FieldControl.js"; +import { BaseControl, createFieldRoot, createInputElement } from "./FieldControl.js"; /** * Invisible control — stores a value in chrome.storage with no UI widget. @@ -8,16 +8,15 @@ import { BaseControl } from "./FieldControl.js"; * The element is a inside it. */ export class ValueOnlyControl extends BaseControl { - private _value: unknown = undefined; + private _value: unknown; constructor(params: ValueOnlyConfig, store: Store) { super(params, store); - const root = document.createElement("div"); + const root = createFieldRoot(""); this._rootElement = root; - const input = document.createElement("input"); - input.type = "hidden"; + const input = createInputElement("hidden"); root.appendChild(input); this._element = input; diff --git a/src/ui/settings-engine/i18n/I18n.ts b/src/ui/settings-engine/i18n/I18n.ts index 32d82df0..6d3306f0 100644 --- a/src/ui/settings-engine/i18n/I18n.ts +++ b/src/ui/settings-engine/i18n/I18n.ts @@ -14,18 +14,12 @@ export class I18n { return this.lang; } - if (Object.prototype.hasOwnProperty.call(this, value)) { - const entry = this[value] as TranslationMap; - if (Object.prototype.hasOwnProperty.call(entry, this.lang)) { - return entry[this.lang]; - } else if (Object.prototype.hasOwnProperty.call(entry, "en")) { - return entry["en"]; - } else { - return Object.values(entry)[0] ?? value; - } + if (!Object.prototype.hasOwnProperty.call(this, value)) { + return value; } - return value; + const entry = this[value] as TranslationMap; + return entry[this.lang] ?? entry.en ?? Object.values(entry)[0] ?? value; } extend(translations: TranslationDictionary): void { diff --git a/src/ui/settings-engine/index.ts b/src/ui/settings-engine/index.ts index 742769d9..184fea85 100644 --- a/src/ui/settings-engine/index.ts +++ b/src/ui/settings-engine/index.ts @@ -1,4 +1,3 @@ -// Public API export type { FieldControl, SelectFieldControl, @@ -17,7 +16,6 @@ export type { SettingsRegistry, SettingsEngineOptions } from "./SettingsEngine.j export { I18n } from "./i18n/I18n.js"; export type { TranslationMap, TranslationDictionary } from "./i18n/I18n.js"; -// Controls (re-exported for direct import when needed) export { CheckboxControl } from "./controls/CheckboxControl.js"; export { SliderControl } from "./controls/SliderControl.js"; export { TextControl } from "./controls/TextControl.js"; diff --git a/src/ui/settings-engine/layout/GroupManager.ts b/src/ui/settings-engine/layout/GroupManager.ts index 250da069..2f2d310c 100644 --- a/src/ui/settings-engine/layout/GroupManager.ts +++ b/src/ui/settings-engine/layout/GroupManager.ts @@ -10,7 +10,7 @@ export function createGroup(tabContent: HTMLElement, label: string): GroupBundle header.className = "settings-group-header"; const title = document.createElement("h3"); title.className = "settings-group-title divider"; - title.innerText = label; + title.textContent = label; header.appendChild(title); groupDiv.appendChild(header); diff --git a/src/ui/settings-engine/layout/TabManager.ts b/src/ui/settings-engine/layout/TabManager.ts index cca7bc60..027b29e3 100644 --- a/src/ui/settings-engine/layout/TabManager.ts +++ b/src/ui/settings-engine/layout/TabManager.ts @@ -59,6 +59,11 @@ export class TabManager { this.contentContainer.appendChild(content); content.classList.add("is-hidden"); + const setActiveState = (active: boolean): void => { + tabLi.classList.toggle("is-active", active); + content.classList.toggle("is-active", active); + content.classList.toggle("is-hidden", !active); + }; const bundle: TabBundle = { tabLi, @@ -68,15 +73,11 @@ export class TabManager { if (this.activeBundle && this.activeBundle !== bundle) { this.activeBundle.deactivate(); } - tabLi.classList.add("is-active"); - content.classList.add("is-active"); - content.classList.remove("is-hidden"); + setActiveState(true); this.activeBundle = bundle; }, deactivate: () => { - tabLi.classList.remove("is-active"); - content.classList.remove("is-active"); - content.classList.add("is-hidden"); + setActiveState(false); this.activeBundle = null; }, }; diff --git a/src/ui/settings-engine/types.ts b/src/ui/settings-engine/types.ts index 57769778..602ecf19 100644 --- a/src/ui/settings-engine/types.ts +++ b/src/ui/settings-engine/types.ts @@ -12,8 +12,6 @@ export interface RuleToggleAction { values: string[]; } -// --- Discriminated union of all field config shapes --- - export type CheckboxConfig = { type: "checkbox"; tab: string; From 9150a2ebc30c5da7d6bf1652e9e29d8309f9dd33 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 10:17:57 +0100 Subject: [PATCH 3/4] fix: address CI lint failures --- .../background/bootstrap/BackgroundBootstrap.ts | 17 +++++++++++++---- .../background/testing/RuntimeTestHooks.noop.ts | 7 ++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts b/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts index 9714db12..cb5f408e 100644 --- a/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts +++ b/src/adapters/chrome/background/bootstrap/BackgroundBootstrap.ts @@ -43,14 +43,23 @@ export class BackgroundBootstrap { } private loadLastVersionAndInitialize(): void { - // Keep listener registration synchronous, but still await startup work once the - // persisted version is available so migration/config initialization stays ordered. - chrome.storage.local.get("lastVersion", async ({ lastVersion }) => { + const initializeFromLastVersion = async ({ + lastVersion, + }: { + lastVersion?: unknown; + }): Promise => { try { await this.worker.initialize(typeof lastVersion === "string" ? lastVersion : undefined); } catch (error) { logError("lastVersion handler", error); } - }); + }; + + // Keep listener registration synchronous, but still await startup work once the + // persisted version is available so migration/config initialization stays ordered. + chrome.storage.local.get( + "lastVersion", + initializeFromLastVersion as unknown as (items: { [key: string]: unknown }) => void, + ); } } diff --git a/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts b/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts index 4787c3a7..9ed5dce7 100644 --- a/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts +++ b/src/adapters/chrome/background/testing/RuntimeTestHooks.noop.ts @@ -7,11 +7,12 @@ interface RuntimeTestPredictionRequest { } export function maybePredictFromRuntimeTestOverride( - _request: RuntimeTestPredictionRequest, + request: RuntimeTestPredictionRequest, ): Promise { + void request; return Promise.resolve(null); } -export function registerRuntimeTestHooks(_commandRouter: CommandRouter): void { - return; +export function registerRuntimeTestHooks(commandRouter: CommandRouter): void { + void commandRouter; } From b7648ca5bb8c025a6b39281639ffdfd79e441eeb Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 10:25:26 +0100 Subject: [PATCH 4/4] style: satisfy CI formatting checks --- .../chrome/background/LanguageDetector.ts | 13 +++- src/adapters/chrome/background/Migration.ts | 4 +- .../background/PredictionOrchestrator.ts | 2 +- .../chrome/background/PresageEngine.ts | 5 +- .../chrome/background/PresageHandler.ts | 4 +- .../chrome/background/TabMessenger.ts | 4 +- .../background/config/runtimeSettings.ts | 21 +++--- .../background/webllm/ResponseParser.ts | 4 +- .../content-script/ContentMessageHandler.ts | 5 +- .../SuggestionEntryPredictionContext.ts | 36 +++++----- .../suggestions/SuggestionManagerRuntime.ts | 3 +- src/core/domain/contracts/settings.ts | 4 +- src/core/domain/grammar/GrammarRuleEngine.ts | 10 +-- .../EnglishPronounICapitalizationRule.ts | 4 +- .../grammar/implementations/SpacingRule.ts | 6 +- .../productivityStats/StatsAggregator.ts | 5 +- src/ui/options/SiteManagementPanel.ts | 6 +- src/ui/options/TextAssetsPanel.ts | 34 ++++++--- src/ui/options/settings.ts | 6 +- src/ui/options/settingsManifest.ts | 72 +++++++------------ src/ui/popup/popup.ts | 23 +++--- tests/SuggestionAcceptedState.test.ts | 13 +--- .../SuggestionEntryPredictionContext.test.ts | 12 ++-- 23 files changed, 144 insertions(+), 152 deletions(-) diff --git a/src/adapters/chrome/background/LanguageDetector.ts b/src/adapters/chrome/background/LanguageDetector.ts index 4acd84a2..549c91f6 100644 --- a/src/adapters/chrome/background/LanguageDetector.ts +++ b/src/adapters/chrome/background/LanguageDetector.ts @@ -129,7 +129,15 @@ export class LanguageDetector { const nextRuntimeGeneration = this.resolveRuntimeGeneration(request.runtimeGeneration); const session = this.sessions.get(key) || - this.createSessionState(key, request, nextRuntimeGeneration, domain, allowedLanguages, fallbackLanguage, now); + this.createSessionState( + key, + request, + nextRuntimeGeneration, + domain, + allowedLanguages, + fallbackLanguage, + now, + ); this.syncSessionScope(session, request, nextRuntimeGeneration, domain, allowedLanguages, now); session.rollingSample = updateAutoLanguageRollingSample(session.rollingSample, request.text); @@ -235,7 +243,8 @@ export class LanguageDetector { allowedLanguages: string[], now: number, ): void { - const scopeChanged = session.runtimeGeneration !== runtimeGeneration || session.domain !== domain; + const scopeChanged = + session.runtimeGeneration !== runtimeGeneration || session.domain !== domain; session.tabId = request.tabId; session.frameId = request.frameId; session.suggestionId = request.suggestionId; diff --git a/src/adapters/chrome/background/Migration.ts b/src/adapters/chrome/background/Migration.ts index e09804a2..f04dba8c 100644 --- a/src/adapters/chrome/background/Migration.ts +++ b/src/adapters/chrome/background/Migration.ts @@ -32,7 +32,9 @@ export async function migrateToLocalStore(lastVersion?: string): Promise { const siteProfileRepository = new SiteProfileRepository(settingsManager); const enabledLanguages = await coreSettings.getEnabledLanguages(); const rawSiteProfiles = await siteProfileRepository.getRawSiteProfiles(); - await siteProfileRepository.setSiteProfiles(resolveSiteProfiles(rawSiteProfiles, enabledLanguages)); + await siteProfileRepository.setSiteProfiles( + resolveSiteProfiles(rawSiteProfiles, enabledLanguages), + ); void chrome.storage.local.set({ lastVersion: currentVersion }); } diff --git a/src/adapters/chrome/background/PredictionOrchestrator.ts b/src/adapters/chrome/background/PredictionOrchestrator.ts index 3f007102..bc0e5daa 100644 --- a/src/adapters/chrome/background/PredictionOrchestrator.ts +++ b/src/adapters/chrome/background/PredictionOrchestrator.ts @@ -255,7 +255,7 @@ export class PredictionOrchestrator { const timeoutPromise = new Promise<{ predictions: string[]; timedOut: boolean; - }>((resolve) => { + }>((resolve) => { timeoutId = setTimeout(() => { this.interruptAIPrediction("timeout", { lang, diff --git a/src/adapters/chrome/background/PresageEngine.ts b/src/adapters/chrome/background/PresageEngine.ts index ac653bbc..23aafbf9 100644 --- a/src/adapters/chrome/background/PresageEngine.ts +++ b/src/adapters/chrome/background/PresageEngine.ts @@ -60,10 +60,7 @@ export class PresageEngine { } private createLibPresage(): Presage { - return new this.module.Presage( - this.callbackImpl, - `resources_js/${this.lang}/presage.xml`, - ); + return new this.module.Presage(this.callbackImpl, `resources_js/${this.lang}/presage.xml`); } private parsePrediction(rawPrediction: string): string | null { diff --git a/src/adapters/chrome/background/PresageHandler.ts b/src/adapters/chrome/background/PresageHandler.ts index 15f5fd92..26483c83 100644 --- a/src/adapters/chrome/background/PresageHandler.ts +++ b/src/adapters/chrome/background/PresageHandler.ts @@ -193,7 +193,9 @@ export class PresageHandler { const cachedPrediction = this.lastPrediction[lang]; if (cachedPrediction?.pastStream === predictionInput) { return Promise.all( - cachedPrediction.templates.map((text) => TemplateExpander.parseStringTemplateAsync(text, resolver)), + cachedPrediction.templates.map((text) => + TemplateExpander.parseStringTemplateAsync(text, resolver), + ), ); } const predictions = this.presageEngines[lang].predict(predictionInput); diff --git a/src/adapters/chrome/background/TabMessenger.ts b/src/adapters/chrome/background/TabMessenger.ts index c5e47065..2fedb70c 100644 --- a/src/adapters/chrome/background/TabMessenger.ts +++ b/src/adapters/chrome/background/TabMessenger.ts @@ -14,7 +14,9 @@ export class TabMessenger { }); } - private async queryTabs(queryInfo: chrome.tabs.QueryInfo): Promise { + private async queryTabs( + queryInfo: chrome.tabs.QueryInfo, + ): Promise { try { return await chrome.tabs.query(queryInfo); } catch { diff --git a/src/adapters/chrome/background/config/runtimeSettings.ts b/src/adapters/chrome/background/config/runtimeSettings.ts index 7ab52f02..7ac0a858 100644 --- a/src/adapters/chrome/background/config/runtimeSettings.ts +++ b/src/adapters/chrome/background/config/runtimeSettings.ts @@ -66,14 +66,19 @@ export async function resolveDomainRuntimeSettings( ): Promise { const settingsRepository = new CoreSettingsRepository(settingsManager); const siteProfileRepository = new SiteProfileRepository(settingsManager); - const [languageState, inlineSuggestionGlobal, preferNativeAutocompleteGlobal, numGlobal, siteProfilesRaw] = - await Promise.all([ - resolveLanguageState(settingsManager), - settingsRepository.getInlineSuggestion(), - settingsRepository.getPreferNativeAutocomplete(), - settingsRepository.getNumSuggestions(), - siteProfileRepository.getSiteProfiles(), - ]); + const [ + languageState, + inlineSuggestionGlobal, + preferNativeAutocompleteGlobal, + numGlobal, + siteProfilesRaw, + ] = await Promise.all([ + resolveLanguageState(settingsManager), + settingsRepository.getInlineSuggestion(), + settingsRepository.getPreferNativeAutocomplete(), + settingsRepository.getNumSuggestions(), + siteProfileRepository.getSiteProfiles(), + ]); const profile = domainURL ? getSiteProfileForDomain(siteProfilesRaw, domainURL, languageState.enabledLanguages) : undefined; diff --git a/src/adapters/chrome/background/webllm/ResponseParser.ts b/src/adapters/chrome/background/webllm/ResponseParser.ts index 0525d2b5..8b75ae55 100644 --- a/src/adapters/chrome/background/webllm/ResponseParser.ts +++ b/src/adapters/chrome/background/webllm/ResponseParser.ts @@ -149,6 +149,8 @@ export class ResponseParser { if (typeof value !== "object" || value === null) { return false; } - return typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function"; + return ( + typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function" + ); } } diff --git a/src/adapters/chrome/content-script/ContentMessageHandler.ts b/src/adapters/chrome/content-script/ContentMessageHandler.ts index bf894700..9fcc7895 100644 --- a/src/adapters/chrome/content-script/ContentMessageHandler.ts +++ b/src/adapters/chrome/content-script/ContentMessageHandler.ts @@ -22,10 +22,7 @@ import type { PredictResponseContext, SetConfigContext, } from "@core/domain/messageTypes"; -import { - createPredictionTraceContext, - resolveTraceAgeMs, -} from "./predictionTrace"; +import { createPredictionTraceContext, resolveTraceAgeMs } from "./predictionTrace"; type RuntimeInboundMessage = | Message diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts index e6b0af72..8625d975 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntryPredictionContext.ts @@ -49,7 +49,9 @@ function resolveBlockContext( contentEditableAdapter: SuggestionEntrySessionContentEditableAdapter, ): CursorContextBlock | null { const blockContext = contentEditableAdapter.getBlockContext(entry.elem as HTMLElement); - return blockContext ?? contentEditableAdapter.getBlockContextBySelection(entry.elem as HTMLElement); + return ( + blockContext ?? contentEditableAdapter.getBlockContextBySelection(entry.elem as HTMLElement) + ); } /** @@ -63,23 +65,21 @@ function resolveBlockContext( * character matches the typed character * - pending grammar replacements can seed contenteditable contexts */ -export function resolveEditableCursorContext( - { - entry, - contentEditableAdapter, - snapshot, - hasMultipleBlockDescendants, - inputAction, - typedKey, - }: { - entry: SuggestionEntryCursorContextSource; - contentEditableAdapter: SuggestionEntrySessionContentEditableAdapter; - snapshot: SuggestionSnapshot | null; - hasMultipleBlockDescendants: boolean; - inputAction?: PredictionInputAction; - typedKey?: string | null; - }, -): EditableCursorContext { +export function resolveEditableCursorContext({ + entry, + contentEditableAdapter, + snapshot, + hasMultipleBlockDescendants, + inputAction, + typedKey, +}: { + entry: SuggestionEntryCursorContextSource; + contentEditableAdapter: SuggestionEntrySessionContentEditableAdapter; + snapshot: SuggestionSnapshot | null; + hasMultipleBlockDescendants: boolean; + inputAction?: PredictionInputAction; + typedKey?: string | null; +}): EditableCursorContext { if (TextTargetAdapter.isTextValue(entry.elem)) { const resolvedSnapshot = snapshot ?? TextTargetAdapter.snapshot(entry.elem as TextTarget); return { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index d38e1d43..ca6bec15 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -169,8 +169,7 @@ export class SuggestionManagerRuntime { this.getSession(entry.id)?.acceptSuggestion(suggestion) ?? false, acceptSuggestionAtIndex: (entry, index) => this.getSession(entry.id)?.acceptSuggestionAtIndex(index) ?? false, - requestInlineSuggestion: (entry) => - this.getSession(entry.id)?.requestInlineSuggestion(), + requestInlineSuggestion: (entry) => this.getSession(entry.id)?.requestInlineSuggestion(), }); } diff --git a/src/core/domain/contracts/settings.ts b/src/core/domain/contracts/settings.ts index 441c0d57..7b15fd7b 100644 --- a/src/core/domain/contracts/settings.ts +++ b/src/core/domain/contracts/settings.ts @@ -170,7 +170,9 @@ const ALIASES_BY_CANONICAL: Record = { [SETTINGS_KEYS.suggestionPaddingHorizontal]: ["tributePaddingHorizontal"], }; -const CANONICAL_BY_STORAGE_KEY: Record = Object.entries(ALIASES_BY_CANONICAL).reduce( +const CANONICAL_BY_STORAGE_KEY: Record = Object.entries( + ALIASES_BY_CANONICAL, +).reduce( (lookup, [canonical, aliases]) => { lookup[canonical] = canonical; for (const alias of aliases) { diff --git a/src/core/domain/grammar/GrammarRuleEngine.ts b/src/core/domain/grammar/GrammarRuleEngine.ts index cfcdf611..0fc98136 100644 --- a/src/core/domain/grammar/GrammarRuleEngine.ts +++ b/src/core/domain/grammar/GrammarRuleEngine.ts @@ -1,7 +1,4 @@ -import { - applyGrammarEditToContext, - mergeSequentialGrammarEdits, -} from "./GrammarEditSequencing"; +import { applyGrammarEditToContext, mergeSequentialGrammarEdits } from "./GrammarEditSequencing"; import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "./types"; const MAX_PROCESS_ITERATIONS = 5; @@ -132,10 +129,7 @@ export class GrammarRuleEngine { return !enabledRules || enabledRules.includes(ruleId); } - private getSourceRuleId( - rule: GrammarRule, - edit: GrammarEdit, - ): GrammarEdit["sourceRuleId"] { + private getSourceRuleId(rule: GrammarRule, edit: GrammarEdit): GrammarEdit["sourceRuleId"] { if (edit.sourceRuleId) { return edit.sourceRuleId; } diff --git a/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts b/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts index 5b9b0ab6..cb06b31a 100644 --- a/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts +++ b/src/core/domain/grammar/implementations/EnglishPronounICapitalizationRule.ts @@ -14,7 +14,9 @@ export class EnglishPronounICapitalizationRule implements GrammarRule { readonly triggers: GrammarEventType[] = ["insertChar", "wordBoundary"]; apply(context: GrammarContext): GrammarEdit | null { - const boundaryContext = resolveEnglishBoundaryContext(context, { ignoreDeleteInputAction: true }); + const boundaryContext = resolveEnglishBoundaryContext(context, { + ignoreDeleteInputAction: true, + }); if (!boundaryContext) { return null; } diff --git a/src/core/domain/grammar/implementations/SpacingRule.ts b/src/core/domain/grammar/implementations/SpacingRule.ts index 2c7b59bf..4efbb7ea 100644 --- a/src/core/domain/grammar/implementations/SpacingRule.ts +++ b/src/core/domain/grammar/implementations/SpacingRule.ts @@ -216,7 +216,11 @@ export class SpacingRule extends SpacingRuleShared implements GrammarRule { closingBracket: string, closingIndex: number, ): SpacingPolicy { - const shouldInsertAfter = this.isProseLikeClosingContext(inputStr, closingBracket, closingIndex); + const shouldInsertAfter = this.isProseLikeClosingContext( + inputStr, + closingBracket, + closingIndex, + ); return { spaceBefore: Spacing.REMOVE_SPACE, spaceAfter: shouldInsertAfter ? Spacing.INSERT_SPACE : Spacing.NO_CHANGE, diff --git a/src/core/domain/productivityStats/StatsAggregator.ts b/src/core/domain/productivityStats/StatsAggregator.ts index c09d1e50..d445e8b3 100644 --- a/src/core/domain/productivityStats/StatsAggregator.ts +++ b/src/core/domain/productivityStats/StatsAggregator.ts @@ -266,8 +266,9 @@ export class StatsAggregator { ): ProductivityDashboardStats["milestoneProgress"] { const lifetimeHoursSaved = this.sanitizer.roundMetric(lifetimeMinutesSaved / 60); const previousMilestoneHours = - [...DONATION_MILESTONE_HOURS].reverse().find((milestone) => lifetimeHoursSaved >= milestone) || - 0; + [...DONATION_MILESTONE_HOURS] + .reverse() + .find((milestone) => lifetimeHoursSaved >= milestone) || 0; const highestDefinedMilestone = DONATION_MILESTONE_HOURS[DONATION_MILESTONE_HOURS.length - 1] || 0; diff --git a/src/ui/options/SiteManagementPanel.ts b/src/ui/options/SiteManagementPanel.ts index f1ae1f10..93c1397d 100644 --- a/src/ui/options/SiteManagementPanel.ts +++ b/src/ui/options/SiteManagementPanel.ts @@ -10,11 +10,7 @@ import { import { normalizeDomainHost } from "@core/domain/siteProfiles"; import { SiteProfilesManager } from "./siteProfiles.js"; import { i18n } from "./fluenttyperI18n.js"; -import { - bindRerender, - createWorkspaceCard, - createWorkspaceShell, -} from "./workspacePanelUtils.js"; +import { bindRerender, createWorkspaceCard, createWorkspaceShell } from "./workspacePanelUtils.js"; type DomainListMode = "blackList" | "whiteList"; diff --git a/src/ui/options/TextAssetsPanel.ts b/src/ui/options/TextAssetsPanel.ts index 59fb7a7d..ae74ea31 100644 --- a/src/ui/options/TextAssetsPanel.ts +++ b/src/ui/options/TextAssetsPanel.ts @@ -77,19 +77,31 @@ export class TextAssetsPanel { this.store = store; bindControlEvents(this.registry[KEY_TEXT_EXPANSIONS], [["action", () => void this.load()]]); - bindControlEvents(this.registry[KEY_USER_DICTIONARY_LIST], [["action", () => void this.load()]]); + bindControlEvents(this.registry[KEY_USER_DICTIONARY_LIST], [ + ["action", () => void this.load()], + ]); bindControlEvents(this.registry[KEY_DATE_FORMAT], [["action", () => void this.render()]]); bindControlEvents(this.registry[KEY_TIME_FORMAT], [["action", () => void this.render()]]); - bindControlEvents(this.registry[KEY_DATE_FORMAT], [["change", () => { - this.liveDateFormat = toTextValue(this.registry[KEY_DATE_FORMAT].get()); - this.refreshActiveSnippetPreview(); - void this.render(); - }]]); - bindControlEvents(this.registry[KEY_TIME_FORMAT], [["change", () => { - this.liveTimeFormat = toTextValue(this.registry[KEY_TIME_FORMAT].get()); - this.refreshActiveSnippetPreview(); - void this.render(); - }]]); + bindControlEvents(this.registry[KEY_DATE_FORMAT], [ + [ + "change", + () => { + this.liveDateFormat = toTextValue(this.registry[KEY_DATE_FORMAT].get()); + this.refreshActiveSnippetPreview(); + void this.render(); + }, + ], + ]); + bindControlEvents(this.registry[KEY_TIME_FORMAT], [ + [ + "change", + () => { + this.liveTimeFormat = toTextValue(this.registry[KEY_TIME_FORMAT].get()); + this.refreshActiveSnippetPreview(); + void this.render(); + }, + ], + ]); this.liveDateFormat = toTextValue(this.registry[KEY_DATE_FORMAT]?.get()); this.liveTimeFormat = toTextValue(this.registry[KEY_TIME_FORMAT]?.get()); diff --git a/src/ui/options/settings.ts b/src/ui/options/settings.ts index f72027c3..fb14760b 100644 --- a/src/ui/options/settings.ts +++ b/src/ui/options/settings.ts @@ -228,11 +228,7 @@ const OBSERVABILITY_REFRESH_KEYS = new Set([ KEY_OBSERVABILITY_DEFAULT_LEVEL, ]); -function bindActionHandler( - registry: SettingsRegistry, - key: string, - handler: () => void, -): void { +function bindActionHandler(registry: SettingsRegistry, key: string, handler: () => void): void { registry[key]?.addEvent("action", handler); } diff --git a/src/ui/options/settingsManifest.ts b/src/ui/options/settingsManifest.ts index e6fcf1c1..302f22ef 100644 --- a/src/ui/options/settingsManifest.ts +++ b/src/ui/options/settingsManifest.ts @@ -105,13 +105,10 @@ function createTab( } const DEV_TABS: ManifestDefinition["tabs"] = [ - createTab( + createTab("observability_tab", "observability_tab", "observability_tab_desc", "OB", [ "observability_tab", - "observability_tab", - "observability_tab_desc", - "OB", - ["observability_tab", "observability_dashboard_group"], - ), + "observability_dashboard_group", + ]), ]; const DEV_PREDICTOR_SETTINGS: FieldConfig[] = [ @@ -276,27 +273,18 @@ const manifest: ManifestDefinition = { name: i18n.get("options_page_title"), icon: "/icon/icon128.png", tabs: [ - createTab( - "core_settings", + createTab("core_settings", "options_tab_essentials", "options_tab_essentials_desc", "ES", [ "options_tab_essentials", - "options_tab_essentials_desc", - "ES", - ["options_tab_essentials", "prediction_engine"], - ), - createTab( - "grammar_tab", + "prediction_engine", + ]), + createTab("grammar_tab", "grammar_tab", "options_tab_grammar_desc", "GR", [ + "grammar_rules", "grammar_tab", - "options_tab_grammar_desc", - "GR", - ["grammar_rules", "grammar_tab"], - ), - createTab( - "language_tab", + ]), + createTab("language_tab", "options_tab_languages", "options_tab_languages_desc", "LA", [ "options_tab_languages", - "options_tab_languages_desc", - "LA", - ["options_tab_languages", "language_selection"], - ), + "language_selection", + ]), createTab( "shortcuts_expansions_tab", "options_tab_snippets", @@ -304,35 +292,23 @@ const manifest: ManifestDefinition = { "SD", ["options_tab_snippets", "text_expander"], ), - createTab( - "site_mgmt_tab", + createTab("site_mgmt_tab", "options_tab_sites", "options_tab_sites_desc", "SI", [ "options_tab_sites", - "options_tab_sites_desc", - "SI", - ["options_tab_sites", "site_profiles"], - ), - createTab( + "site_profiles", + ]), + createTab("theming_tab", "theming_tab", "options_tab_appearance_desc", "AP", [ "theming_tab", - "theming_tab", - "options_tab_appearance_desc", - "AP", - ["theming_tab", "theme_presets"], - ), - createTab( - "advanced_tab", + "theme_presets", + ]), + createTab("advanced_tab", "options_tab_data", "options_tab_data_desc", "DD", [ "options_tab_data", - "options_tab_data_desc", - "DD", - ["options_tab_data", "config_data"], - ), + "config_data", + ]), ...(IS_DEV_BUILD ? DEV_TABS : []), - createTab( - "about_support_tab", + createTab("about_support_tab", "options_tab_about", "options_tab_about_desc", "AB", [ "options_tab_about", - "options_tab_about_desc", - "AB", - ["options_tab_about", "support_development_group"], - ), + "support_development_group", + ]), ], settings: [ // ========================================================================= diff --git a/src/ui/popup/popup.ts b/src/ui/popup/popup.ts index 50713a99..d75fa787 100644 --- a/src/ui/popup/popup.ts +++ b/src/ui/popup/popup.ts @@ -339,7 +339,12 @@ function renderPermissionBlockedPageState(state: WebsiteAccessPermissionState): ), kind: "non_actionable" as const, }; - renderNonActionablePageState(permissionBlockedState, currentDomainURL, permissionBlockedState.kind, false); + renderNonActionablePageState( + permissionBlockedState, + currentDomainURL, + permissionBlockedState.kind, + false, + ); } function applyPopupThemeMode(theme: "light" | "dark"): void { @@ -541,11 +546,10 @@ function populateLanguageOptions(select: HTMLSelectElement, languages: string[]) } } -function populateSuggestionOptions( - select: HTMLSelectElement, - globalNumSuggestions: number, -): void { - select.replaceChildren(createSelectOption("global", getInheritLabel(String(globalNumSuggestions)))); +function populateSuggestionOptions(select: HTMLSelectElement, globalNumSuggestions: number): void { + select.replaceChildren( + createSelectOption("global", getInheritLabel(String(globalNumSuggestions))), + ); for (let idx = 0; idx <= MAX_NUM_SUGGESTIONS; idx += 1) { select.appendChild(createSelectOption(String(idx), String(idx))); } @@ -639,9 +643,10 @@ async function loadSiteProfileEditor() { function readSiteProfileFromEditor(): SiteProfile { const { language, suggestions, inline, preferNativeAutocomplete } = getSiteProfileElements(); - const languageValue = language && currentEnabledLanguages.includes(language.value) - ? language.value - : currentProfileLanguageFallback; + const languageValue = + language && currentEnabledLanguages.includes(language.value) + ? language.value + : currentProfileLanguageFallback; const profile: SiteProfile = { language: languageValue, }; diff --git a/tests/SuggestionAcceptedState.test.ts b/tests/SuggestionAcceptedState.test.ts index 092fb0b9..b1505bd5 100644 --- a/tests/SuggestionAcceptedState.test.ts +++ b/tests/SuggestionAcceptedState.test.ts @@ -192,10 +192,7 @@ describe("SuggestionAcceptedState", () => { describe("keyboard policy", () => { test("releases the accepted-suggestion suppression only for literal whitespace input", () => { - const event: Pick< - KeyboardEvent, - "key" | "metaKey" | "ctrlKey" | "altKey" | "isComposing" - > = { + const event: Pick = { key: " ", metaKey: false, ctrlKey: false, @@ -261,12 +258,8 @@ describe("SuggestionAcceptedState", () => { ctrlKey: false, }; - expect( - shouldInvalidatePendingExtensionEditOnKeydown(undoChord), - ).toBe(false); - expect( - shouldInvalidatePendingExtensionEditOnKeydown(arrowLeft), - ).toBe(true); + expect(shouldInvalidatePendingExtensionEditOnKeydown(undoChord)).toBe(false); + expect(shouldInvalidatePendingExtensionEditOnKeydown(arrowLeft)).toBe(true); expect(shouldDismissSuggestionsOnKeydown(home)).toBe(true); }); }); diff --git a/tests/SuggestionEntryPredictionContext.test.ts b/tests/SuggestionEntryPredictionContext.test.ts index 3c171ab7..062d4512 100644 --- a/tests/SuggestionEntryPredictionContext.test.ts +++ b/tests/SuggestionEntryPredictionContext.test.ts @@ -196,14 +196,10 @@ describe("resolvePredictionInputAction", () => { test("prefers event inputType when resolving the prediction action", () => { const inputEvent = new Event("input") as Event & { inputType?: string }; inputEvent.inputType = "deleteContentBackward"; - const action = resolvePredictionInputAction( - inputEvent, - "hell", - { - lastKeydownKey: null, - lastBeforeCursorText: "hello", - }, - ); + const action = resolvePredictionInputAction(inputEvent, "hell", { + lastKeydownKey: null, + lastBeforeCursorText: "hello", + }); expect(action).toBe("delete"); });