diff --git a/build.ts b/build.ts index ca87e169..57f37cf8 100644 --- a/build.ts +++ b/build.ts @@ -186,6 +186,11 @@ async function bundleExtension(context: BuildContext): Promise { outfile: path.join(context.buildDir, "content_script.js"), label: "content_script", }, + { + entrypoint: path.join(context.srcDir, "entries", "content_script_main_world_start.ts"), + outfile: path.join(context.buildDir, "content_script_main_world_start.js"), + label: "content_script_main_world_start", + }, { entrypoint: path.join(context.srcDir, "entries", "content_script_main_world.ts"), outfile: path.join(context.buildDir, "content_script_main_world.js"), diff --git a/platform/chrome/manifest.json b/platform/chrome/manifest.json index 427ce07d..bf43aa0f 100755 --- a/platform/chrome/manifest.json +++ b/platform/chrome/manifest.json @@ -5,6 +5,14 @@ }, "optional_host_permissions": [""], "content_scripts": [ + { + "js": ["content_script_main_world_start.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true, + "match_about_blank": true, + "world": "MAIN" + }, { "js": ["content_script_main_world.js"], "matches": [""], diff --git a/platform/edge/manifest.json b/platform/edge/manifest.json index 0c728ea3..aceefb48 100755 --- a/platform/edge/manifest.json +++ b/platform/edge/manifest.json @@ -5,6 +5,14 @@ }, "optional_host_permissions": [""], "content_scripts": [ + { + "js": ["content_script_main_world_start.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true, + "match_about_blank": true, + "world": "MAIN" + }, { "js": ["content_script_main_world.js"], "matches": [""], diff --git a/platform/firefox/manifest.json b/platform/firefox/manifest.json index 7b31a3ab..16d38fc8 100755 --- a/platform/firefox/manifest.json +++ b/platform/firefox/manifest.json @@ -5,6 +5,14 @@ }, "optional_host_permissions": [""], "content_scripts": [ + { + "js": ["content_script_main_world_start.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true, + "match_about_blank": true, + "world": "MAIN" + }, { "js": ["content_script_main_world.js"], "matches": [""], diff --git a/src/adapters/chrome/content-script/ContentRuntimeController.ts b/src/adapters/chrome/content-script/ContentRuntimeController.ts index e2346cec..8d94fae0 100644 --- a/src/adapters/chrome/content-script/ContentRuntimeController.ts +++ b/src/adapters/chrome/content-script/ContentRuntimeController.ts @@ -152,6 +152,19 @@ export class ContentRuntimeController { this.suggestionManager?.triggerActiveSuggestion(); } + handleEarlyTabAcceptRequest(entryId: string): EarlyTabAcceptResult { + return ( + this.suggestionManager?.handleEarlyTabAcceptRequest(entryId) ?? { + accepted: false, + reason: "entry_not_found", + entryId, + suggestionCount: 0, + menuVisible: false, + hasInlineSuggestion: false, + } + ); + } + getPredictionGeneration(): number { return this.predictionGeneration; } @@ -420,3 +433,4 @@ export class ContentRuntimeController { } } } +import type { EarlyTabAcceptResult } from "./suggestions/SuggestionManagerRuntime"; diff --git a/src/adapters/chrome/content-script/SuggestionManager.ts b/src/adapters/chrome/content-script/SuggestionManager.ts index c5c1689c..2bf0a6d5 100644 --- a/src/adapters/chrome/content-script/SuggestionManager.ts +++ b/src/adapters/chrome/content-script/SuggestionManager.ts @@ -1,4 +1,7 @@ -import { SuggestionManagerRuntime } from "./suggestions/SuggestionManagerRuntime"; +import { + SuggestionManagerRuntime, + type EarlyTabAcceptResult, +} from "./suggestions/SuggestionManagerRuntime"; import type { PredictionResponse, SuggestionManagerOptions } from "./suggestions/types"; export class SuggestionManager { @@ -20,6 +23,10 @@ export class SuggestionManager { this.runtime.triggerActiveSuggestion(); } + public handleEarlyTabAcceptRequest(entryId: string): EarlyTabAcceptResult { + return this.runtime.handleEarlyTabAcceptRequest(entryId); + } + public updateLangConfig(lang: string): void { this.runtime.updateLangConfig(lang); } diff --git a/src/adapters/chrome/content-script/content_script.ts b/src/adapters/chrome/content-script/content_script.ts index 250c3825..69b1ea08 100644 --- a/src/adapters/chrome/content-script/content_script.ts +++ b/src/adapters/chrome/content-script/content_script.ts @@ -18,6 +18,7 @@ import type { import { ContentMessageHandler } from "./ContentMessageHandler"; import { ContentRuntimeController } from "./ContentRuntimeController"; import { HostChangeWatcher } from "./HostChangeWatcher"; +import { isEarlyTabAcceptMessage } from "./suggestions/EarlyTabAcceptBridgeProtocol"; import { ThemeApplicator } from "./ThemeApplicator"; import type { DomObserver } from "./DomObserver"; import type { SuggestionManager } from "./SuggestionManager"; @@ -68,6 +69,8 @@ class FluentTyper { sender?: chrome.runtime.MessageSender, sendResponse?: (response: unknown) => void, ) => this.messageHandler(message, sender, sendResponse); + private readonly boundEarlyTabAcceptHandler = (event: MessageEvent) => + this.handleEarlyTabAccept(event); constructor() { logger.info("Initializing content script", { @@ -110,6 +113,7 @@ class FluentTyper { }); chrome.runtime.onMessage.addListener(this.boundMessageHandler); + window.addEventListener("message", this.boundEarlyTabAcceptHandler); this.hostChangeWatcher.start(); this.getConfig(); } @@ -186,9 +190,18 @@ class FluentTyper { logger.info("Destroying content script instance"); this.hostChangeWatcher.stop(); this.disable(); + window.removeEventListener("message", this.boundEarlyTabAcceptHandler); chrome.runtime.onMessage.removeListener(this.boundMessageHandler); } + handleEarlyTabAccept(event: MessageEvent): void { + if (!isEarlyTabAcceptMessage(event.data)) { + return; + } + + this.runtimeController.handleEarlyTabAcceptRequest(event.data.entryId); + } + messageHandler( message: Message | null, sender?: chrome.runtime.MessageSender, diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts new file mode 100644 index 00000000..6612202f --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts @@ -0,0 +1,25 @@ +export const EARLY_TAB_ACCEPT_REQUEST_EVENT = "ft-early-tab-accept-request"; +export const EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG = "__ftEarlyTabAcceptBridgeInstalled"; +export const EARLY_TAB_ACCEPT_ENTRY_ID_ATTR = "data-ft-suggestion-id"; +export const EARLY_TAB_ACCEPT_ENABLED_ATTR = "data-ft-autocomplete-on-tab"; +export const EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR = "data-ft-early-tab-bridge"; +export const EARLY_TAB_ACCEPT_MESSAGE_TYPE = "ft-early-tab-accept-message"; + +export interface EarlyTabAcceptMessage { + source: typeof EARLY_TAB_ACCEPT_REQUEST_EVENT; + type: typeof EARLY_TAB_ACCEPT_MESSAGE_TYPE; + entryId: string; +} + +export function isEarlyTabAcceptMessage(value: unknown): value is EarlyTabAcceptMessage { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return ( + candidate.source === EARLY_TAB_ACCEPT_REQUEST_EVENT && + candidate.type === EARLY_TAB_ACCEPT_MESSAGE_TYPE && + typeof candidate.entryId === "string" + ); +} diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts new file mode 100644 index 00000000..88ffea6a --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts @@ -0,0 +1,158 @@ +import { + EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, + EARLY_TAB_ACCEPT_ENABLED_ATTR, + EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, + EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG, + EARLY_TAB_ACCEPT_MESSAGE_TYPE, + EARLY_TAB_ACCEPT_REQUEST_EVENT, +} from "./EarlyTabAcceptBridgeProtocol"; + +type FluentTyperManagedElement = HTMLElement; +type FluentTyperBridgeWindow = Window & { + [EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG]?: boolean; + __ftEarlyTabAcceptBridgeKeydownHandler?: (event: KeyboardEvent) => void; +}; + +function resolveSuggestionMenu( + element: FluentTyperManagedElement, + doc: Document, +): HTMLElement | null { + const entryId = element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + if (!entryId) { + return null; + } + + const menu = doc.querySelector( + `[data-ft-suggestion-role="menu"][${EARLY_TAB_ACCEPT_ENTRY_ID_ATTR}="${entryId}"]`, + ); + return menu instanceof HTMLElement ? menu : null; +} + +function hasVisibleSuggestionMenu(element: FluentTyperManagedElement, doc: Document): boolean { + const menu = resolveSuggestionMenu(element, doc); + return menu instanceof HTMLElement && menu.isConnected && menu.style.display !== "none"; +} + +function isManagedSuggestionTarget( + element: HTMLElement | null, + doc: Document, +): element is FluentTyperManagedElement { + return ( + element instanceof HTMLElement && + element.getAttribute("data-suggestion") === "true" && + element.getAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR) === "true" && + element.getAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR) === "true" && + hasVisibleSuggestionMenu(element, doc) + ); +} + +function findManagedSuggestionTarget( + start: HTMLElement | null, + doc: Document, +): FluentTyperManagedElement | null { + let current: Node | null = start; + while (current) { + if (current instanceof HTMLElement && isManagedSuggestionTarget(current, doc)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function resolveManagedSuggestionTarget( + event: KeyboardEvent, + doc: Document, +): FluentTyperManagedElement | null { + const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target]; + for (const node of path) { + if (node instanceof HTMLElement) { + const match = findManagedSuggestionTarget(node, doc); + if (match) { + return match; + } + } + } + + const activeElement = doc.activeElement; + return activeElement instanceof HTMLElement + ? findManagedSuggestionTarget(activeElement, doc) + : null; +} + +export function installEarlyTabAcceptMainWorldBridge(doc: Document = document): void { + const win = doc.defaultView; + if (!win) { + return; + } + const bridgeWindow = win as FluentTyperBridgeWindow; + if (bridgeWindow[EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG]) { + return; + } + + bridgeWindow[EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG] = true; + + const handler = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.key !== "Tab" || + event.shiftKey || + event.altKey || + event.ctrlKey || + event.metaKey || + event.isComposing + ) { + return; + } + + const target = resolveManagedSuggestionTarget(event, doc); + if (!target) { + return; + } + + const entryId = target.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + if (!entryId) { + return; + } + + win.postMessage( + { + source: EARLY_TAB_ACCEPT_REQUEST_EVENT, + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId, + }, + "*", + ); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + bridgeWindow.__ftEarlyTabAcceptBridgeKeydownHandler = handler; + + win.addEventListener("keydown", handler, true); +} + +export function resetEarlyTabAcceptMainWorldBridgeForTests(doc: Document = document): void { + const win = doc.defaultView as FluentTyperBridgeWindow | null; + if (!win) { + return; + } + + const handler = win.__ftEarlyTabAcceptBridgeKeydownHandler; + if (handler) { + win.removeEventListener("keydown", handler, true); + delete win.__ftEarlyTabAcceptBridgeKeydownHandler; + } + + if (win[EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG]) { + delete win[EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG]; + } +} + +export { + EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, + EARLY_TAB_ACCEPT_ENABLED_ATTR, + EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, + EARLY_TAB_ACCEPT_MESSAGE_TYPE, + EARLY_TAB_ACCEPT_REQUEST_EVENT, +} from "./EarlyTabAcceptBridgeProtocol"; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts index fe94cbbd..a86bf8ab 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts @@ -432,16 +432,16 @@ export class SuggestionEntrySession { controls.dismissEntry(); } - public acceptSuggestionAtIndex(index: number): void { + public acceptSuggestionAtIndex(index: number): boolean { const suggestion = this.entry.suggestions[index]; if (!suggestion) { - return; + return false; } - this.acceptSuggestion(suggestion); + return this.acceptSuggestion(suggestion); } - public acceptSuggestion(suggestion: string): void { - this.acceptSuggestionInternal(suggestion); + public acceptSuggestion(suggestion: string): boolean { + return this.acceptSuggestionInternal(suggestion); } public reconcileSelection(controls: { dismissEntry: () => void }): void { @@ -1187,12 +1187,12 @@ export class SuggestionEntrySession { return true; } - private acceptSuggestionInternal(suggestion: string): void { + private acceptSuggestionInternal(suggestion: string): boolean { this.entry.suppressNextSuggestionInputPrediction = true; const accepted = this.textEditService.acceptSuggestion(this.entry, suggestion); if (!accepted) { this.entry.suppressNextSuggestionInputPrediction = false; - return; + return false; } this.finishAcceptedSuggestion( accepted.triggerText, @@ -1200,6 +1200,7 @@ export class SuggestionEntrySession { accepted.cursorAfter, accepted.cursorAfterIsBlockLocal, ); + return true; } private finishAcceptedSuggestion( diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionKeyboardHandler.ts b/src/adapters/chrome/content-script/suggestions/SuggestionKeyboardHandler.ts index 9b23b8e9..82a21150 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionKeyboardHandler.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionKeyboardHandler.ts @@ -14,8 +14,8 @@ interface SuggestionKeyboardHandlerOptions { clearSuggestions: (entry: SuggestionEntry) => void; isMenuVisible: (entry: SuggestionEntry) => boolean; updateSelectionHighlight: (entry: SuggestionEntry) => void; - acceptSuggestion: (entry: SuggestionEntry, suggestion: string) => void; - acceptSuggestionAtIndex: (entry: SuggestionEntry, index: number) => void; + acceptSuggestion: (entry: SuggestionEntry, suggestion: string) => boolean; + acceptSuggestionAtIndex: (entry: SuggestionEntry, index: number) => boolean; requestInlineSuggestion: (entry: SuggestionEntry) => void; } @@ -38,8 +38,8 @@ export class SuggestionKeyboardHandler { private readonly clearSuggestions: (entry: SuggestionEntry) => void; private readonly isMenuVisible: (entry: SuggestionEntry) => boolean; private readonly updateSelectionHighlight: (entry: SuggestionEntry) => void; - private readonly acceptSuggestion: (entry: SuggestionEntry, suggestion: string) => void; - private readonly acceptSuggestionAtIndex: (entry: SuggestionEntry, index: number) => void; + private readonly acceptSuggestion: (entry: SuggestionEntry, suggestion: string) => boolean; + private readonly acceptSuggestionAtIndex: (entry: SuggestionEntry, index: number) => boolean; private readonly requestInlineSuggestion: (entry: SuggestionEntry) => void; constructor(options: SuggestionKeyboardHandlerOptions) { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts index 6fb74889..1c191f4d 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts @@ -12,11 +12,14 @@ export class SuggestionLifecycleController { private readonly dismissEntry: (entry: SuggestionEntry) => void; private readonly reconcileEntrySelection: (entry: SuggestionEntry) => void; private readonly doc: Document; + private readonly keydownListenerByEntryId = new Map(); private attachedEntryCount = 0; private documentPointerDownListenerAttached = false; + private documentKeyDownListenerAttached = false; private documentSelectionChangeListenerAttached = false; private readonly onDocumentPointerDownBound: EventListener = this.onDocumentPointerDown.bind(this); + private readonly onDocumentKeyDownBound: EventListener = this.onDocumentKeyDown.bind(this); private readonly onDocumentSelectionChangeBound: EventListener = this.onDocumentSelectionChange.bind(this); @@ -30,7 +33,7 @@ export class SuggestionLifecycleController { public attachEntryListeners(entry: SuggestionEntry): void { entry.elem.addEventListener("beforeinput", entry.handlers.beforeinput, true); entry.elem.addEventListener("input", entry.handlers.input, true); - entry.elem.addEventListener("keydown", entry.handlers.keydown, true); + entry.elem.addEventListener("keydown", this.getEntryKeydownListener(entry), true); entry.elem.addEventListener("paste", entry.handlers.paste, true); entry.elem.addEventListener("focus", entry.handlers.focus, true); entry.elem.addEventListener("blur", entry.handlers.blur, true); @@ -43,13 +46,14 @@ export class SuggestionLifecycleController { this.attachedEntryCount += 1; this.ensureDocumentPointerDownListener(); + this.ensureDocumentKeyDownListener(); this.ensureDocumentSelectionChangeListener(); } public detachEntryListeners(entry: SuggestionEntry): void { entry.elem.removeEventListener("beforeinput", entry.handlers.beforeinput, true); entry.elem.removeEventListener("input", entry.handlers.input, true); - entry.elem.removeEventListener("keydown", entry.handlers.keydown, true); + entry.elem.removeEventListener("keydown", this.getEntryKeydownListener(entry), true); entry.elem.removeEventListener("paste", entry.handlers.paste, true); entry.elem.removeEventListener("focus", entry.handlers.focus, true); entry.elem.removeEventListener("blur", entry.handlers.blur, true); @@ -59,10 +63,12 @@ export class SuggestionLifecycleController { this.toggleBackingInputTargetListeners(entry, false); entry.list.removeEventListener("mousedown", entry.handlers.menuMouseDown); entry.list.removeEventListener("click", entry.handlers.menuClick); + this.keydownListenerByEntryId.delete(entry.id); this.attachedEntryCount = Math.max(0, this.attachedEntryCount - 1); if (this.attachedEntryCount === 0) { this.removeDocumentPointerDownListener(); + this.removeDocumentKeyDownListener(); this.removeDocumentSelectionChangeListener(); } } @@ -75,7 +81,7 @@ export class SuggestionLifecycleController { const method = attach ? "addEventListener" : "removeEventListener"; inputEventTarget[method]("beforeinput", entry.handlers.beforeinput, true); inputEventTarget[method]("input", entry.handlers.input, true); - inputEventTarget[method]("keydown", entry.handlers.keydown, true); + inputEventTarget[method]("keydown", this.getEntryKeydownListener(entry), true); inputEventTarget[method]("paste", entry.handlers.paste, true); inputEventTarget[method]("focus", entry.handlers.focus, true); inputEventTarget[method]("blur", entry.handlers.blur, true); @@ -83,6 +89,23 @@ export class SuggestionLifecycleController { inputEventTarget[method]("compositionend", entry.handlers.compositionEnd, true); } + private getEntryKeydownListener(entry: SuggestionEntry): EventListener { + const existing = this.keydownListenerByEntryId.get(entry.id); + if (existing) { + return existing; + } + + const listener: EventListener = (event) => { + const keyboardEvent = event as KeyboardEvent & { __ftDocumentTabCaptureHandled?: boolean }; + if (keyboardEvent.__ftDocumentTabCaptureHandled) { + return; + } + entry.handlers.keydown(event); + }; + this.keydownListenerByEntryId.set(entry.id, listener); + return listener; + } + private ensureDocumentPointerDownListener(): void { if (this.documentPointerDownListenerAttached) { return; @@ -99,6 +122,22 @@ export class SuggestionLifecycleController { this.documentPointerDownListenerAttached = false; } + private ensureDocumentKeyDownListener(): void { + if (this.documentKeyDownListenerAttached) { + return; + } + this.doc.addEventListener("keydown", this.onDocumentKeyDownBound, true); + this.documentKeyDownListenerAttached = true; + } + + private removeDocumentKeyDownListener(): void { + if (!this.documentKeyDownListenerAttached) { + return; + } + this.doc.removeEventListener("keydown", this.onDocumentKeyDownBound, true); + this.documentKeyDownListenerAttached = false; + } + private ensureDocumentSelectionChangeListener(): void { if (this.documentSelectionChangeListenerAttached) { return; @@ -134,6 +173,61 @@ export class SuggestionLifecycleController { } } + private onDocumentKeyDown(event: Event): void { + const keyboardEvent = event as KeyboardEvent & { __ftDocumentTabCaptureHandled?: boolean }; + if ( + keyboardEvent.defaultPrevented || + keyboardEvent.key !== "Tab" || + keyboardEvent.__ftDocumentTabCaptureHandled + ) { + return; + } + + const composedPath = typeof event.composedPath === "function" ? event.composedPath() : []; + const path = composedPath.length > 0 ? composedPath : [event.target]; + const entries = [...this.getEntries()]; + + for (const node of path) { + const directBackingTargetMatch = entries.find( + (entry) => this.isDocumentTabFallbackEligible(entry) && node === entry.inputEventTarget, + ); + if (directBackingTargetMatch) { + directBackingTargetMatch.handlers.keydown(keyboardEvent); + keyboardEvent.__ftDocumentTabCaptureHandled = true; + return; + } + + const directElementMatch = entries.find( + (entry) => this.isDocumentTabFallbackEligible(entry) && node === entry.elem, + ); + if (directElementMatch) { + directElementMatch.handlers.keydown(keyboardEvent); + keyboardEvent.__ftDocumentTabCaptureHandled = true; + return; + } + + if (!(node instanceof Node)) { + continue; + } + + const containingEntry = entries.find( + (entry) => this.isDocumentTabFallbackEligible(entry) && entry.elem.contains(node), + ); + if (containingEntry) { + containingEntry.handlers.keydown(keyboardEvent); + keyboardEvent.__ftDocumentTabCaptureHandled = true; + return; + } + } + } + + private isDocumentTabFallbackEligible(entry: SuggestionEntry): boolean { + return ( + entry.inlineSuggestion !== null || + (entry.suggestions.length > 0 && entry.menu.style.display !== "none") + ); + } + private onDocumentSelectionChange(): void { for (const entry of this.getEntries()) { this.reconcileEntrySelection(entry); diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 35961b84..7c4504cc 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -23,6 +23,11 @@ import { EditableContextResolver } from "./EditableContextResolver"; import { SuggestionTextEditService } from "./SuggestionTextEditService"; import { ContentEditableAdapter } from "./ContentEditableAdapter"; import { TextTargetAdapter } from "./TextTargetAdapter"; +import { + EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, + EARLY_TAB_ACCEPT_ENABLED_ATTR, + EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, +} from "./EarlyTabAcceptBridgeProtocol"; import { resolveTraceAgeMs } from "../predictionTrace"; import type { PendingKeyFallback, @@ -40,6 +45,21 @@ const SUGGESTION_DEBOUNCE_BY_ACTION = { }; const logger = createLogger("SuggestionManagerRuntime"); +export interface EarlyTabAcceptResult { + accepted: boolean; + reason: + | "entry_not_found" + | "session_not_found" + | "accepted_inline" + | "accepted_menu" + | "accept_failed" + | "no_visible_suggestion_state"; + entryId: string; + suggestionCount: number; + menuVisible: boolean; + hasInlineSuggestion: boolean; +} + export class SuggestionManagerRuntime { private readonly discovery: SuggestionElementDiscovery; private readonly entryRegistry = new SuggestionEntryRegistry(); @@ -62,6 +82,7 @@ export class SuggestionManagerRuntime { private readonly pendingKeyFallbacks = new Map(); private readonly displayLangHeader: boolean; + private readonly autocompleteOnTab: boolean; private readonly inlineSuggestionEnabled: boolean; private readonly insertSpaceAfterAutocomplete: boolean; private readonly preferNativeAutocomplete: boolean; @@ -86,6 +107,7 @@ export class SuggestionManagerRuntime { }); this.displayLangHeader = options.displayLangHeader; + this.autocompleteOnTab = options.autocompleteOnTab; this.inlineSuggestionEnabled = options.inline_suggestion; this.insertSpaceAfterAutocomplete = options.insertSpaceAfterAutocomplete; this.preferNativeAutocomplete = options.preferNativeAutocomplete; @@ -199,6 +221,71 @@ export class SuggestionManagerRuntime { this.sessionRegistry.get(entry.id)?.requestPrediction(); } + public handleEarlyTabAcceptRequest(entryId: string): EarlyTabAcceptResult { + const entry = this.resolveEntryForBridgeEntryId(entryId); + if (!entry) { + return { + accepted: false, + reason: "entry_not_found", + entryId, + suggestionCount: 0, + menuVisible: false, + hasInlineSuggestion: false, + }; + } + + const session = this.sessionRegistry.get(entry.id); + if (!session) { + return { + accepted: false, + reason: "session_not_found", + entryId, + suggestionCount: entry.suggestions.length, + menuVisible: this.menuPresenter.isVisible(entry.menu, entry.suggestions.length), + hasInlineSuggestion: entry.inlineSuggestion !== null, + }; + } + + this.activeEntryId = entry.id; + + if (this.inlineSuggestionEnabled && entry.inlineSuggestion) { + const accepted = session.acceptSuggestion(entry.inlineSuggestion); + return { + accepted, + reason: accepted ? "accepted_inline" : "accept_failed", + entryId, + suggestionCount: entry.suggestions.length, + menuVisible: this.menuPresenter.isVisible(entry.menu, entry.suggestions.length), + hasInlineSuggestion: true, + }; + } + + if ( + this.autocompleteOnTab && + this.menuPresenter.isVisible(entry.menu, entry.suggestions.length) && + entry.suggestions.length > 0 + ) { + const accepted = session.acceptSuggestionAtIndex(entry.selectedIndex); + return { + accepted, + reason: accepted ? "accepted_menu" : "accept_failed", + entryId, + suggestionCount: entry.suggestions.length, + menuVisible: true, + hasInlineSuggestion: entry.inlineSuggestion !== null, + }; + } + + return { + accepted: false, + reason: "no_visible_suggestion_state", + entryId, + suggestionCount: entry.suggestions.length, + menuVisible: this.menuPresenter.isVisible(entry.menu, entry.suggestions.length), + hasInlineSuggestion: entry.inlineSuggestion !== null, + }; + } + public updateLangConfig(lang: string): void { if (this.lang === lang) { return; @@ -436,6 +523,12 @@ export class SuggestionManagerRuntime { elem.setAttribute("data-tribute", "true"); elem.setAttribute("data-suggestion", "true"); + elem.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, String(id)); + elem.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, String(this.autocompleteOnTab)); + elem.setAttribute( + EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, + String(this.shouldUseEarlyTabBridge(elem)), + ); menu.dataset.ftSuggestionId = String(id); elem.tributeMenu = menu; elem.suggestionMenu = menu; @@ -470,6 +563,9 @@ export class SuggestionManagerRuntime { delete entry.elem.suggestionMenu; entry.elem.removeAttribute("data-tribute"); entry.elem.removeAttribute("data-suggestion"); + entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); + entry.elem.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); this.entryRegistry.unregister(id); this.sessionRegistry.delete(id); @@ -520,6 +616,15 @@ export class SuggestionManagerRuntime { return entry; } + private resolveEntryForBridgeEntryId(entryId: string): SuggestionEntry | null { + const numericId = Number(entryId); + if (!Number.isInteger(numericId)) { + return null; + } + + return this.entryRegistry.getById(numericId) ?? null; + } + private onElementFocus(id: number): void { this.activeEntryId = id; this.sessionRegistry.get(id)?.handleFocus(); @@ -693,12 +798,16 @@ export class SuggestionManagerRuntime { } private onElementKeyDown(id: number, event: Event): void { + const keyboardEvent = event as KeyboardEvent & { __ftDocumentTabCaptureHandled?: boolean }; + if (keyboardEvent.__ftDocumentTabCaptureHandled) { + return; + } + this.activeEntryId = id; const entry = this.entryRegistry.getById(id); if (!entry) { return; } - const keyboardEvent = event as KeyboardEvent; this.sessionRegistry.get(id)?.handleKeyDown(keyboardEvent, { dispatchKeyboard: () => this.keyboardHandler.handle(entry, keyboardEvent), dismissEntry: (keepActive = true) => this.dismissEntry(entry, keepActive), @@ -748,6 +857,10 @@ export class SuggestionManagerRuntime { this.pendingKeyFallbacks.delete(id); } + private shouldUseEarlyTabBridge(elem: SuggestionElement): boolean { + return elem.tagName !== "INPUT" && elem.tagName !== "TEXTAREA"; + } + private consumeCancelableEvent(event: Event): void { event.preventDefault(); event.stopPropagation(); diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 238e59cb..f3a78c47 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -1062,7 +1062,7 @@ export class SuggestionTextEditService { }; } - return this.replaceTextByOffsets( + const initialApplyResult = this.replaceTextByOffsets( elem, blockSourceText, replaceStart, @@ -1071,6 +1071,29 @@ export class SuggestionTextEditService { cursorAfter, { scopeRoot: activeBlock }, ); + const deferredHostNoMutation = + "appliedBy" in initialApplyResult && + initialApplyResult.appliedBy === "host-beforeinput" && + !initialApplyResult.didMutateDom; + + if (!deferredHostNoMutation) { + return initialApplyResult; + } + + const domFallbackResult = this.replaceTextByOffsets( + elem, + blockSourceText, + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + { + preferDomMutation: true, + scopeRoot: activeBlock, + }, + ); + + return domFallbackResult.didMutateDom ? domFallbackResult : initialApplyResult; } private acceptContentEditableSuggestion( diff --git a/src/core/application/repositories/CoreSettingsRepository.ts b/src/core/application/repositories/CoreSettingsRepository.ts index 6208a3d0..40fefa38 100644 --- a/src/core/application/repositories/CoreSettingsRepository.ts +++ b/src/core/application/repositories/CoreSettingsRepository.ts @@ -122,11 +122,11 @@ export class CoreSettingsRepository extends SettingsRepositoryBase { } async getAutocompleteOnEnter(): Promise { - return this.getBooleanField("autocompleteOnEnter"); + return this.getBooleanField("autocompleteOnEnter", true); } async getAutocompleteOnTab(): Promise { - return this.getBooleanField("autocompleteOnTab"); + return this.getBooleanField("autocompleteOnTab", true); } async getSelectByDigit(): Promise { diff --git a/src/entries/content_script_main_world_start.ts b/src/entries/content_script_main_world_start.ts new file mode 100644 index 00000000..65d8daa9 --- /dev/null +++ b/src/entries/content_script_main_world_start.ts @@ -0,0 +1,3 @@ +import { installEarlyTabAcceptMainWorldBridge } from "@adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; + +installEarlyTabAcceptMainWorldBridge(); diff --git a/tests/CoreSettingsRepository.test.ts b/tests/CoreSettingsRepository.test.ts index 203ad215..3835f49c 100644 --- a/tests/CoreSettingsRepository.test.ts +++ b/tests/CoreSettingsRepository.test.ts @@ -21,6 +21,13 @@ describe("CoreSettingsRepository", () => { await expect(repository.getPreferNativeAutocomplete()).resolves.toBe(true); }); + test("defaults autocompleteOnEnter and autocompleteOnTab to true when absent", async () => { + const repository = new CoreSettingsRepository(createSettingsManagerMock({})); + + await expect(repository.getAutocompleteOnEnter()).resolves.toBe(true); + await expect(repository.getAutocompleteOnTab()).resolves.toBe(true); + }); + test("keeps legacy [shortcut, string] entries for runtime compatibility", async () => { const repository = new CoreSettingsRepository( createSettingsManagerMock({ diff --git a/tests/EarlyTabAcceptMainWorldBridge.test.ts b/tests/EarlyTabAcceptMainWorldBridge.test.ts new file mode 100644 index 00000000..7928c0cd --- /dev/null +++ b/tests/EarlyTabAcceptMainWorldBridge.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, jest, test } from "bun:test"; +import { + EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, + EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, + EARLY_TAB_ACCEPT_ENABLED_ATTR, + EARLY_TAB_ACCEPT_MESSAGE_TYPE, + installEarlyTabAcceptMainWorldBridge, + resetEarlyTabAcceptMainWorldBridgeForTests, +} from "../src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; + +declare global { + interface Window { + __ftEarlyTabAcceptBridgeInstalled?: boolean; + } +} + +describe("EarlyTabAcceptMainWorldBridge", () => { + afterEach(() => { + document.body.innerHTML = ""; + resetEarlyTabAcceptMainWorldBridgeForTests(document); + }); + + test("posts an early accept request before a later page capture listener stops propagation", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + const menu = document.createElement("div"); + menu.style.display = "block"; + menu.setAttribute("data-ft-suggestion-role", "menu"); + menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + document.body.append(input, menu); + + const pageCaptureBlocker = (event: Event) => { + event.stopImmediatePropagation(); + }; + document.addEventListener("keydown", pageCaptureBlocker, true); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + source: "ft-early-tab-accept-request", + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId: "7", + }, + "*", + ); + expect(keydown.defaultPrevented).toBe(true); + document.removeEventListener("keydown", pageCaptureBlocker, true); + postMessageSpy.mockRestore(); + }); + + test("posts an early accept request before a later window capture listener stops propagation", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); + const menu = document.createElement("div"); + menu.style.display = "block"; + menu.setAttribute("data-ft-suggestion-role", "menu"); + menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); + document.body.append(input, menu); + + const windowCaptureBlocker = (event: Event) => { + event.stopImmediatePropagation(); + }; + window.addEventListener("keydown", windowCaptureBlocker, true); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + source: "ft-early-tab-accept-request", + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId: "9", + }, + "*", + ); + expect(keydown.defaultPrevented).toBe(true); + window.removeEventListener("keydown", windowCaptureBlocker, true); + postMessageSpy.mockRestore(); + }); + + test("does not post when there is no visible FluentTyper menu", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + const menu = document.createElement("div"); + menu.style.display = "none"; + menu.setAttribute("data-ft-suggestion-role", "menu"); + menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + document.body.append(input, menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); + + test("does not post when Tab acceptance is disabled for the managed target", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "false"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); + const menu = document.createElement("div"); + menu.style.display = "block"; + menu.setAttribute("data-ft-suggestion-role", "menu"); + menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); + document.body.append(input, menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); + + test("does not post for plain text inputs that should keep the regular Tab handler", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("input"); + input.type = "text"; + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "false"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); + const menu = document.createElement("div"); + menu.style.display = "block"; + menu.setAttribute("data-ft-suggestion-role", "menu"); + menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); + document.body.append(input, menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); +}); diff --git a/tests/SuggestionLifecycleController.test.ts b/tests/SuggestionLifecycleController.test.ts index aba3d727..e5b9412e 100644 --- a/tests/SuggestionLifecycleController.test.ts +++ b/tests/SuggestionLifecycleController.test.ts @@ -124,7 +124,6 @@ describe("SuggestionLifecycleController", () => { document.body.append(textarea, codeMirror, menu); const input = jest.fn(); - const keydown = jest.fn(); const focus = jest.fn(); const blur = jest.fn(); const entry = createSuggestionEntry({ @@ -133,7 +132,7 @@ describe("SuggestionLifecycleController", () => { menu, handlers: { input, - keydown, + keydown: () => undefined, paste: () => undefined, focus, blur, @@ -153,21 +152,17 @@ describe("SuggestionLifecycleController", () => { controller.attachEntryListeners(entry); textarea.dispatchEvent(new Event("input", { bubbles: true })); - textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); textarea.dispatchEvent(new Event("focus", { bubbles: true })); textarea.dispatchEvent(new Event("blur", { bubbles: true })); expect(input).toHaveBeenCalledTimes(1); - expect(keydown).toHaveBeenCalledTimes(1); expect(focus).toHaveBeenCalledTimes(1); expect(blur).toHaveBeenCalledTimes(1); controller.detachEntryListeners(entry); textarea.dispatchEvent(new Event("input", { bubbles: true })); - textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); textarea.dispatchEvent(new Event("focus", { bubbles: true })); textarea.dispatchEvent(new Event("blur", { bubbles: true })); expect(input).toHaveBeenCalledTimes(1); - expect(keydown).toHaveBeenCalledTimes(1); expect(focus).toHaveBeenCalledTimes(1); expect(blur).toHaveBeenCalledTimes(1); }); diff --git a/tests/SuggestionManager.test.ts b/tests/SuggestionManager.test.ts index a54d06cc..fbad1324 100644 --- a/tests/SuggestionManager.test.ts +++ b/tests/SuggestionManager.test.ts @@ -5,6 +5,8 @@ import type { } from "../src/core/domain/messageTypes"; import { querySuggestionMenuItemByIndex, querySuggestionMenuItems } from "./suggestionTestUtils"; +const activeManagers: Array<{ detachAllHelpers: () => void }> = []; + async function waitForNextCall( mock: jest.Mock<(context: ContentScriptPredictRequestContext) => void>, { timeout = 2000 }: { timeout?: number } = {}, @@ -265,6 +267,7 @@ async function createManager(overrides: Partial = {}) { getPrediction, ...overrides, }); + activeManagers.push(manager); return { manager, getPrediction }; } @@ -312,6 +315,9 @@ describe("SuggestionManager", () => { }); afterEach(() => { + while (activeManagers.length > 0) { + activeManagers.pop()?.detachAllHelpers(); + } document.body.innerHTML = ""; }); diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 85973fac..31bb5ecc 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -529,6 +529,127 @@ describe("SuggestionManagerRuntime", () => { expect(handleKeyDown.mock.calls[0]?.[0]).toBe(keydown); }); + test("document-level Tab capture accepts suggestions when an ancestor swallows keydown before the entry listener", () => { + const runtime = makeRuntime(); + const wrapper = document.createElement("div"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + wrapper.appendChild(editable); + document.body.appendChild(wrapper); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + + const session = getAttachedSession(runtime, entry.id); + const acceptSuggestionAtIndex = jest.fn(() => true); + session.acceptSuggestionAtIndex = acceptSuggestionAtIndex; + + wrapper.addEventListener( + "keydown", + (event) => { + event.stopPropagation(); + }, + true, + ); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + editable.dispatchEvent(keydown); + + expect(acceptSuggestionAtIndex).toHaveBeenCalledTimes(1); + expect(acceptSuggestionAtIndex).toHaveBeenCalledWith(0); + expect(keydown.defaultPrevented).toBe(true); + }); + + test("early bridge accept delegates popup acceptance to the attached session", () => { + const runtime = makeRuntime(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(editable); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + + const session = getAttachedSession(runtime, entry.id); + const acceptSuggestionAtIndex = jest.fn(() => true); + session.acceptSuggestionAtIndex = acceptSuggestionAtIndex; + + expect( + ( + runtime as unknown as { + handleEarlyTabAcceptRequest: (entryId: string) => { accepted: boolean }; + } + ).handleEarlyTabAcceptRequest(String(entry.id)), + ).toEqual(expect.objectContaining({ accepted: true })); + expect(acceptSuggestionAtIndex).toHaveBeenCalledTimes(1); + expect(acceptSuggestionAtIndex).toHaveBeenCalledWith(0); + }); + + test("early bridge accept reports failure when session acceptance returns false", () => { + const runtime = makeRuntime(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(editable); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + + const session = getAttachedSession(runtime, entry.id); + session.acceptSuggestionAtIndex = jest.fn(() => false); + + expect( + ( + runtime as unknown as { + handleEarlyTabAcceptRequest: (entryId: string) => { accepted: boolean; reason: string }; + } + ).handleEarlyTabAcceptRequest(String(entry.id)), + ).toEqual( + expect.objectContaining({ + accepted: false, + reason: "accept_failed", + }), + ); + }); + test("selection reconciliation delegates to the attached session", () => { const runtime = makeRuntime("input"); const input = document.createElement("input"); diff --git a/tests/SuggestionTextEditService.test.ts b/tests/SuggestionTextEditService.test.ts index 277aa145..35ca3393 100644 --- a/tests/SuggestionTextEditService.test.ts +++ b/tests/SuggestionTextEditService.test.ts @@ -128,6 +128,44 @@ class HostCanceledNoMutationContentEditableAdapter extends ContentEditableAdapte } } +class DeferredHostThenDomFallbackContentEditableAdapter extends ContentEditableAdapter { + public override getBlockContext( + elem: HTMLElement, + ): { beforeCursor: string; afterCursor: string } | null { + const fullText = elem.textContent ?? ""; + return { + beforeCursor: fullText, + afterCursor: "", + }; + } + + public override replaceTextByOffsets( + elem: HTMLElement, + replaceStart: number, + replaceEnd: number, + replacementText: string, + cursorAfter: number, + options?: { preferDomMutation?: boolean; scopeRoot?: HTMLElement | null }, + ) { + if (options?.preferDomMutation === true) { + return super.replaceTextByOffsets( + elem, + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + options, + ); + } + + return { + appliedBy: "host-beforeinput" as const, + didMutateDom: false, + didDispatchInput: false, + }; + } +} + class EmptyBlockContextContentEditableAdapter extends ContentEditableAdapter { public override getBlockContext( elem: HTMLElement, @@ -1226,6 +1264,43 @@ describe("SuggestionTextEditService", () => { expect(editable.textContent).toBe("Wh"); }); + test("retries generic contenteditable acceptance with direct DOM mutation after a canceled no-op beforeinput", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + contentEditableAdapter: new DeferredHostThenDomFallbackContentEditableAdapter(), + }); + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + editable.innerHTML = "

Wh

"; + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(editable); + + const textNode = editable.querySelector("span")?.firstChild as Text | null; + if (!textNode) { + throw new Error("Expected text node"); + } + setTextNodeCursor(textNode, textNode.textContent?.length ?? 0); + + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "Wh", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "What "); + + expect(accepted).toEqual({ + triggerText: "Wh", + insertedText: "What\u00A0", + cursorAfter: 5, + cursorAfterIsBlockLocal: true, + }); + expect(editable.textContent).toBe("What\u00A0"); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho).toBe(true); + }); + test("does nothing when delayed post-accept spacing is not armed", () => { const service = new SuggestionTextEditService({ findMentionToken, diff --git a/tests/content_script.behavior.test.ts b/tests/content_script.behavior.test.ts index 646436a8..ed3dd294 100644 --- a/tests/content_script.behavior.test.ts +++ b/tests/content_script.behavior.test.ts @@ -1,16 +1,20 @@ import { jest, mock } from "bun:test"; import { + CMD_CONTENT_SCRIPT_GET_CONFIG, + CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, CMD_BACKGROUND_PAGE_PREDICT_RESP, CMD_BACKGROUND_PAGE_SET_CONFIG, CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, - CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, - CMD_CONTENT_SCRIPT_GET_CONFIG, CMD_POPUP_PAGE_DISABLE, CMD_POPUP_PAGE_ENABLE, CMD_STATUS_COMMAND, CMD_TOGGLE_FT_ACTIVE_TAB, CMD_TRIGGER_FT_ACTIVE_TAB, } from "../src/core/domain/constants"; +import { + EARLY_TAB_ACCEPT_MESSAGE_TYPE, + EARLY_TAB_ACCEPT_REQUEST_EVENT, +} from "../src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol"; type SuggestionLike = { queryAndAttachHelper: jest.Mock; @@ -19,6 +23,7 @@ type SuggestionLike = { updateLangConfig: jest.Mock; triggerActiveSuggestion: jest.Mock; fulfillPrediction: jest.Mock; + handleEarlyTabAcceptRequest: jest.Mock; autocompleteSeparator?: RegExp; }; @@ -114,6 +119,14 @@ jest.unstable_mockModule("../src/adapters/chrome/content-script/SuggestionManage updateLangConfig: jest.fn(), triggerActiveSuggestion: jest.fn(), fulfillPrediction: jest.fn(), + handleEarlyTabAcceptRequest: jest.fn(() => ({ + accepted: false, + reason: "entry_not_found", + entryId: "0", + suggestionCount: 0, + menuVisible: false, + hasInlineSuggestion: false, + })), }; behaviorHarness.suggestionInstances.push(instance); return instance; @@ -212,6 +225,61 @@ describe("content_script behavior", () => { ); }); + test("consumes early Tab bridge requests when suggestion acceptance succeeds", async () => { + const { fluentTyper, suggestionInstances } = await loadContentScript(); + + fluentTyper.enable(); + const suggestionManager = suggestionInstances[0]; + suggestionManager.handleEarlyTabAcceptRequest.mockReturnValue({ + accepted: true, + reason: "accepted_menu", + entryId: "17", + suggestionCount: 1, + menuVisible: true, + hasInlineSuggestion: false, + }); + + const event = new window.MessageEvent("message", { + source: window, + data: { + source: EARLY_TAB_ACCEPT_REQUEST_EVENT, + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId: "17", + }, + }); + window.dispatchEvent(event); + + expect(suggestionManager.handleEarlyTabAcceptRequest).toHaveBeenCalledWith("17"); + }); + + test("consumes early Tab bridge requests even when message source is not the page window", async () => { + const { fluentTyper, suggestionInstances } = await loadContentScript(); + + fluentTyper.enable(); + const suggestionManager = suggestionInstances[0]; + suggestionManager.handleEarlyTabAcceptRequest.mockReturnValue({ + accepted: true, + reason: "accepted_menu", + entryId: "23", + suggestionCount: 1, + menuVisible: true, + hasInlineSuggestion: false, + }); + + const channel = new MessageChannel(); + const event = new window.MessageEvent("message", { + source: channel.port1, + data: { + source: EARLY_TAB_ACCEPT_REQUEST_EVENT, + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId: "23", + }, + }); + window.dispatchEvent(event); + + expect(suggestionManager.handleEarlyTabAcceptRequest).toHaveBeenCalledWith("23"); + }); + test("handleGetPrediction sends request and matching response fulfills prediction", async () => { const { fluentTyper, suggestionInstances, sendMessage } = await loadContentScript();