diff --git a/docs/vnext/codemirror-adapter.md b/docs/vnext/codemirror-adapter.md index 29c522d..572d52a 100644 --- a/docs/vnext/codemirror-adapter.md +++ b/docs/vnext/codemirror-adapter.md @@ -47,6 +47,29 @@ The adapter installs one coherent `autocompletion` configuration. Additional sources belong in `autocomplete.externalSources`; consumers should not install a second independently configured `autocompletion` extension. +Rich completion details are opt-in through +`autocomplete.infoResolver`. The resolver receives the immutable core item and +an `AbortSignal`, and returns a DOM resource with an explicit `destroy` +callback. The adapter aborts pending work and destroys resolved resources when +the selected option, document input, context, regions, or view lifetime +changes. Resolver failures are contained and simply omit the detail panel. + +```ts +const support = sqlEditor({ + autocomplete: { + infoResolver: async (item, { signal }) => { + const metadata = await loadMetadata(item, { signal }); + const dom = document.createElement("div"); + const root = createRoot(dom); + root.render(renderMetadata(metadata)); + return { dom, destroy: () => root.unmount() }; + }, + }, + initialContext, + service, +}); +``` + ## Atomic inputs Context and document changes can share one CodeMirror transaction: diff --git a/src/vnext/codemirror/__tests__/sql-editor.test.ts b/src/vnext/codemirror/__tests__/sql-editor.test.ts index a119040..a9bfdf7 100644 --- a/src/vnext/codemirror/__tests__/sql-editor.test.ts +++ b/src/vnext/codemirror/__tests__/sql-editor.test.ts @@ -2,7 +2,10 @@ import { closeCompletion, completionStatus, currentCompletions, + setSelectedCompletion, startCompletion, + type Completion, + type CompletionInfo, } from "@codemirror/autocomplete"; import { EditorSelection, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -283,6 +286,15 @@ async function waitForActiveCompletion(view: EditorView): Promise { }); } +async function resolveCompletionInfo( + completion: Completion, +): Promise { + if (typeof completion.info !== "function") { + throw new Error("Expected a completion info resolver"); + } + return completion.info(completion); +} + describe("sqlEditor", () => { it("maps current service completions and applies the exact core edit", async () => { const service = createSqlLanguageService({ @@ -324,6 +336,349 @@ describe("sqlEditor", () => { service.dispose(); }); + it("owns rich completion info until CodeMirror destroys it", async () => { + const item = completionItem(); + const destroys: Array> = []; + const resolver = vi.fn((resolvedItem, { signal }) => { + const dom = document.createElement("div"); + const index = destroys.length; + const destroy = vi.fn(); + destroys.push(destroy); + dom.dataset.resolverIndex = String(index); + return { dom, destroy, signal }; + }); + const harness = fakeService((revision) => + readyResult(revision, [item]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + const info = await resolveCompletionInfo(completion); + + expect(resolver).toHaveBeenCalledWith( + item, + { signal: expect.any(AbortSignal) }, + ); + expect(info).toMatchObject({ dom: expect.any(Node) }); + if (info === null || info instanceof Node) { + throw new Error("Expected disposable completion info"); + } + const index = Number( + (info.dom as HTMLElement).dataset.resolverIndex, + ); + info.destroy?.(); + info.destroy?.(); + expect(destroys[index]).toHaveBeenCalledTimes(1); + }); + + it("rejects info requests reentered during host cleanup", async () => { + let reenter = () => undefined; + const resolver = vi.fn(() => ({ + dom: document.createElement("div"), + destroy: () => reenter(), + })); + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + await resolveCompletionInfo(completion); + const callsBeforeReplacement = resolver.mock.calls.length; + reenter = () => { + void resolveCompletionInfo(completion); + }; + + await resolveCompletionInfo(completion); + expect(resolver).toHaveBeenCalledTimes( + callsBeforeReplacement + 1, + ); + }); + + it("aborts superseded info and destroys its late resource", async () => { + let resolveFirst: + | ((value: { + readonly dom: Node; + readonly destroy: () => void; + }) => void) + | undefined; + const firstDestroy = vi.fn(); + const signals: AbortSignal[] = []; + const resolver = vi.fn((_item, { signal }) => { + signals.push(signal); + if (signals.length === 1) { + return new Promise<{ + readonly dom: Node; + readonly destroy: () => void; + }>((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve({ + dom: document.createElement("div"), + destroy: vi.fn(), + }); + }); + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + const first = resolveCompletionInfo(completion); + const second = resolveCompletionInfo(completion); + expect(signals[0]?.aborted).toBe(true); + + resolveFirst?.({ + dom: document.createElement("div"), + destroy: firstDestroy, + }); + expect(await first).toBeNull(); + expect(firstDestroy).toHaveBeenCalledTimes(1); + expect(await second).toMatchObject({ dom: expect.any(Node) }); + }); + + it("disposes info when selection moves to an external option", async () => { + let resolveInfo: + | ((value: { + readonly dom: Node; + readonly destroy: () => void; + }) => void) + | undefined; + const destroy = vi.fn(); + let signal: AbortSignal | undefined; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 14, + options: [{ label: "users_external" }], + })], + infoResolver: (_item, context) => { + signal = context.signal; + return new Promise((resolve) => { + resolveInfo = resolve; + }); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + await vi.waitFor(() => { + expect( + currentCompletions(view.state).map((item) => item.label), + ).toContain("users_external"); + }); + const completions = currentCompletions(view.state); + const coreIndex = completions.findIndex( + (completion) => completion.label === "users", + ); + const externalIndex = completions.findIndex( + (completion) => completion.label === "users_external", + ); + const core = completions[coreIndex]; + if (!core || coreIndex < 0 || externalIndex < 0) { + throw new Error("Expected core and external completions"); + } + view.dispatch({ effects: setSelectedCompletion(coreIndex) }); + const pending = resolveCompletionInfo(core); + view.dispatch({ effects: setSelectedCompletion(externalIndex) }); + + expect(signal?.aborted).toBe(true); + resolveInfo?.({ dom: document.createElement("div"), destroy }); + await expect(pending).resolves.toBeNull(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("destroys resolved info when selection moves away", async () => { + const destroys: Array> = []; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 14, + options: [{ label: "users_external" }], + })], + infoResolver: () => { + const dom = document.createElement("div"); + const destroy = vi.fn(); + const index = destroys.push(destroy) - 1; + dom.dataset.resolverIndex = String(index); + return { dom, destroy }; + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + await vi.waitFor(() => { + expect( + currentCompletions(view.state).map((item) => item.label), + ).toContain("users_external"); + }); + const completions = currentCompletions(view.state); + const coreIndex = completions.findIndex( + (completion) => completion.label === "users", + ); + const externalIndex = completions.findIndex( + (completion) => completion.label === "users_external", + ); + const core = completions[coreIndex]; + if (!core || coreIndex < 0 || externalIndex < 0) { + throw new Error("Expected core and external completions"); + } + view.dispatch({ effects: setSelectedCompletion(coreIndex) }); + const info = await resolveCompletionInfo(core); + if (info === null || info instanceof Node) { + throw new Error("Expected disposable completion info"); + } + const index = Number( + (info.dom as HTMLElement).dataset.resolverIndex, + ); + view.dispatch({ effects: setSelectedCompletion(externalIndex) }); + + expect(destroys[index]).toHaveBeenCalledTimes(1); + }); + + it("aborts pending info when editor input changes", async () => { + let resolveInfo: + | ((value: { + readonly dom: Node; + readonly destroy: () => void; + }) => void) + | undefined; + const destroy = vi.fn(); + let signal: AbortSignal | undefined; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + infoResolver: (_item, context) => { + signal = context.signal; + return new Promise((resolve) => { + resolveInfo = resolve; + }); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + const pending = resolveCompletionInfo(completion); + view.dispatch({ selection: { anchor: 0 } }); + expect(signal?.aborted).toBe(true); + + resolveInfo?.({ dom: document.createElement("div"), destroy }); + expect(await pending).toBeNull(); + expect(destroy).toHaveBeenCalledTimes(1); + await expect(resolveCompletionInfo(completion)).resolves.toBeNull(); + }); + + it("omits null completion info", async () => { + const resolver = vi.fn(() => null); + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + await expect(resolveCompletionInfo(completion)).resolves.toBeNull(); + }); + + it("contains info resolver and cleanup failures", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + infoResolver: () => { + throw new Error("resolver failed"); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + await expect(resolveCompletionInfo(completion)).resolves.toBeNull(); + + const cleanupSupport = sqlEditor({ + autocomplete: { + infoResolver: () => ({ + dom: document.createElement("div"), + destroy: () => { + throw new Error("cleanup failed"); + }, + }), + }, + initialContext: context(), + service: harness.service, + }); + const cleanupView = createView(cleanupSupport.extension); + startCompletion(cleanupView); + await waitForActiveCompletion(cleanupView); + const cleanupCompletion = currentCompletions(cleanupView.state)[0]; + if (!cleanupCompletion) throw new Error("Expected a completion"); + const info = await resolveCompletionInfo(cleanupCompletion); + if (info === null || info instanceof Node) { + throw new Error("Expected disposable completion info"); + } + expect(() => info.destroy?.()).not.toThrow(); + }); + it("maps embedded regions through its own non-overlapping apply edit", async () => { const harness = fakeService((revision) => readyResult(revision, [completionItem(16, 18)]) diff --git a/src/vnext/codemirror/index.ts b/src/vnext/codemirror/index.ts index 24a6e55..e5e1d6f 100644 --- a/src/vnext/codemirror/index.ts +++ b/src/vnext/codemirror/index.ts @@ -4,3 +4,8 @@ export { type SqlEditorOptions, type SqlEditorSupport, } from "./sql-editor.js"; +export type { + SqlCompletionInfoResolver, + SqlCompletionInfoResolverContext, + SqlDisposableCompletionInfo, +} from "./relation-completion-types.js"; diff --git a/src/vnext/codemirror/sql-editor.ts b/src/vnext/codemirror/sql-editor.ts index 722d396..652b143 100644 --- a/src/vnext/codemirror/sql-editor.ts +++ b/src/vnext/codemirror/sql-editor.ts @@ -3,6 +3,8 @@ import { closeCompletion, completionStatus, pickedCompletion, + selectedCompletion, + selectedCompletionIndex, startCompletion, type Completion, type CompletionContext, @@ -24,6 +26,10 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; +import type { + SqlCompletionInfoResolver, + SqlDisposableCompletionInfo, +} from "./relation-completion-types.js"; import type { SqlCompletionItem, SqlCompletionRefreshToken, @@ -46,6 +52,7 @@ export interface SqlEditorAutocompleteOptions { readonly closeOnBlur?: boolean; readonly defaultKeymap?: boolean; readonly externalSources?: readonly CompletionSource[]; + readonly infoResolver?: SqlCompletionInfoResolver; readonly maxRenderedOptions?: number; readonly selectOnOpen?: boolean; readonly updateSyncTime?: number; @@ -112,6 +119,12 @@ interface CompletionIntent { readonly token: SqlCompletionRefreshToken; } +interface ActiveCompletionInfo { + readonly controller: AbortController; + disposed: boolean; + resource: SqlDisposableCompletionInfo | null; +} + const defaultRuntime: SqlEditorRuntime = Object.freeze({ clearTimeout: (handle: ReturnType) => clearTimeout(handle), @@ -222,6 +235,12 @@ export function createSqlEditorInternal< return next; }, }); + const autocomplete = options.autocomplete ?? {}; + const { + externalSources = [], + infoResolver, + ...autocompleteOptions + } = autocomplete; let plugin: ViewPlugin; let completionSource: CompletionSource; @@ -232,10 +251,14 @@ export function createSqlEditorInternal< #active: ActiveCompletion | null = null; #contextGeneration = 0; #destroyed = false; + #disposingInfo = false; #hasEmbeddedRegions: boolean; + #info: ActiveCompletionInfo | null = null; #intent: CompletionIntent | null = null; #intentTimer: ReturnType | null = null; #lastCompletionStatus: ReturnType; + #lastSelectedCompletion: Completion | null; + #lastSelectedCompletionIndex: number | null; #refreshScheduled = false; #sequence = 0; readonly #subscription; @@ -251,6 +274,10 @@ export function createSqlEditorInternal< }); this.#hasEmbeddedRegions = initialRegions.length > 0; this.#lastCompletionStatus = completionStatus(view.state); + this.#lastSelectedCompletion = selectedCompletion(view.state); + this.#lastSelectedCompletionIndex = selectedCompletionIndex( + view.state, + ); this.#subscription = this.#session.onDidChange((event) => { if (event.refreshToken === null) { this.#clearCompletionState(); @@ -279,6 +306,32 @@ export function createSqlEditorInternal< this.#active = null; } + #disposeInfo(info: ActiveCompletionInfo): void { + if (info.disposed) return; + info.disposed = true; + if (this.#info === info) this.#info = null; + const resource = info.resource; + info.resource = null; + const wasDisposingInfo = this.#disposingInfo; + this.#disposingInfo = true; + try { + try { + info.controller.abort(); + } catch { + // Continue to resource cleanup. + } + resource?.destroy(); + } catch { + // Host cleanup must not escape into CodeMirror lifecycle hooks. + } finally { + this.#disposingInfo = wasDisposingInfo; + } + } + + #clearInfo(): void { + if (this.#info !== null) this.#disposeInfo(this.#info); + } + #clearIntent(): void { if (this.#intentTimer !== null) { runtime.clearTimeout(this.#intentTimer); @@ -290,6 +343,7 @@ export function createSqlEditorInternal< #clearCompletionState(): void { this.#sequence += 1; this.#abortActive(); + this.#clearInfo(); this.#clearIntent(); this.#refreshScheduled = false; } @@ -402,6 +456,59 @@ export function createSqlEditorInternal< label: item.label, type: completionType(item), }; + if (infoResolver !== undefined) { + completion.info = async () => { + if (this.#disposingInfo) return null; + this.#clearInfo(); + if ( + !this.#captureIsCurrent(capture) || + !this.#session.isCurrent(revision) + ) { + return null; + } + const info: ActiveCompletionInfo = { + controller: new AbortController(), + disposed: false, + resource: null, + }; + this.#info = info; + let resource: SqlDisposableCompletionInfo | null; + try { + resource = await infoResolver(item, { + signal: info.controller.signal, + }); + } catch { + this.#disposeInfo(info); + return null; + } + if ( + info.disposed || + this.#info !== info || + !this.#captureIsCurrent(capture) || + !this.#session.isCurrent(revision) + ) { + const wasDisposingInfo = this.#disposingInfo; + this.#disposingInfo = true; + try { + resource?.destroy(); + } catch { + // Host cleanup must not escape from a stale resolver. + } finally { + this.#disposingInfo = wasDisposingInfo; + } + return null; + } + if (resource === null) { + this.#disposeInfo(info); + return null; + } + info.resource = resource; + return { + dom: resource.dom, + destroy: () => this.#disposeInfo(info), + }; + }; + } return item.detail === undefined ? completion : { ...completion, detail: item.detail }; @@ -584,6 +691,19 @@ export function createSqlEditorInternal< this.#clearCompletionState(); } const nextCompletionStatus = completionStatus(update.state); + const nextSelectedCompletion = selectedCompletion(update.state); + const nextSelectedCompletionIndex = selectedCompletionIndex( + update.state, + ); + if ( + nextSelectedCompletion !== this.#lastSelectedCompletion || + nextSelectedCompletionIndex !== + this.#lastSelectedCompletionIndex + ) { + this.#clearInfo(); + } + this.#lastSelectedCompletion = nextSelectedCompletion; + this.#lastSelectedCompletionIndex = nextSelectedCompletionIndex; if ( nextCompletionStatus === null && (this.#lastCompletionStatus === "active" || @@ -615,11 +735,6 @@ export function createSqlEditorInternal< const instance = context.view?.plugin(plugin); return instance?.complete(context) ?? null; }; - const autocomplete = options.autocomplete ?? {}; - const { - externalSources = [], - ...autocompleteOptions - } = autocomplete; const escapeKeymap = Prec.high( keymap.of([{ key: "Escape", diff --git a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts index edc16c8..33938c2 100644 --- a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts +++ b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts @@ -3,7 +3,7 @@ import type { EditorView } from "@codemirror/view"; import type { SqlCompletionInfoResolver, SqlCompletionInfoResolverContext, -} from "../../src/vnext/codemirror/relation-completion-types.js"; +} from "../../src/vnext/codemirror/index.js"; import type { SqlCompletionItem } from "../../src/vnext/relation-completion-types.js"; interface ReactRootLike {