diff --git a/README.md b/README.md index 932617d..f27a022 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ The initial playground includes: - Syntax highlighting from the shared Stack TextMate grammar - Distinct, accessible syntax palettes for light and dark color modes - Compiler-style diagnostics with source selection, code frames, expected values, help, and related locations +- Contextual completion, semantic hover, inline diagnostic ranges, and debounced live preview - Safe SVG image preview, expanded dialog, and download - User-selected provider icon stores with searchable AWS, Google Cloud, Azure, and tool artwork @@ -75,6 +76,8 @@ Authentication, persistence, collaboration, and paid themes remain outside this The editor keeps a native textarea as the input surface and layers Shiki presentation behind it. Highlighting consumes the raw `@stack-sh/language/grammar` export and does not determine whether source is valid. The Web-owned Shiki themes map grammar scopes to accessible light and dark colors. Validation and diagnostic guidance continue to come only from `@stack-sh/engine`. +Language intelligence runs locally through `@stack-sh/engine` 0.7.0 WebAssembly; it does not start an LSP server or send source to a server. Suggestions follow typing or `Ctrl Space`; use arrow keys to choose, `Enter` or `Tab` to accept, and `Escape` to dismiss. The Context panel follows the caret or pointer. Imported provider packs supply their icon catalog to the same engine APIs. The adapter converts browser UTF-16 selections to engine source positions, rejects stale document results, and suspends completion during IME composition. Diagnostics and preview refresh from the same source snapshot after editing, while explicit actions remain available. + ## License Repository-authored work is licensed under Apache-2.0. Bundled font files and their licenses are documented in [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md). diff --git a/docs/ja/language/themes-and-icons.md b/docs/ja/language/themes-and-icons.md index daffb9f..ace5bab 100644 --- a/docs/ja/language/themes-and-icons.md +++ b/docs/ja/language/themes-and-icons.md @@ -107,7 +107,7 @@ Free core catalogは、次のprovider-neutralな明示iconを`default`、`light` | `email` | Email delivery | Email delivery / inbox | | `ai` | Artificial intelligence system | AI model / inference service | -下のpreviewをlight / darkへ切り替えると、実際のoutputを比較できます。各imageは公開`@stack-sh/engine@0.6.0`がlocalでrenderし、DocumentationにTheme SVG assetを複製していません。Syntaxの行を選ぶとコピーできます。 +下のpreviewをlight / darkへ切り替えると、実際のoutputを比較できます。各imageは公開`@stack-sh/engine@0.7.0`がlocalでrenderし、DocumentationにTheme SVG assetを複製していません。Syntaxの行を選ぶとコピーできます。 diff --git a/docs/ko/language/themes-and-icons.md b/docs/ko/language/themes-and-icons.md index 30958c5..7ec9657 100644 --- a/docs/ko/language/themes-and-icons.md +++ b/docs/ko/language/themes-and-icons.md @@ -107,7 +107,7 @@ diagram "Explicit icon" { | `email` | Email delivery | 이메일 전송 또는 받은편지함 | | `ai` | Artificial intelligence system | AI 모델 또는 추론 서비스 | -아래 미리보기를 라이트와 다크로 전환하여 실제 출력을 비교할 수 있습니다. 각 이미지는 공개된 `@stack-sh/engine@0.6.0`이 로컬에서 렌더링하며 문서에 Theme SVG 에셋을 복제하지 않습니다. 문법 행을 선택하면 복사됩니다. +아래 미리보기를 라이트와 다크로 전환하여 실제 출력을 비교할 수 있습니다. 각 이미지는 공개된 `@stack-sh/engine@0.7.0`이 로컬에서 렌더링하며 문서에 Theme SVG 에셋을 복제하지 않습니다. 문법 행을 선택하면 복사됩니다. diff --git a/docs/language/themes-and-icons.md b/docs/language/themes-and-icons.md index 89ad46f..c4bb400 100644 --- a/docs/language/themes-and-icons.md +++ b/docs/language/themes-and-icons.md @@ -107,7 +107,7 @@ The free core catalog includes these provider-neutral explicit icons in `default | `email` | Email delivery | Email delivery or inboxes | | `ai` | Artificial intelligence system | AI models or inference services | -Switch the preview below between light and dark to inspect the actual output. The published `@stack-sh/engine@0.6.0` renders every image locally; the Documentation does not copy Theme SVG assets. Select a syntax row to copy it. +Switch the preview below between light and dark to inspect the actual output. The published `@stack-sh/engine@0.7.0` renders every image locally; the Documentation does not copy Theme SVG assets. Select a syntax row to copy it. diff --git a/docs/public/examples/application-and-data.svg b/docs/public/examples/application-and-data.svg index 2700645..49ea47a 100644 --- a/docs/public/examples/application-and-data.svg +++ b/docs/public/examples/application-and-data.svg @@ -1,8 +1,8 @@ - + Application and datastore Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/aws-serverless-checkout.svg b/docs/public/examples/aws-serverless-checkout.svg index c736fce..9c5a8f6 100644 --- a/docs/public/examples/aws-serverless-checkout.svg +++ b/docs/public/examples/aws-serverless-checkout.svg @@ -1,8 +1,8 @@ - + AWS serverless checkout Architecture diagram with 8 nodes, 3 groups, and 7 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/azure-event-platform.svg b/docs/public/examples/azure-event-platform.svg index e65dd92..479b942 100644 --- a/docs/public/examples/azure-event-platform.svg +++ b/docs/public/examples/azure-event-platform.svg @@ -1,8 +1,8 @@ - + Azure event platform Architecture diagram with 8 nodes, 3 groups, and 7 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/commerce-platform.svg b/docs/public/examples/commerce-platform.svg index a26b025..bbce95c 100644 --- a/docs/public/examples/commerce-platform.svg +++ b/docs/public/examples/commerce-platform.svg @@ -1,8 +1,8 @@ - + Commerce platform Architecture diagram with 13 nodes, 5 groups, and 12 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/gcp-data-service.svg b/docs/public/examples/gcp-data-service.svg index edbc5fc..60396b1 100644 --- a/docs/public/examples/gcp-data-service.svg +++ b/docs/public/examples/gcp-data-service.svg @@ -1,8 +1,8 @@ - + GCP data service Architecture diagram with 6 nodes, 2 groups, and 5 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/github-delivery-workflow.svg b/docs/public/examples/github-delivery-workflow.svg index 5e33d21..a42b011 100644 --- a/docs/public/examples/github-delivery-workflow.svg +++ b/docs/public/examples/github-delivery-workflow.svg @@ -1,8 +1,8 @@ - + GitHub delivery workflow Architecture diagram with 5 nodes, 2 groups, and 4 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/groups-and-layout.svg b/docs/public/examples/groups-and-layout.svg index 69f4588..d19d7f7 100644 --- a/docs/public/examples/groups-and-layout.svg +++ b/docs/public/examples/groups-and-layout.svg @@ -1,8 +1,8 @@ - + Public application Architecture diagram with 5 nodes, 2 groups, and 4 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/hello-stack.svg b/docs/public/examples/hello-stack.svg index 9fd7b83..36d97c7 100644 --- a/docs/public/examples/hello-stack.svg +++ b/docs/public/examples/hello-stack.svg @@ -1,8 +1,8 @@ - + Hello Stack Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/public/examples/mixed-provider-platform.svg b/docs/public/examples/mixed-provider-platform.svg index 83c765a..79ec4b8 100644 --- a/docs/public/examples/mixed-provider-platform.svg +++ b/docs/public/examples/mixed-provider-platform.svg @@ -1,8 +1,8 @@ - + Mixed-provider platform Architecture diagram with 7 nodes, 4 groups, and 6 relationships. - stack-engine 0.6.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 + stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 diff --git a/docs/zh/language/themes-and-icons.md b/docs/zh/language/themes-and-icons.md index 1902d1e..6d8fda0 100644 --- a/docs/zh/language/themes-and-icons.md +++ b/docs/zh/language/themes-and-icons.md @@ -107,7 +107,7 @@ diagram "Explicit icon" { | `email` | Email delivery | 邮件投递或收件箱 | | `ai` | Artificial intelligence system | AI 模型或推理服务 | -切换下方预览的浅色与深色模式即可比较真实输出。每张图片都由已发布的 `@stack-sh/engine@0.6.0` 在本地渲染;文档没有复制 Theme SVG 资源。选择语法行即可复制。 +切换下方预览的浅色与深色模式即可比较真实输出。每张图片都由已发布的 `@stack-sh/engine@0.7.0` 在本地渲染;文档没有复制 Theme SVG 资源。选择语法行即可复制。 diff --git a/package-lock.json b/package-lock.json index e35067d..b34538b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", - "@stack-sh/engine": "0.6.0", + "@stack-sh/engine": "0.7.0", "@stack-sh/language": "0.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -4869,9 +4869,9 @@ "license": "CC0-1.0" }, "node_modules/@stack-sh/engine": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@stack-sh/engine/-/engine-0.6.0.tgz", - "integrity": "sha512-u5N70XLHfA6giOOcCiDqy3m/Fagl5J2q/Ak2DIS49yi4m2aHvvqLDG8aHQNisCIaXOYZmD4Bj3dAylwUFrj/8Q==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@stack-sh/engine/-/engine-0.7.0.tgz", + "integrity": "sha512-Y6HCbiNHfdyqD1TEsDy3kNNtiswy7DPCHInYHUdrExjAW/GeeCLigYjHqLtUlXVmE/ppRpKxThZOOhy2sUQL5g==", "license": "Apache-2.0" }, "node_modules/@stack-sh/language": { @@ -5371,6 +5371,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", diff --git a/package.json b/package.json index 649724a..6dd9f80 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", - "@stack-sh/engine": "0.6.0", + "@stack-sh/engine": "0.7.0", "@stack-sh/language": "0.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs index 612bf2b..b3c6d89 100644 --- a/scripts/validate-docs.mjs +++ b/scripts/validate-docs.mjs @@ -20,8 +20,8 @@ const providerCatalog = JSON.parse( await readFile(path.join(docsRoot, ".vitepress/theme/data/provider-catalogs.json"), "utf8"), ) -if (packageMetadata.dependencies["@stack-sh/engine"] !== "0.6.0") { - throw new Error("Documentation must use the exact @stack-sh/engine 0.6.0 release") +if (packageMetadata.dependencies["@stack-sh/engine"] !== "0.7.0") { + throw new Error("Documentation must use the exact @stack-sh/engine 0.7.0 release") } const expectedProviderCounts = { aws: 305, gcp: 45, azure: 639, "simple-icons": 62 } diff --git a/scripts/validate-example-corpus.mjs b/scripts/validate-example-corpus.mjs index 2939f15..bc3a852 100644 --- a/scripts/validate-example-corpus.mjs +++ b/scripts/validate-example-corpus.mjs @@ -64,7 +64,7 @@ for (const example of catalog.examples) { providerIcons.length, `${example.id} ${operation} provider fallback count does not match its source`, ) - assert.equal(result.metadata.engineVersion, "0.6.0") + assert.equal(result.metadata.engineVersion, "0.7.0") assert.deepEqual(result.metadata.languageVersion, { major: 1, minor: 0 }) } @@ -96,5 +96,5 @@ assert.deepEqual( "Example thumbnail inventory has drifted", ) console.log( - `${writeThumbnails ? "Generated" : "Validated"} ${catalog.examples.length} checked, rendered, and provider-resolved example thumbnails with @stack-sh/engine 0.6.0.`, + `${writeThumbnails ? "Generated" : "Validated"} ${catalog.examples.length} checked, rendered, and provider-resolved example thumbnails with @stack-sh/engine 0.7.0.`, ) diff --git a/src/App.test.tsx b/src/App.test.tsx index 083004d..885b159 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -5,7 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const engine = vi.hoisted(() => ({ check: vi.fn(), checkWithProviderPacks: vi.fn(), + completion: vi.fn(), + completionWithProviderPacks: vi.fn(), format: vi.fn(), + hover: vi.fn(), initialize: vi.fn(), render: vi.fn(), renderWithProviderPacks: vi.fn(), @@ -14,8 +17,11 @@ const engine = vi.hoisted(() => ({ vi.mock("@stack-sh/engine", () => ({ check: engine.check, checkWithProviderPacks: engine.checkWithProviderPacks, + completion: engine.completion, + completionWithProviderPacks: engine.completionWithProviderPacks, default: engine.initialize, format: engine.format, + hover: engine.hover, render: engine.render, renderWithProviderPacks: engine.renderWithProviderPacks, })) @@ -28,8 +34,18 @@ function fileAt(path: string, contents: string, type?: string): File { return file } +function sourcePosition(source: string, utf16Index: number) { + const prefix = source.slice(0, utf16Index) + const lines = prefix.split("\n") + return { + byteOffset: new TextEncoder().encode(prefix).length, + column: [...(lines.at(-1) ?? "")].length + 1, + line: lines.length, + } +} + const metadata = { - engineVersion: "0.6.0", + engineVersion: "0.7.0", languageVersion: { major: 1, minor: 0 }, themeCatalogRevision: "sha256:test", themeCatalogVersion: "0.4.0", @@ -56,7 +72,27 @@ describe("Stack Playground", () => { engine.initialize.mockResolvedValue({}) engine.check.mockReturnValue({ diagnostics: [], metadata }) engine.checkWithProviderPacks.mockReturnValue({ diagnostics: [], metadata }) + engine.completion.mockImplementation((_source, documentVersion) => ({ + diagnostics: [], + documentVersion, + isIncomplete: false, + items: [], + schemaVersion: "1.0", + })) + engine.completionWithProviderPacks.mockImplementation((_source, documentVersion) => ({ + diagnostics: [], + documentVersion, + isIncomplete: false, + items: [], + schemaVersion: "1.0", + })) engine.format.mockReturnValue({ diagnostics: [], formattedSource: "stack 1.0\n", metadata }) + engine.hover.mockImplementation((_source, documentVersion) => ({ + diagnostics: [], + documentVersion, + hover: null, + schemaVersion: "1.0", + })) engine.render.mockReturnValue({ diagnostics: [], metadata, @@ -127,6 +163,12 @@ describe("Stack Playground", () => { engine.check.mockReturnValueOnce({ diagnostics: [diagnostic], metadata }) render() await screen.findByAltText("Rendered Stack architecture diagram") + engine.render.mockReturnValueOnce({ + diagnostics: [diagnostic], + metadata, + providerNotices: [], + svg: '', + }) await user.click(screen.getByRole("button", { name: "Check" })) @@ -139,33 +181,35 @@ describe("Stack Playground", () => { const user = userEvent.setup() const source = 'stack 1.0\ndiagram "Example" {\n node app "App"\n layout { direction hoo }\n}\n' - engine.check.mockReturnValueOnce({ - diagnostics: [ + const detailedDiagnostic = { + code: "STK2002", + expected: ["right", "down"], + help: "Use 'right' for horizontal flow or 'down' for vertical flow.", + message: "Unknown layout direction 'hoo'.", + range: { + end: { byteOffset: 71, column: 25, line: 4 }, + start: { byteOffset: 68, column: 22, line: 4 }, + }, + related: [ { - code: "STK2002", - expected: ["right", "down"], - help: "Use 'right' for horizontal flow or 'down' for vertical flow.", - message: "Unknown layout direction 'hoo'.", + message: "The diagram starts here.", range: { - end: { byteOffset: 71, column: 25, line: 4 }, - start: { byteOffset: 68, column: 22, line: 4 }, + end: { byteOffset: 17, column: 8, line: 2 }, + start: { byteOffset: 10, column: 1, line: 2 }, }, - related: [ - { - message: "The diagram starts here.", - range: { - end: { byteOffset: 17, column: 8, line: 2 }, - start: { byteOffset: 10, column: 1, line: 2 }, - }, - }, - ], - severity: "error", }, ], - metadata, - }) + severity: "error" as const, + } + engine.check.mockReturnValueOnce({ diagnostics: [detailedDiagnostic], metadata }) render() await screen.findByAltText("Rendered Stack architecture diagram") + engine.render.mockReturnValueOnce({ + diagnostics: [detailedDiagnostic], + metadata, + providerNotices: [], + svg: '', + }) const editor = screen.getByRole("textbox", { name: "Stack source" }) fireEvent.change(editor, { target: { value: source } }) @@ -187,7 +231,176 @@ describe("Stack Playground", () => { fireEvent.change(editor, { target: { value: source.replace("hoo", "right") } }) expect(screen.queryByText("[STK2002]")).not.toBeInTheDocument() - expect(screen.getByText("Source changed")).toBeInTheDocument() + expect(screen.getAllByText("Analyzing source…").length).toBeGreaterThan(0) + }) + + it("offers contextual completion and applies the active edit from the keyboard", async () => { + const user = userEvent.setup() + const source = 'stack 1.0\ndiagram "Draft" {\n no' + const editStart = source.length - 2 + const start = sourcePosition(source, editStart) + const end = sourcePosition(source, source.length) + engine.completion.mockImplementation((_source, documentVersion) => ({ + diagnostics: [], + documentVersion, + isIncomplete: true, + items: [ + { + detail: "Declare a node", + documentation: null, + edit: { newText: "node", range: { end, start } }, + filterText: "node", + kind: "keyword", + label: "node", + sortText: "node", + }, + { + detail: "Declare a group", + documentation: "Groups contain related nodes.", + edit: { newText: "group", range: { end, start } }, + filterText: "group", + kind: "keyword", + label: "group", + sortText: "group", + }, + ], + schemaVersion: "1.0", + })) + render() + await screen.findByAltText("Rendered Stack architecture diagram") + + const editor = screen.getByRole("textbox", { name: "Stack source" }) + await user.click(editor) + fireEvent.change(editor, { target: { value: source } }) + + const options = await screen.findAllByRole("option") + expect(options).toHaveLength(2) + expect(options[0]).toHaveTextContent("node") + expect(options[1]).toHaveTextContent("group") + expect(editor).toHaveAttribute("aria-controls", screen.getByRole("listbox").id) + expect(engine.completion).toHaveBeenLastCalledWith( + source, + 1, + sourcePosition(source, source.length), + ) + + editor.setSelectionRange(0, 0) + fireEvent.select(editor) + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + editor.setSelectionRange(source.length, source.length) + fireEvent.select(editor) + fireEvent.keyDown(editor, { key: " ", ctrlKey: true }) + fireEvent.compositionStart(editor) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + fireEvent.compositionEnd(editor) + await screen.findByRole("listbox") + fireEvent.keyDown(editor, { key: "Enter", isComposing: true }) + expect(editor).toHaveValue(source) + expect(screen.getByRole("listbox")).toBeInTheDocument() + // Safari's confirming Enter may arrive after compositionend with isComposing false. + fireEvent.keyDown(editor, { key: "Enter", keyCode: 229, isComposing: false }) + expect(editor).toHaveValue(source) + expect(screen.getByRole("listbox")).toBeInTheDocument() + await user.keyboard("{ArrowDown}{Enter}") + + expect(editor).toHaveValue(source.slice(0, editStart) + "group") + expect(editor.selectionStart).toBe(source.slice(0, editStart).length + "group".length) + expect(screen.queryByRole("listbox", { name: "Stack suggestions" })).not.toBeInTheDocument() + }) + + it("discards completion results for an older document version", async () => { + const user = userEvent.setup() + engine.completion.mockImplementation((_source, documentVersion) => ({ + diagnostics: [], + documentVersion: documentVersion - 1, + isIncomplete: false, + items: [ + { + detail: null, + documentation: null, + edit: { + newText: "node", + range: { + end: { byteOffset: 2, column: 3, line: 1 }, + start: { byteOffset: 0, column: 1, line: 1 }, + }, + }, + filterText: "node", + kind: "keyword", + label: "node", + sortText: "node", + }, + ], + schemaVersion: "1.0", + })) + render() + await screen.findByAltText("Rendered Stack architecture diagram") + + const editor = screen.getByRole("textbox", { name: "Stack source" }) + await user.click(editor) + fireEvent.change(editor, { target: { value: "no" } }) + + await waitFor(() => expect(engine.completion).toHaveBeenCalled()) + expect(screen.queryByRole("listbox", { name: "Stack suggestions" })).not.toBeInTheDocument() + }) + + it("shows semantic hover for a Unicode caret using the current snapshot", async () => { + const user = userEvent.setup() + const source = 'stack 1.0\ndiagram "顧客 API" {\n node user "顧客"\n}\n' + const caret = source.indexOf("顧客", source.indexOf("node")) + 1 + const rangeStart = sourcePosition(source, source.indexOf("user")) + const rangeEnd = sourcePosition(source, source.indexOf("user") + 4) + engine.hover.mockImplementation((_source, documentVersion) => ({ + diagnostics: [], + documentVersion, + hover: { + detail: "node user · service", + documentation: null, + kind: "node", + label: "顧客", + range: { end: rangeEnd, start: rangeStart }, + }, + schemaVersion: "1.0", + })) + render() + await screen.findByAltText("Rendered Stack architecture diagram") + + const editor = screen.getByRole("textbox", { name: "Stack source" }) + await user.click(editor) + fireEvent.change(editor, { target: { value: source } }) + editor.setSelectionRange(caret, caret) + fireEvent.select(editor) + + expect(await screen.findByText("顧客")).toBeInTheDocument() + expect(screen.getByText(/node user · service/)).toBeInTheDocument() + expect(engine.hover).toHaveBeenLastCalledWith(source, 1, sourcePosition(source, caret)) + }) + + it("updates inline diagnostics and preview from the same debounced render", async () => { + const user = userEvent.setup() + const source = "stack 1.0 diagram" + render() + await screen.findByAltText("Rendered Stack architecture diagram") + engine.render.mockReturnValueOnce({ + diagnostics: [diagnostic], + metadata, + providerNotices: [], + svg: '', + }) + + const editor = screen.getByRole("textbox", { name: "Stack source" }) + await user.click(editor) + fireEvent.change(editor, { target: { value: source } }) + + expect(screen.queryByAltText("Rendered Stack architecture diagram")).not.toBeInTheDocument() + expect(screen.getAllByText("Analyzing source…").length).toBeGreaterThan(0) + + expect(await screen.findByText("The diagram is not closed.")).toBeInTheDocument() + expect(screen.getByAltText("Rendered Stack architecture diagram")).toBeInTheDocument() + expect(screen.getByText("Render found errors")).toBeInTheDocument() + expect(document.querySelector('[data-diagnostic-severity="error"]')).toBeInTheDocument() + expect(engine.render).toHaveBeenLastCalledWith(source) }) it("switches color mode and remembers the choice", async () => { @@ -296,6 +509,12 @@ describe("Stack Playground", () => { expect(engine.renderWithProviderPacks.mock.calls[0][1]).toHaveLength(2) await user.click(screen.getByRole("button", { name: "Close" })) expect(screen.getByRole("button", { name: "Notice" })).toBeInTheDocument() + + const editor = screen.getByRole("textbox", { name: "Stack source" }) + await user.click(editor) + fireEvent.keyDown(editor, { ctrlKey: true, key: " " }) + await waitFor(() => expect(engine.completionWithProviderPacks).toHaveBeenCalledOnce()) + expect(engine.completionWithProviderPacks.mock.calls[0][3]).toHaveLength(2) }) it("shows an icon store error before sending provider files to the engine", async () => { diff --git a/src/App.tsx b/src/App.tsx index 43b41f2..0c60510 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,12 @@ -import { lazy, Suspense, useCallback, useEffect, useState } from "react" -import type { Diagnostic, EngineMetadata, ProviderNotice } from "@stack-sh/engine" +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react" +import type { + CompletionResult, + Diagnostic, + EngineMetadata, + HoverResult, + ProviderNotice, + SourcePosition, +} from "@stack-sh/engine" import { ColorModeToggle } from "@/components/color-mode-toggle" import { EditorPane } from "@/components/editor-pane" @@ -10,13 +17,16 @@ import { EXAMPLE_SOURCE } from "@/lib/example" import type { LoadedProviderPack } from "@/lib/provider-pack" import { checkStack, + completeStack, formatStack, + hoverStack, initializeStackEngine, renderStack, validateProviderPack, } from "@/lib/stack-engine" const ProviderIcons = lazy(() => import("@/components/provider-icons")) +const INPUT_RENDER_DELAY_MS = 180 function hasErrors(diagnostics: readonly Diagnostic[]) { return diagnostics.some((diagnostic) => diagnostic.severity === "error") @@ -37,6 +47,8 @@ function errorMessage(error: unknown) { export default function App() { const [colorMode, setColorMode] = useState(initialColorMode) const [source, setSource] = useState(EXAMPLE_SOURCE) + const [documentVersion, setDocumentVersion] = useState(0) + const [analysisRevision, setAnalysisRevision] = useState(0) const [diagnostics, setDiagnostics] = useState([]) const [svg, setSvg] = useState(null) const [metadata, setMetadata] = useState(null) @@ -44,6 +56,12 @@ export default function App() { const [providerPacks, setProviderPacks] = useState([]) const [status, setStatus] = useState("Loading engine…") const [isReady, setIsReady] = useState(false) + const sourceRef = useRef(EXAMPLE_SOURCE) + const providerPacksRef = useRef([]) + const documentVersionRef = useRef(0) + const analysisRevisionRef = useRef(0) + const lastRenderedRevisionRef = useRef(-1) + const scheduledRenderRef = useRef | null>(null) useEffect(() => { applyColorMode(colorMode) @@ -55,18 +73,39 @@ export default function App() { setStatus(errorMessage(error)) }, []) - const runRender = useCallback((nextSource: string, packs: readonly LoadedProviderPack[]) => { - const result = renderStack( - nextSource, - packs.map((pack) => pack.input), - ) - setDiagnostics(result.diagnostics) - setMetadata(result.metadata) - setSvg(result.svg) - setProviderNotices(result.providerNotices) - setStatus(resultStatus("Render", result.diagnostics)) + const cancelScheduledRender = useCallback(() => { + if (!scheduledRenderRef.current) return + clearTimeout(scheduledRenderRef.current) + scheduledRenderRef.current = null }, []) + const runRender = useCallback( + ( + nextSource: string, + packs: readonly LoadedProviderPack[], + revision: number, + action = "Render", + ) => { + try { + const result = renderStack( + nextSource, + packs.map((pack) => pack.input), + ) + if (revision !== analysisRevisionRef.current) return + + lastRenderedRevisionRef.current = revision + setDiagnostics(result.diagnostics) + setMetadata(result.metadata) + setSvg(result.svg) + setProviderNotices(result.providerNotices) + setStatus(resultStatus(action, result.diagnostics)) + } catch (error) { + if (revision === analysisRevisionRef.current) reportFailure(error) + } + }, + [reportFailure], + ) + useEffect(() => { let active = true @@ -74,7 +113,7 @@ export default function App() { .then(() => { if (!active) return setIsReady(true) - runRender(EXAMPLE_SOURCE, []) + runRender(sourceRef.current, providerPacksRef.current, analysisRevisionRef.current) }) .catch((error: unknown) => { if (active) reportFailure(error) @@ -85,49 +124,105 @@ export default function App() { } }, [reportFailure, runRender]) - function handleRender() { - try { - runRender(source, providerPacks) - } catch (error) { - reportFailure(error) + useEffect(() => { + if (!isReady || lastRenderedRevisionRef.current === analysisRevision) return + + scheduledRenderRef.current = setTimeout(() => { + scheduledRenderRef.current = null + runRender(source, providerPacks, analysisRevision) + }, INPUT_RENDER_DELAY_MS) + + return () => { + if (scheduledRenderRef.current) { + clearTimeout(scheduledRenderRef.current) + scheduledRenderRef.current = null + } } - } + }, [analysisRevision, isReady, providerPacks, runRender, source]) - function handleSourceChange(nextSource: string) { + const handleSourceChange = useCallback((nextSource: string) => { + sourceRef.current = nextSource setSource(nextSource) + + const nextDocumentVersion = documentVersionRef.current + 1 + documentVersionRef.current = nextDocumentVersion + setDocumentVersion(nextDocumentVersion) + + const nextRevision = analysisRevisionRef.current + 1 + analysisRevisionRef.current = nextRevision + setAnalysisRevision(nextRevision) + setDiagnostics([]) - setStatus("Source changed") + setSvg(null) + setProviderNotices([]) + setStatus("Analyzing source…") + return nextDocumentVersion + }, []) + + const handleRequestCompletion = useCallback( + (snapshot: string, version: number, position: SourcePosition): CompletionResult => + completeStack( + snapshot, + version, + position, + providerPacksRef.current.map((pack) => pack.input), + ), + [], + ) + + const handleRequestHover = useCallback( + (snapshot: string, version: number, position: SourcePosition): HoverResult => + hoverStack(snapshot, version, position), + [], + ) + + function advanceProviderContext(packs: readonly LoadedProviderPack[]) { + providerPacksRef.current = packs + setProviderPacks(packs) + + const nextDocumentVersion = documentVersionRef.current + 1 + documentVersionRef.current = nextDocumentVersion + setDocumentVersion(nextDocumentVersion) + + const nextRevision = analysisRevisionRef.current + 1 + analysisRevisionRef.current = nextRevision + setAnalysisRevision(nextRevision) + return nextRevision + } + + function handleRender() { + cancelScheduledRender() + runRender(sourceRef.current, providerPacksRef.current, analysisRevisionRef.current) } function handleCheck() { + cancelScheduledRender() try { const result = checkStack( - source, - providerPacks.map((pack) => pack.input), + sourceRef.current, + providerPacksRef.current.map((pack) => pack.input), ) - setDiagnostics(result.diagnostics) setMetadata(result.metadata) - setStatus(resultStatus("Check", result.diagnostics)) + runRender(sourceRef.current, providerPacksRef.current, analysisRevisionRef.current, "Check") } catch (error) { reportFailure(error) } } function handleFormat() { + cancelScheduledRender() try { - const result = formatStack(source) + const result = formatStack(sourceRef.current) setDiagnostics(result.diagnostics) setMetadata(result.metadata) setStatus(resultStatus("Format", result.diagnostics)) if (result.formattedSource !== null) { - setSource(result.formattedSource) - const rendered = renderStack( - result.formattedSource, - providerPacks.map((pack) => pack.input), - ) - setSvg(rendered.svg) - setProviderNotices(rendered.providerNotices) + const formattedSource = result.formattedSource + const nextVersion = handleSourceChange(formattedSource) + const nextRevision = analysisRevisionRef.current + documentVersionRef.current = nextVersion + runRender(formattedSource, providerPacksRef.current, nextRevision, "Format") } } catch (error) { reportFailure(error) @@ -141,14 +236,16 @@ export default function App() { function handleProviderIconStoreLoad(packs: readonly LoadedProviderPack[]) { for (const pack of packs) validateProviderPack(pack.input) - setProviderPacks(packs) - runRender(source, packs) + cancelScheduledRender() + const nextRevision = advanceProviderContext(packs) + runRender(sourceRef.current, packs, nextRevision) } function handleProviderPackRemove(providerId: string) { - const nextPacks = providerPacks.filter((pack) => pack.providerId !== providerId) - setProviderPacks(nextPacks) - runRender(source, nextPacks) + const nextPacks = providerPacksRef.current.filter((pack) => pack.providerId !== providerId) + cancelScheduledRender() + const nextRevision = advanceProviderContext(nextPacks) + runRender(sourceRef.current, nextPacks, nextRevision) } return ( @@ -206,9 +303,12 @@ export default function App() { colorMode={colorMode} diagnostics={diagnostics} disabled={!isReady} + documentVersion={documentVersion} onCheck={handleCheck} onFormat={handleFormat} onRender={handleRender} + onRequestCompletion={handleRequestCompletion} + onRequestHover={handleRequestHover} onSourceChange={handleSourceChange} source={source} /> diff --git a/src/components/diagnostic-layer.test.tsx b/src/components/diagnostic-layer.test.tsx new file mode 100644 index 0000000..79b433f --- /dev/null +++ b/src/components/diagnostic-layer.test.tsx @@ -0,0 +1,72 @@ +import { render } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { diagnosticLines } from "@/lib/diagnostic-lines" + +import { DiagnosticLayer } from "./diagnostic-layer" + +describe("DiagnosticLayer", () => { + it("marks UTF-8 ranges and point diagnostics without duplicating accessible text", () => { + const source = 'node café "😀"\nlayout { direction hoo }' + const start = new TextEncoder().encode(source.slice(0, source.indexOf("hoo"))).length + const end = start + 3 + const diagnostics = [ + { + code: "STK2002", + expected: ["right", "down"], + help: null, + message: "Unknown direction.", + range: { + end: { byteOffset: end, column: 25, line: 2 }, + start: { byteOffset: start, column: 22, line: 2 }, + }, + related: [], + severity: "error" as const, + }, + { + code: "STK2001", + expected: [], + help: null, + message: "Close the document.", + range: { + end: { byteOffset: end, column: 25, line: 2 }, + start: { byteOffset: end, column: 25, line: 2 }, + }, + related: [], + severity: "warning" as const, + }, + ] + + const lines = diagnosticLines(source, diagnostics) + expect(lines[1].find((part) => part.severity === "error")?.text).toBe("hoo") + expect(lines[1].some((part) => part.point && part.severity === "warning")).toBe(true) + + const { container } = render( + , + ) + expect(container.querySelector("pre")).toHaveAttribute("aria-hidden", "true") + expect(container.querySelectorAll('[data-diagnostic-severity="error"]')).toHaveLength(1) + expect(container.querySelectorAll('[data-diagnostic-severity="warning"]')).toHaveLength(1) + }) + + it("keeps the stronger diagnostic when ranges overlap", () => { + const source = "abc" + const range = { + end: { byteOffset: 2, column: 3, line: 1 }, + start: { byteOffset: 1, column: 2, line: 1 }, + } + const diagnostics = ["warning", "error"].map((severity, index) => ({ + code: `STK${index}`, + expected: [], + help: null, + message: "message", + range, + related: [], + severity: severity as "warning" | "error", + })) + + expect( + diagnosticLines(source, diagnostics)[0].find((part) => part.text === "b")?.severity, + ).toBe("error") + }) +}) diff --git a/src/components/diagnostic-layer.tsx b/src/components/diagnostic-layer.tsx new file mode 100644 index 0000000..03158bd --- /dev/null +++ b/src/components/diagnostic-layer.tsx @@ -0,0 +1,48 @@ +import type { RefObject } from "react" +import type { Diagnostic } from "@stack-sh/engine" + +import { diagnosticLines } from "@/lib/diagnostic-lines" +import { cn } from "@/lib/utils" + +interface DiagnosticLayerProps { + diagnostics: readonly Diagnostic[] + layerRef: RefObject + source: string +} + +export function DiagnosticLayer({ diagnostics, layerRef, source }: DiagnosticLayerProps) { + const lines = diagnosticLines(source, diagnostics) + + return ( + + ) +} diff --git a/src/components/editor-pane.tsx b/src/components/editor-pane.tsx index 95eae1b..b9edd88 100644 --- a/src/components/editor-pane.tsx +++ b/src/components/editor-pane.tsx @@ -1,52 +1,119 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +/* oxlint-disable jsx-a11y/prefer-tag-over-role -- Native select controls cannot preserve textarea focus or render completion metadata. */ + +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react" import { Braces, CircleCheck, Play } from "lucide-react" +import type { + CompletionItem, + CompletionResult, + Diagnostic, + Hover, + HoverResult, + SourcePosition, + SourceRange, +} from "@stack-sh/engine" +import { DiagnosticLayer } from "@/components/diagnostic-layer" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import type { ColorMode } from "@/lib/color-mode" import { highlightStack } from "@/lib/highlight-stack" -import { sourceSelection } from "@/lib/source-position" +import { applyTextEdit, sourcePositionAtUtf16Index, sourceSelection } from "@/lib/source-position" import { cn } from "@/lib/utils" import { Diagnostics } from "./diagnostics" import { HighlightLayer } from "./highlight-layer" -import type { Diagnostic, SourceRange } from "@stack-sh/engine" interface EditorPaneProps { colorMode: ColorMode source: string + documentVersion: number diagnostics: readonly Diagnostic[] disabled: boolean - onSourceChange: (source: string) => void + onSourceChange: (source: string) => number + onRequestCompletion: ( + source: string, + documentVersion: number, + position: SourcePosition, + ) => CompletionResult + onRequestHover: (source: string, documentVersion: number, position: SourcePosition) => HoverResult onFormat: () => void onCheck: () => void onRender: () => void } +interface PendingSelection { + documentVersion: number + index: number + source: string +} + +const MAX_VISIBLE_COMPLETIONS = 10 + export function EditorPane({ colorMode, source, + documentVersion, diagnostics, disabled, onSourceChange, + onRequestCompletion, + onRequestHover, onFormat, onCheck, onRender, }: EditorPaneProps) { const gutterRef = useRef(null) const highlightRef = useRef(null) + const diagnosticLayerRef = useRef(null) const textareaRef = useRef(null) + const completionTimerRef = useRef | null>(null) + const hoverTimerRef = useRef | null>(null) + const completionRequestRef = useRef(0) + const completionSelectionRef = useRef(null) + const hoverRequestRef = useRef(0) + const composingRef = useRef(false) + const focusedRef = useRef(false) + const latestSourceRef = useRef(source) + const latestVersionRef = useRef(documentVersion) + const pendingSelectionRef = useRef(null) + const characterWidthRef = useRef(null) + const completionListId = useId() + const contextId = useId() + const intelligenceStatusId = useId() const [highlighted, setHighlighted] = useState<{ source: string colorMode: ColorMode lines: Awaited> } | null>(null) const [isComposing, setIsComposing] = useState(false) + const [completion, setCompletion] = useState(null) + const [activeCompletion, setActiveCompletion] = useState(0) + const [hoverState, setHoverState] = useState<{ + documentVersion: number + hover: Hover | null + } | null>(null) + const [intelligenceErrorState, setIntelligenceErrorState] = useState<{ + documentVersion: number + message: string + } | null>(null) + + const completionItems = useMemo( + () => + completion?.documentVersion === documentVersion + ? completion.items.slice(0, MAX_VISIBLE_COMPLETIONS) + : [], + [completion, documentVersion], + ) + const hover = hoverState?.documentVersion === documentVersion ? hoverState.hover : null + const intelligenceError = + intelligenceErrorState?.documentVersion === documentVersion + ? intelligenceErrorState.message + : null const lineNumbers = useMemo( () => Array.from({ length: source.split("\n").length }, (_, index) => index + 1), [source], ) - const diagnosticLines = useMemo(() => { + const diagnosticGutterLines = useMemo(() => { const lines = new Map() for (const diagnostic of diagnostics) { const line = diagnostic.range.start.line @@ -71,11 +138,126 @@ export function EditorPane({ } }, [colorMode, source]) + useEffect( + () => () => { + if (completionTimerRef.current) clearTimeout(completionTimerRef.current) + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) + }, + [], + ) + + const requestCompletion = useCallback( + (snapshot: string, version: number, selection: number, delay: number) => { + if (disabled || composingRef.current) return + if (completionTimerRef.current) clearTimeout(completionTimerRef.current) + completionSelectionRef.current = selection + const request = completionRequestRef.current + 1 + completionRequestRef.current = request + + completionTimerRef.current = setTimeout(() => { + try { + const result = onRequestCompletion( + snapshot, + version, + sourcePositionAtUtf16Index(snapshot, selection), + ) + if ( + request !== completionRequestRef.current || + !focusedRef.current || + composingRef.current || + textareaRef.current?.selectionStart !== selection || + textareaRef.current?.selectionEnd !== selection || + snapshot !== latestSourceRef.current || + version !== latestVersionRef.current || + result.documentVersion !== version + ) { + return + } + setCompletion(result.items.length > 0 ? result : null) + setActiveCompletion(0) + setIntelligenceErrorState(null) + } catch (error) { + if (request !== completionRequestRef.current) return + setCompletion(null) + setIntelligenceErrorState({ documentVersion: version, message: errorMessage(error) }) + } + }, delay) + }, + [disabled, onRequestCompletion], + ) + + const requestHover = useCallback( + (snapshot: string, version: number, selection: number, delay: number) => { + if (disabled || composingRef.current) return + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) + const request = hoverRequestRef.current + 1 + hoverRequestRef.current = request + + hoverTimerRef.current = setTimeout(() => { + try { + let result = onRequestHover( + snapshot, + version, + sourcePositionAtUtf16Index(snapshot, selection), + ) + if (result.documentVersion !== version) return + if (result.hover === null && selection > 0) { + const previous = onRequestHover( + snapshot, + version, + sourcePositionAtUtf16Index(snapshot, selection - 1), + ) + if (previous.documentVersion !== version) return + result = previous + } + if ( + request !== hoverRequestRef.current || + snapshot !== latestSourceRef.current || + version !== latestVersionRef.current + ) { + return + } + setHoverState({ documentVersion: version, hover: result.hover }) + setIntelligenceErrorState(null) + } catch (error) { + if (request !== hoverRequestRef.current) return + setHoverState(null) + setIntelligenceErrorState({ documentVersion: version, message: errorMessage(error) }) + } + }, delay) + }, + [disabled, onRequestHover], + ) + + useLayoutEffect(() => { + latestSourceRef.current = source + latestVersionRef.current = documentVersion + }, [documentVersion, source]) + + useLayoutEffect(() => { + const pending = pendingSelectionRef.current + const textarea = textareaRef.current + if ( + !pending || + !textarea || + pending.source !== source || + pending.documentVersion !== documentVersion + ) { + return + } + + pendingSelectionRef.current = null + textarea.focus() + textarea.setSelectionRange(pending.index, pending.index) + requestHover(source, documentVersion, pending.index, 0) + }, [documentVersion, requestHover, source]) + const syncScroll = useCallback((textarea: HTMLTextAreaElement) => { if (gutterRef.current) gutterRef.current.scrollTop = textarea.scrollTop - if (highlightRef.current) { - highlightRef.current.scrollTop = textarea.scrollTop - highlightRef.current.scrollLeft = textarea.scrollLeft + for (const layer of [highlightRef.current, diagnosticLayerRef.current]) { + if (!layer) continue + layer.scrollTop = textarea.scrollTop + layer.scrollLeft = textarea.scrollLeft } }, []) @@ -86,6 +268,7 @@ export function EditorPane({ const selection = sourceSelection(source, range) textarea.focus() textarea.setSelectionRange(selection.start, selection.end) + requestHover(source, documentVersion, selection.start, 0) const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 26.4 const selectionTop = (range.start.line - 1) * lineHeight @@ -98,12 +281,70 @@ export function EditorPane({ syncScroll(textarea) } + function handleSourceChange(nextSource: string, selection: number) { + const nextVersion = onSourceChange(nextSource) + latestSourceRef.current = nextSource + latestVersionRef.current = nextVersion + setCompletion(null) + setHoverState(null) + setIntelligenceErrorState(null) + requestCompletion(nextSource, nextVersion, selection, 80) + requestHover(nextSource, nextVersion, selection, 140) + } + + function handleSelection(selection: number, delay = 80) { + if ( + selection !== completionSelectionRef.current || + textareaRef.current?.selectionEnd !== selection + ) { + completionRequestRef.current += 1 + setCompletion(null) + } + requestHover(latestSourceRef.current, latestVersionRef.current, selection, delay) + } + + function acceptCompletion(item: CompletionItem) { + const edited = applyTextEdit(latestSourceRef.current, item.edit) + const nextVersion = onSourceChange(edited.source) + latestSourceRef.current = edited.source + latestVersionRef.current = nextVersion + pendingSelectionRef.current = { + documentVersion: nextVersion, + index: edited.selection, + source: edited.source, + } + setCompletion(null) + setHoverState(null) + setIntelligenceErrorState(null) + } + + function handlePointerMove(event: React.PointerEvent) { + if (event.pointerType === "touch") return + const textarea = event.currentTarget + characterWidthRef.current ??= measureCharacterWidth( + getComputedStyle(textarea).font, + Number.parseFloat(getComputedStyle(textarea).fontSize) || 13, + ) + const index = sourceIndexAtPointer( + textarea, + source, + event.clientX, + event.clientY, + characterWidthRef.current, + ) + requestHover(source, documentVersion, index, 180) + } + const highlightedLines = highlighted?.source === source && highlighted.colorMode === colorMode ? highlighted.lines : null const showHighlighting = highlightedLines !== null && !isComposing + const hasCompletions = completionItems.length > 0 + const activeOptionId = hasCompletions + ? `${completionListId}-option-${activeCompletion}` + : undefined return ( -
+

Source

@@ -142,11 +383,11 @@ export function EditorPane({ @@ -161,35 +402,215 @@ export function EditorPane({ isVisible={showHighlighting} lines={highlightedLines} /> + +