From 7cb4d8b251563ef604f38eaf59cf176172285666 Mon Sep 17 00:00:00 2001 From: felipegenef Date: Fri, 19 Jun 2026 13:48:33 -0300 Subject: [PATCH] feat(app): edit files directly in the app --- bun.lock | 55 +++ .../session/session-sortable-tab.tsx | 42 +- packages/app/src/context/command.tsx | 2 +- packages/app/src/context/layout.tsx | 77 ++- .../app/src/context/tab-write-guard.test.ts | 58 +++ packages/app/src/context/tab-write-guard.ts | 17 + packages/app/src/i18n/en.ts | 15 + .../src/pages/session/file-confirm-dialog.tsx | 61 +++ .../app/src/pages/session/file-save.test.ts | 301 ++++++++++++ packages/app/src/pages/session/file-save.ts | 140 ++++++ packages/app/src/pages/session/file-tabs.tsx | 258 +++++++++- .../src/pages/session/session-side-panel.tsx | 31 +- .../pages/session/use-session-commands.tsx | 17 +- packages/opencode/src/lsp/client.ts | 66 ++- packages/opencode/src/lsp/lsp.ts | 86 +++- .../src/server/routes/instance/httpapi/api.ts | 2 + .../routes/instance/httpapi/groups/file.ts | 25 + .../routes/instance/httpapi/groups/lsp.ts | 146 ++++++ .../routes/instance/httpapi/handlers/file.ts | 55 +++ .../routes/instance/httpapi/handlers/lsp.ts | 112 +++++ .../server/routes/instance/httpapi/server.ts | 2 + .../test/fixture/lsp/fake-lsp-server.js | 46 +- .../opencode/test/lsp/buffer-sync.test.ts | 196 ++++++++ packages/opencode/test/lsp/completion.test.ts | 208 ++++++++ .../test/lsp/diagnostics-event.test.ts | 211 ++++++++ .../test/server/httpapi-file-write.test.ts | 154 ++++++ .../opencode/test/server/httpapi-lsp.test.ts | 136 ++++++ packages/opencode/test/session/prompt.test.ts | 3 + .../test/session/snapshot-tool-race.test.ts | 3 + packages/opencode/test/tool/lsp.test.ts | 3 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 347 +++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 292 +++++++++++ packages/ui/bunfig.toml | 5 + packages/ui/package.json | 16 + .../ui/src/codemirror/shiki-highlight.test.ts | 29 ++ packages/ui/src/codemirror/shiki-highlight.ts | 196 ++++++++ packages/ui/src/codemirror/theme.ts | 298 ++++++++++++ .../ui/src/components/code-editor-lsp.test.ts | 354 ++++++++++++++ packages/ui/src/components/code-editor-lsp.ts | 455 ++++++++++++++++++ .../ui/src/components/code-editor.test.tsx | 115 +++++ packages/ui/src/components/code-editor.tsx | 267 ++++++++++ packages/ui/test/dom-preload.ts | 49 ++ 42 files changed, 4889 insertions(+), 62 deletions(-) create mode 100644 packages/app/src/context/tab-write-guard.test.ts create mode 100644 packages/app/src/context/tab-write-guard.ts create mode 100644 packages/app/src/pages/session/file-confirm-dialog.tsx create mode 100644 packages/app/src/pages/session/file-save.test.ts create mode 100644 packages/app/src/pages/session/file-save.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts create mode 100644 packages/opencode/test/lsp/buffer-sync.test.ts create mode 100644 packages/opencode/test/lsp/completion.test.ts create mode 100644 packages/opencode/test/lsp/diagnostics-event.test.ts create mode 100644 packages/opencode/test/server/httpapi-file-write.test.ts create mode 100644 packages/opencode/test/server/httpapi-lsp.test.ts create mode 100644 packages/ui/bunfig.toml create mode 100644 packages/ui/src/codemirror/shiki-highlight.test.ts create mode 100644 packages/ui/src/codemirror/shiki-highlight.ts create mode 100644 packages/ui/src/codemirror/theme.ts create mode 100644 packages/ui/src/components/code-editor-lsp.test.ts create mode 100644 packages/ui/src/components/code-editor-lsp.ts create mode 100644 packages/ui/src/components/code-editor.test.tsx create mode 100644 packages/ui/src/components/code-editor.tsx create mode 100644 packages/ui/test/dom-preload.ts diff --git a/bun.lock b/bun.lock index 456c8165e1ca..19d6ade3014e 100644 --- a/bun.lock +++ b/bun.lock @@ -823,7 +823,18 @@ "name": "@opencode-ai/ui", "version": "1.17.7", "dependencies": { + "@codemirror/autocomplete": "6.18.6", + "@codemirror/commands": "6.8.1", + "@codemirror/lang-go": "6.0.1", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-python": "6.2.1", + "@codemirror/language": "6.11.3", + "@codemirror/lint": "6.8.5", + "@codemirror/search": "6.5.11", + "@codemirror/state": "6.5.2", + "@codemirror/view": "6.38.1", "@kobalte/core": "catalog:", + "@lezer/highlight": "1.2.3", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", @@ -855,12 +866,16 @@ "virtua": "catalog:", }, "devDependencies": { + "@babel/core": "7.28.4", + "@babel/preset-typescript": "7.27.1", + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "babel-preset-solid": "1.9.12", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", @@ -1312,6 +1327,26 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], + + "@codemirror/commands": ["@codemirror/commands@6.8.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw=="], + + "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/language": ["@codemirror/language@6.11.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA=="], + + "@codemirror/lint": ["@codemirror/lint@6.8.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA=="], + + "@codemirror/search": ["@codemirror/search@6.5.11", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" } }, "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA=="], + + "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="], + + "@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -1624,6 +1659,18 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + + "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + + "@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="], @@ -1644,6 +1691,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], @@ -3188,6 +3237,8 @@ "crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], @@ -5004,6 +5055,8 @@ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -5302,6 +5355,8 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index f04228ca66c7..29a1e05008da 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -9,6 +9,7 @@ import { getFilename } from "@opencode-ai/core/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" +import { useSessionLayout } from "@/pages/session/session-layout" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return ( @@ -31,8 +32,10 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v const file = useFile() const language = useLanguage() const command = useCommand() + const { tabs } = useSessionLayout() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) + const dirty = createMemo(() => tabs().dirty(props.tab)) const content = createMemo(() => { const value = path() if (!value) return @@ -40,24 +43,33 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v }) return (
-
+
- props.onTabClose(props.tab)} - aria-label={language.t("common.closeTab")} - /> - + + + + + + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index e979ad6a0595..8c9fa0739f86 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -13,7 +13,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na const PALETTE_ID = "command.palette" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const SUGGESTED_PREFIX = "suggested." -const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) +const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach", "file.save"]) type KeyLabel = | "common.key.ctrl" diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 53bf40e70594..1aa8f2ef0b9b 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { createTabWriteGuard } from "./tab-write-guard" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -54,6 +55,7 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { type SessionTabs = { active?: string all: string[] + dirty?: Record } type SessionView = { @@ -566,9 +568,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (sessionTimer !== undefined) window.clearTimeout(sessionTimer) }) + const { runTabWrite, tabsWriting } = createTabWriteGuard() + return { route, ready, + tabsWriting, handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { @@ -896,28 +901,61 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( tabs, active: createMemo(() => tabs().active), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), + dirty(tab: string) { + return tabs().dirty?.[tab] ?? false + }, + setDirty(tab: string, value: boolean) { + const session = key() + const current = store.sessionTabs[session] + if (!current) { + if (!value) return + setStore("sessionTabs", session, { all: [], dirty: { [tab]: true } }) + return + } + if (!value) { + if (!current.dirty?.[tab]) return + setStore( + "sessionTabs", + session, + "dirty", + produce((draft) => { + if (draft) delete draft[tab] + }), + ) + return + } + if (!current.dirty) { + setStore("sessionTabs", session, "dirty", { [tab]: true }) + return + } + setStore("sessionTabs", session, "dirty", tab, true) + }, setActive(tab: string | undefined) { const session = key() const next = tab ? normalize(tab) : tab - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: [], active: next }) - } else { - setStore("sessionTabs", session, "active", next) - } + runTabWrite(() => { + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: [], active: next }) + } else { + setStore("sessionTabs", session, "active", next) + } + }) }, setAll(all: string[]) { const session = key() const next = normalizeAll(all).filter((tab) => tab !== "review") - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: next, active: undefined }) - } else { - setStore("sessionTabs", session, "all", next) - } + runTabWrite(() => { + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: next, active: undefined }) + } else { + setStore("sessionTabs", session, "all", next) + } + }) }, async open(tab: string) { const session = key() const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab)) - setStore("sessionTabs", session, next) + runTabWrite(() => setStore("sessionTabs", session, next)) }, close(tab: string) { const session = key() @@ -931,8 +969,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } const all = current.all.filter((x) => x !== tab) + const clearDirty = () => { + if (!current.dirty?.[tab]) return + setStore( + "sessionTabs", + session, + "dirty", + produce((draft) => { + if (draft) delete draft[tab] + }), + ) + } if (current.active !== tab) { - setStore("sessionTabs", session, "all", all) + batch(() => { + setStore("sessionTabs", session, "all", all) + clearDirty() + }) return } @@ -941,6 +993,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( batch(() => { setStore("sessionTabs", session, "all", all) setStore("sessionTabs", session, "active", next) + clearDirty() }) }, move(tab: string, to: number) { diff --git a/packages/app/src/context/tab-write-guard.test.ts b/packages/app/src/context/tab-write-guard.test.ts new file mode 100644 index 000000000000..7e197d6005ea --- /dev/null +++ b/packages/app/src/context/tab-write-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { createTabWriteGuard } from "./tab-write-guard" + +const microtask = () => new Promise((resolve) => queueMicrotask(resolve)) + +describe("tab write guard (layout tabsWriting)", () => { + test("tabsWriting is true synchronously inside a tab write and false after a microtask", async () => { + const guard = createTabWriteGuard() + expect(guard.tabsWriting()).toBe(false) + + let insideDuringWrite: boolean | undefined + guard.runTabWrite(() => { + insideDuringWrite = guard.tabsWriting() + }) + + expect(insideDuringWrite).toBe(true) + expect(guard.tabsWriting()).toBe(true) + + await microtask() + expect(guard.tabsWriting()).toBe(false) + }) + + test("nested writes stay guarded until all have flushed", async () => { + const guard = createTabWriteGuard() + guard.runTabWrite(() => { + guard.runTabWrite(() => {}) + expect(guard.tabsWriting()).toBe(true) + }) + expect(guard.tabsWriting()).toBe(true) + + await microtask() + expect(guard.tabsWriting()).toBe(false) + }) + + test("onChange feedback during a programmatic write is ignored; genuine onChange is honored", async () => { + const guard = createTabWriteGuard() + let active = "review" + + const openTab = (value: string) => { + active = value + } + const onTabsChange = (value: string) => { + if (guard.tabsWriting()) return + openTab(value) + } + + // Programmatically open a file tab; Kobalte re-emits onChange("review") while + // it reconciles. That stale feedback must NOT revert the active tab. + guard.runTabWrite(() => openTab("file://src/a.ts")) + onTabsChange("review") + expect(active).toBe("file://src/a.ts") + + // After the write flushes, a genuine user onChange IS honored. + await microtask() + onTabsChange("review") + expect(active).toBe("review") + }) +}) diff --git a/packages/app/src/context/tab-write-guard.ts b/packages/app/src/context/tab-write-guard.ts new file mode 100644 index 000000000000..69daade8a855 --- /dev/null +++ b/packages/app/src/context/tab-write-guard.ts @@ -0,0 +1,17 @@ +// Controlled Kobalte re-emits onChange with a stale key during programmatic +// active-tab changes; guard so the Tabs onChange ignores that feedback. Reset on a +// microtask to cover the store write's reactive flush. +export function createTabWriteGuard() { + let depth = 0 + const runTabWrite = (fn: () => T): T => { + depth++ + try { + return fn() + } finally { + queueMicrotask(() => { + depth-- + }) + } + } + return { runTabWrite, tabsWriting: () => depth > 0 } +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 403addcb241a..2b91178e4cca 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -722,6 +722,21 @@ export const dict = { "common.close": "Close", "common.edit": "Edit", "common.loadMore": "Load more", + "common.discard": "Discard", + "common.overwrite": "Overwrite", + "common.reload": "Reload", + "common.unsavedChanges": "Unsaved changes", + "file.edit.toggle.edit": "Edit", + "file.edit.toggle.view": "View", + "file.save.command": "Save file", + "file.unsaved.title": "Unsaved changes", + "file.unsaved.description": "You have unsaved changes. Do you want to save them?", + "file.conflict.title": "File changed on disk", + "file.conflict.description": "This file was modified outside the editor. Reload to discard your edits, or overwrite to keep them.", + "toast.file.saved.title": "File saved", + "toast.file.saveFailed.title": "Failed to save file", + "toast.file.changedExternally.title": "File changed on disk", + "toast.file.definitionExternal.title": "Definition is outside the workspace", "common.key.esc": "ESC", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", diff --git a/packages/app/src/pages/session/file-confirm-dialog.tsx b/packages/app/src/pages/session/file-confirm-dialog.tsx new file mode 100644 index 000000000000..8d49cadc0a57 --- /dev/null +++ b/packages/app/src/pages/session/file-confirm-dialog.tsx @@ -0,0 +1,61 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { For } from "solid-js" + +export type ConfirmChoice = { + value: string + label: string + variant?: "primary" | "secondary" | "ghost" +} + +function ConfirmDialog(props: { + title: string + description: string + choices: ConfirmChoice[] + onPick: (value: string | undefined) => void +}) { + const dialog = useDialog() + return ( + +
+
+ + {(choice) => ( + + )} + +
+
+
+ ) +} + +export function confirmChoice( + dialog: ReturnType, + input: { title: string; description: string; choices: ConfirmChoice[] }, +): Promise { + return new Promise((resolve) => { + let settled = false + const pick = (value: string | undefined) => { + if (settled) return + settled = true + resolve(value) + } + dialog.push( + () => ( + + ), + () => pick(undefined), + ) + }) +} diff --git a/packages/app/src/pages/session/file-save.test.ts b/packages/app/src/pages/session/file-save.test.ts new file mode 100644 index 000000000000..e56ca90a5e38 --- /dev/null +++ b/packages/app/src/pages/session/file-save.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, mock, test } from "bun:test" +import { + activeEditor, + clearActiveEditor, + createFileSaver, + guardTab, + isDirtyAgainst, + registerActiveEditor, + setPendingEditOpen, + takePendingEditOpen, + type WriteResult, +} from "./file-save" + +type Harness = { + editing: boolean + text: string + dirty: boolean + conflict?: "reload" | "overwrite" | undefined + unsaved?: "save" | "discard" | "cancel" | undefined +} + +function createHarness(initial: Partial & { write: (content: string, expectedSha?: string) => WriteResult }) { + const state: Harness = { + editing: true, + text: "", + dirty: false, + ...initial, + } + const writes: Array<{ content: string; expectedSha?: string }> = [] + const saver = createFileSaver({ + editing: () => state.editing, + currentText: () => state.text, + isDirty: () => state.dirty, + setDirty: (v) => { + state.dirty = v + }, + write: async (content, expectedSha) => { + writes.push({ content, expectedSha }) + return initial.write(content, expectedSha) + }, + reloadFromDisk: () => {}, + leaveEditMode: () => { + state.editing = false + }, + promptConflict: async () => state.conflict, + promptUnsaved: async () => state.unsaved, + }) + return { state, saver, writes } +} + +describe("isDirtyAgainst", () => { + test("dirty when content differs, clean when it matches (empty is valid)", () => { + expect(isDirtyAgainst("a", "b")).toBe(true) + expect(isDirtyAgainst("a", "a")).toBe(false) + expect(isDirtyAgainst("", "")).toBe(false) + expect(isDirtyAgainst("x", "")).toBe(true) + }) +}) + +describe("createFileSaver.onChange dirty tracking", () => { + test("editing sets dirty; returning to baseline clears it", () => { + const { state, saver } = createHarness({ write: () => ({ written: true }) }) + saver.setBaseline("hello", "sha1") + + saver.onChange("hello world") + expect(state.dirty).toBe(true) + + saver.onChange("hello") + expect(state.dirty).toBe(false) + }) +}) + +describe("createFileSaver.save", () => { + test("writes content with expectedSha, clears dirty, updates baseline", async () => { + const { state, saver, writes } = createHarness({ + text: "new", + dirty: true, + write: () => ({ written: true, sha: "sha2" }), + }) + saver.setBaseline("old", "sha1") + + await saver.save() + + expect(writes).toHaveLength(1) + expect(writes[0]).toEqual({ content: "new", expectedSha: "sha1" }) + expect(state.dirty).toBe(false) + expect(saver.baselineSha).toBe("sha2") + + // Baseline updated: editing back to "new" is clean, "old" is now dirty. + saver.onChange("new") + expect(state.dirty).toBe(false) + }) + + test("idempotent: no write when not dirty", async () => { + const { saver, writes } = createHarness({ text: "x", dirty: false, write: () => ({ written: true }) }) + await saver.save() + expect(writes).toHaveLength(0) + }) + + test("conflict triggers prompt and keeps dirty when dismissed", async () => { + const prompt = mock(async () => undefined as "reload" | "overwrite" | undefined) + const state = { editing: true, text: "new", dirty: true } + const writes: Array<{ content: string; expectedSha?: string }> = [] + const saver = createFileSaver({ + editing: () => state.editing, + currentText: () => state.text, + isDirty: () => state.dirty, + setDirty: (v) => { + state.dirty = v + }, + write: async (content, expectedSha) => { + writes.push({ content, expectedSha }) + return { written: false, conflict: true } + }, + reloadFromDisk: () => {}, + leaveEditMode: () => {}, + promptConflict: () => prompt(), + promptUnsaved: async () => undefined, + }) + saver.setBaseline("old", "sha1") + + await saver.save() + + expect(prompt).toHaveBeenCalledTimes(1) + expect(state.dirty).toBe(true) // still dirty after dismiss + }) + + test("conflict + overwrite re-sends WITHOUT expectedSha and clears dirty", async () => { + const writes: Array<{ content: string; expectedSha?: string }> = [] + const state = { editing: true, text: "new", dirty: true } + let call = 0 + const saver = createFileSaver({ + editing: () => state.editing, + currentText: () => state.text, + isDirty: () => state.dirty, + setDirty: (v) => { + state.dirty = v + }, + write: async (content, expectedSha) => { + writes.push({ content, expectedSha }) + call += 1 + return call === 1 ? { written: false, conflict: true } : { written: true, sha: "sha3" } + }, + reloadFromDisk: () => {}, + leaveEditMode: () => {}, + promptConflict: async () => "overwrite", + promptUnsaved: async () => undefined, + }) + saver.setBaseline("old", "sha1") + + await saver.save() + + expect(writes).toHaveLength(2) + expect(writes[0].expectedSha).toBe("sha1") + expect(writes[1].expectedSha).toBeUndefined() // forced overwrite + expect(state.dirty).toBe(false) + expect(saver.baselineSha).toBe("sha3") + }) +}) + +describe("createFileSaver.guard (unsaved guard)", () => { + test("clean tab is safe without prompting", async () => { + const prompt = mock(async () => "cancel" as const) + const { saver } = createHarness({ dirty: false, write: () => ({ written: true }) }) + expect(await saver.guard()).toBe(true) + expect(prompt).not.toHaveBeenCalled() + }) + + test("dirty tab prompts; cancel blocks", async () => { + const { saver } = createHarness({ dirty: true, unsaved: "cancel", write: () => ({ written: true }) }) + expect(await saver.guard()).toBe(false) + }) + + test("dirty tab prompts; discard proceeds and clears dirty", async () => { + const { state, saver } = createHarness({ dirty: true, unsaved: "discard", write: () => ({ written: true }) }) + expect(await saver.guard()).toBe(true) + expect(state.dirty).toBe(false) + }) + + test("dirty tab prompts; save writes then proceeds", async () => { + const { state, saver, writes } = createHarness({ + text: "new", + dirty: true, + unsaved: "save", + write: () => ({ written: true, sha: "s" }), + }) + saver.setBaseline("old") + expect(await saver.guard()).toBe(true) + expect(writes).toHaveLength(1) + expect(state.dirty).toBe(false) + }) +}) + +describe("active editor registry + guardTab", () => { + test("guardTab consults the registered editor and isolates by tab", async () => { + const guard = mock(async () => false) + registerActiveEditor({ + tab: "file://a", + editing: () => true, + dirty: () => true, + save: () => {}, + guard, + }) + + // Different tab: no active editor for it -> safe, no prompt. + expect(await guardTab("file://b")).toBe(true) + expect(guard).not.toHaveBeenCalled() + + // Matching tab: delegates to the editor's guard. + expect(await guardTab("file://a")).toBe(false) + expect(guard).toHaveBeenCalledTimes(1) + + clearActiveEditor("file://a") + expect(activeEditor()).toBeUndefined() + expect(await guardTab("file://a")).toBe(true) + }) +}) + +describe("pending cross-file edit-open slot", () => { + test("takePendingEditOpen returns + clears only for the matching path", () => { + setPendingEditOpen("go/main.go", { line: 10, character: 4 }) + + // Non-matching path leaves the slot intact. + expect(takePendingEditOpen("go/other.go")).toBeUndefined() + + // Matching path returns the pos and clears it. + expect(takePendingEditOpen("go/main.go")).toEqual({ line: 10, character: 4 }) + + // Slot is now empty. + expect(takePendingEditOpen("go/main.go")).toBeUndefined() + }) + + test("a later setPendingEditOpen overwrites the previous slot", () => { + setPendingEditOpen("a.ts", { line: 1, character: 0 }) + setPendingEditOpen("b.ts", { line: 2, character: 3 }) + expect(takePendingEditOpen("a.ts")).toBeUndefined() + expect(takePendingEditOpen("b.ts")).toEqual({ line: 2, character: 3 }) + }) +}) + +describe("switch-away guard (B1)", () => { + // Mirrors openTabGuarded in session-side-panel: before switching the active + // file tab, run guardTab(current); only switch when it resolves true. + const switchGuarded = async (current: string | undefined, next: string, onSwitch: (tab: string) => void) => { + if (!current || current === next) { + onSwitch(next) + return + } + if (await guardTab(current)) onSwitch(next) + } + + test("switching away from a dirty tab triggers the guard; Cancel aborts, Discard proceeds", async () => { + // Dirty active tab whose guard cancels. + const guard = mock(async () => false) + registerActiveEditor({ + tab: "file://a", + editing: () => true, + dirty: () => true, + save: () => {}, + guard, + }) + + let switched: string | undefined + await switchGuarded("file://a", "file://b", (t) => (switched = t)) + expect(guard).toHaveBeenCalledTimes(1) // guard consulted + expect(switched).toBeUndefined() // Cancel aborts the switch + + // Now the guard resolves (Discard/Save) -> switch proceeds. + const guardOk = mock(async () => true) + registerActiveEditor({ + tab: "file://a", + editing: () => true, + dirty: () => true, + save: () => {}, + guard: guardOk, + }) + await switchGuarded("file://a", "file://b", (t) => (switched = t)) + expect(guardOk).toHaveBeenCalledTimes(1) + expect(switched).toBe("file://b") + + clearActiveEditor("file://a") + }) + + test("switching with no dirty editor does not prompt and proceeds", async () => { + const guard = mock(async () => false) + registerActiveEditor({ + tab: "file://a", + editing: () => false, + dirty: () => false, + // guard returns true for a clean tab; this mock should not block. + guard: async () => true, + save: () => {}, + }) + + let switched: string | undefined + await switchGuarded("file://a", "file://b", (t) => (switched = t)) + expect(guard).not.toHaveBeenCalled() + expect(switched).toBe("file://b") + clearActiveEditor("file://a") + }) +}) diff --git a/packages/app/src/pages/session/file-save.ts b/packages/app/src/pages/session/file-save.ts new file mode 100644 index 000000000000..50addbeb1e2b --- /dev/null +++ b/packages/app/src/pages/session/file-save.ts @@ -0,0 +1,140 @@ +import { createSignal } from "solid-js" + +export type ActiveEditor = { + tab: string + editing: () => boolean + dirty: () => boolean + save: () => void | Promise + guard: () => Promise +} + +export async function guardTab(tab: string): Promise { + const editor = active() + if (!editor || editor.tab !== tab) return true + return editor.guard() +} + +const [active, setActive] = createSignal(undefined) + +export const activeEditor = active + +export function registerActiveEditor(editor: ActiveEditor) { + setActive(editor) +} + +export function clearActiveEditor(tab: string) { + setActive((current) => (current?.tab === tab ? undefined : current)) +} + +export type PendingEditPos = { line: number; character: number } +type PendingEditOpen = { path: string; pos: PendingEditPos } + +let pendingEditOpen: PendingEditOpen | undefined + +export function setPendingEditOpen(path: string, pos: PendingEditPos) { + pendingEditOpen = { path, pos } +} + +export function takePendingEditOpen(path: string): PendingEditPos | undefined { + if (!pendingEditOpen || pendingEditOpen.path !== path) return undefined + const pos = pendingEditOpen.pos + pendingEditOpen = undefined + return pos +} + +export function isDirtyAgainst(baseline: string, next: string) { + return next !== baseline +} + +export type WriteResult = { conflict?: boolean; sha?: string; written?: boolean } + +export type FileSaverDeps = { + editing: () => boolean + currentText: () => string + isDirty: () => boolean + setDirty: (value: boolean) => void + write: (content: string, expectedSha?: string) => Promise + reloadFromDisk: () => void | Promise + leaveEditMode: () => void + promptConflict: () => Promise<"reload" | "overwrite" | undefined> + promptUnsaved: () => Promise<"save" | "discard" | "cancel" | undefined> + onSaved?: (content: string, sha?: string) => void + onError?: () => void +} + +export function createFileSaver(deps: FileSaverDeps) { + let baseline = "" + let baselineSha: string | undefined + let saving = false + + const setBaseline = (content: string, sha?: string) => { + baseline = content + baselineSha = sha + } + + const applySaved = (content: string, sha?: string) => { + setBaseline(content, sha) + deps.setDirty(false) + deps.onSaved?.(content, sha) + } + + const onChange = (next: string) => { + deps.setDirty(isDirtyAgainst(baseline, next)) + } + + const save = async (): Promise => { + if (!deps.editing() || !deps.isDirty() || saving) return + const content = deps.currentText() + saving = true + try { + // A conflict is a normal result (stale baselineSha), not a throw; on save the baseline resets to the returned sha. + const res = await deps.write(content, baselineSha) + if (res.conflict) { + const choice = await deps.promptConflict() + if (choice === "reload") { + await deps.reloadFromDisk() + deps.leaveEditMode() + return + } + if (choice === "overwrite") { + const forced = await deps.write(content) + if (forced.conflict) { + deps.onError?.() + return + } + applySaved(content, forced.sha) + } + return + } + applySaved(content, res.sha) + } catch { + deps.onError?.() + } finally { + saving = false + } + } + + const guard = async (): Promise => { + if (!deps.editing() || !deps.isDirty()) return true + const choice = await deps.promptUnsaved() + if (choice === "save") { + await save() + return !deps.isDirty() + } + if (choice === "discard") { + deps.setDirty(false) + return true + } + return false + } + + return { + onChange, + save, + guard, + setBaseline, + get baselineSha() { + return baselineSha + }, + } +} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 364760eab0bc..0f4482c7c130 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" @@ -9,9 +9,22 @@ import { createLineCommentController } from "@opencode-ai/ui/line-comment-annota import { sampledChecksum } from "@opencode-ai/core/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" +import { CodeEditor } from "@opencode-ai/ui/code-editor" +import { lspExtensions, type LspDiagnostic, type LspExtensionsOptions } from "@opencode-ai/ui/code-editor-lsp" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { showToast } from "@/utils/toast" +import { useSDK } from "@/context/sdk" +import { + registerActiveEditor, + clearActiveEditor, + createFileSaver, + setPendingEditOpen, + takePendingEditOpen, + type PendingEditPos, +} from "@/pages/session/file-save" +import { confirmChoice } from "@/pages/session/file-confirm-dialog" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" @@ -177,6 +190,8 @@ export function FileTabContent(props: { tab: string }) { const language = useLanguage() const prompt = usePrompt() const fileComponent = useFileComponent() + const sdk = useSDK() + const dialog = useDialog() const { sessionKey, tabs, view } = useSessionLayout() const activeFileTab = createSessionTabs({ tabs, @@ -200,6 +215,188 @@ export function FileTabContent(props: { tab: string }) { }) const contents = createMemo(() => state()?.content?.content ?? "") const cacheKey = createMemo(() => sampledChecksum(contents())) + + const [editMode, setEditMode] = createSignal(false) + const [editorValue, setEditorValue] = createSignal("") + + const isDirty = () => tabs().dirty(props.tab) + const setDirty = (value: boolean) => tabs().setDirty(props.tab, value) + + const writeFile = async (content: string, expectedSha?: string) => { + const res = await sdk().client.file.write({ + path: path() ?? "", + content, + ...(expectedSha ? { expectedSha } : {}), + }) + if (res.error) throw res.error + return (res.data ?? {}) as { conflict?: boolean; sha?: string; written?: boolean } + } + + const saver = createFileSaver({ + editing: editMode, + currentText: () => editorValue(), + isDirty, + setDirty, + write: writeFile, + reloadFromDisk: async () => { + const p = path() + if (p) await file.load(p, { force: true }) + seedFromDisk() + }, + leaveEditMode: () => setEditMode(false), + promptConflict: async () => + (await confirmChoice(dialog, { + title: language.t("file.conflict.title"), + description: language.t("file.conflict.description"), + choices: [ + { value: "reload", label: language.t("common.reload") }, + { value: "overwrite", label: language.t("common.overwrite"), variant: "primary" }, + ], + })) as "reload" | "overwrite" | undefined, + promptUnsaved: async () => + (await confirmChoice(dialog, { + title: language.t("file.unsaved.title"), + description: language.t("file.unsaved.description"), + choices: [ + { value: "cancel", label: language.t("common.cancel"), variant: "ghost" }, + { value: "discard", label: language.t("common.discard") }, + { value: "save", label: language.t("common.save"), variant: "primary" }, + ], + })) as "save" | "discard" | "cancel" | undefined, + onSaved: () => { + const p = path() + if (p) void file.load(p, { force: true }) + showToast({ variant: "success", title: language.t("toast.file.saved.title") }) + }, + onError: () => showToast({ variant: "error", title: language.t("toast.file.saveFailed.title") }), + }) + + const save = () => saver.save() + const guardLeave = () => saver.guard() + + const seedFromDisk = () => { + const text = contents() + setEditorValue(text) + saver.setBaseline(text, undefined) + setDirty(false) + } + + const enterEditMode = () => { + if (editMode()) return + seedFromDisk() + setEditMode(true) + } + + const onEditorChange = (next: string) => { + setEditorValue(next) + saver.onChange(next) + } + + const [pendingSelection, setPendingSelection] = createSignal(undefined) + + createEffect(() => { + const p = path() + if (!p) return + if (!state()?.loaded) return + const pos = takePendingEditOpen(file.normalize(p)) + if (!pos) return + seedFromDisk() + setEditMode(true) + setPendingSelection(pos) + queueMicrotask(() => setPendingSelection(undefined)) + }) + + // Per-tab monotonic buffer version; a successful save keeps the buffer open at the same version (no bump, no re-open). + let bufferVersion = 0 + const diagnosticsSubs = new Set<(list: LspDiagnostic[]) => void>() + + const samePath = (a: string, b: string) => file.normalize(a) === file.normalize(b) + + createEffect(() => { + const p = path() + if (!p) return + const unsub = sdk().event.on("lsp.diagnostics", (e) => { + const props = e.properties + if (!samePath(props.path, p)) return + const list = (props.diagnostics ?? []) as LspDiagnostic[] + for (const cb of diagnosticsSubs) cb(list) + }) + onCleanup(unsub) + }) + + const buildLspOptions = (p: string): LspExtensionsOptions => { + const client = () => sdk().client.lsp + return { + path: p, + bumpVersion: () => ++bufferVersion, + lsp: { + buffer: (input) => client().buffer(input).then((r) => r.data), + bufferClose: (input) => client().bufferClose(input).then((r) => r.data), + completion: (input) => client().completion(input).then((r) => r.data), + hover: (input) => client().hover(input).then((r) => r.data), + definition: (input) => client().definition(input).then((r) => r.data), + diagnostics: (input) => client().diagnostics(input).then((r) => r.data), + }, + onOpenLocation: (target, pos) => { + // Definitions outside the workspace arrive as absolute paths the file API can't read; surface a notice instead of a broken tab. + if (target.startsWith("/") || target.startsWith("file://")) { + showToast({ variant: "default", title: language.t("toast.file.definitionExternal.title") }) + return + } + const normalized = file.normalize(target) + if (!normalized) return + setPendingEditOpen(normalized, pos) + void (async () => { + await tabs().open(file.tab(normalized)) + await file.load(normalized) + })() + }, + subscribeDiagnostics: (_path, cb) => { + diagnosticsSubs.add(cb) + return () => diagnosticsSubs.delete(cb) + }, + } + } + + const editorExtensions = createMemo(() => { + const p = path() + if (!p) return [] + return lspExtensions(buildLspOptions(p)) + }) + + const toggleEditMode = async () => { + if (!editMode()) { + enterEditMode() + return + } + if (!(await guardLeave())) return + setEditMode(false) + } + + createEffect(() => { + if (activeFileTab() !== props.tab) return + registerActiveEditor({ + tab: props.tab, + editing: editMode, + dirty: isDirty, + save, + guard: guardLeave, + }) + }) + onCleanup(() => clearActiveEditor(props.tab)) + + createEffect( + on( + contents, + (next, prev) => { + if (prev === undefined) return + if (next === prev) return + if (!editMode() || !isDirty()) return + showToast({ variant: "default", title: language.t("toast.file.changedExternally.title") }) + }, + { defer: true }, + ), + ) const selectedLines = createMemo(() => { const p = path() if (!p) return null @@ -440,17 +637,58 @@ export function FileTabContent(props: { tab: string }) {
) + const EditViewToggle = () => ( +
+ void toggleEditMode()} + /> + void toggleEditMode()} + /> +
+ ) + return ( - - - {renderFile(contents())} - -
{language.t("common.loading")}...
-
- {(err) =>
{err()}
}
-
-
+ + + + + +
+ void save()} + extensions={editorExtensions()} + initialSelection={pendingSelection()} + class="h-full" + /> +
+
+ + + {renderFile(contents())} + + + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
) } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 097df2219376..b5a8a17f7789 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -23,6 +23,7 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" +import { guardTab } from "@/pages/session/file-save" import { createOpenSessionFileTab, createSessionTabs, @@ -191,6 +192,30 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } + const closeTabGuarded = async (tab: string) => { + if (!(await guardTab(tab))) return + tabs().close(tab) + } + + // Run the unsaved guard before switching away from a dirty active file tab (switching disposes its editor buffer). + const openTabGuarded = (value: string) => { + const next = normalizeTab(value) + const current = activeFileTab() + if (!current || current === next) { + openTab(value) + return + } + void guardTab(current).then((ok) => { + if (ok) openTab(value) + }) + } + + // Ignore the controlled stale-key feedback during a programmatic tab write (see layout tabsWriting). + const onTabsChange = (value: string) => { + if (layout.tabsWriting()) return + openTabGuarded(value) + } + createEffect(() => { if (!file.ready()) return @@ -253,7 +278,7 @@ export function SessionSidePanel(props: { > - +
{ @@ -300,7 +325,7 @@ export function SessionSidePanel(props: { - {(tab) => } + {(tab) => void closeTabGuarded(t)} />}
openTab(file.tab(node.path))} + onFileClick={(node) => openTabGuarded(file.tab(node.path))} /> diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index e3f0c517df1f..77a6baf4c8d7 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -15,6 +15,7 @@ import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" +import { activeEditor, guardTab } from "@/pages/session/file-save" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" @@ -219,12 +220,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }) } - const closeTab = () => { + const closeTab = async () => { const tab = closableTab() if (!tab) return + if (!(await guardTab(tab))) return tabs().close(tab) } + const saveActiveFile = () => { + const editor = activeEditor() + if (!editor || !editor.editing() || !editor.dirty()) return + void editor.save() + } + const addSelection = () => { const tab = activeFileTab() if (!tab) return @@ -428,6 +436,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "open", onSelect: openFile, }), + fileCommand({ + id: "file.save", + title: language.t("file.save.command"), + keybind: "mod+s", + hidden: true, + onSelect: saveActiveFile, + }), tab && fileCommand({ id: "tab.close", diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 0949ec1be086..bb5098ee64f5 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -113,9 +113,7 @@ function configurationValue(settings: unknown, section?: string) { return result ?? null } -// TypeScript's built-in LSP pushes diagnostics aggressively on first open. -// We seed the push cache on the very first publish so waitForFreshPush can -// resolve immediately instead of waiting for a second debounced push. +// Seed the push cache on the first publish so waitForFreshPush resolves without waiting for a second debounced push. function shouldSeedDiagnosticsOnFirstPush(serverID: string) { return serverID === "typescript" } @@ -126,6 +124,8 @@ export async function create(input: { root: string directory: string instance: InstanceContext + // Fired debounced per path (latest-wins) with the deduped push+pull merge; empty means cleared. `path` is absolute. + onDiagnostics?: (input: { path: string; diagnostics: Diagnostic[] }) => void }) { const instance = input.instance @@ -144,12 +144,28 @@ export async function create(input: { const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>() const mergedDiagnostics = (filePath: string) => dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) + // Coalesce rapid push/pull updates per path into one emit per DIAGNOSTICS_DEBOUNCE_MS window so a typing burst yields one event. + const diagnosticEmitTimers = new Map>() + const scheduleDiagnosticsEmit = (filePath: string) => { + if (!input.onDiagnostics) return + const existing = diagnosticEmitTimers.get(filePath) + if (existing) clearTimeout(existing) + diagnosticEmitTimers.set( + filePath, + setTimeout(() => { + diagnosticEmitTimers.delete(filePath) + input.onDiagnostics?.({ path: filePath, diagnostics: mergedDiagnostics(filePath) }) + }, DIAGNOSTICS_DEBOUNCE_MS), + ) + } const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { pushDiagnostics.set(filePath, next) for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID }) + scheduleDiagnosticsEmit(filePath) } const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { pullDiagnostics.set(filePath, next) + scheduleDiagnosticsEmit(filePath) } const emitRegistrationChange = () => { for (const listener of [...registrationListeners]) listener() @@ -246,6 +262,16 @@ export async function create(input: { publishDiagnostics: { versionSupport: false, }, + completion: { + dynamicRegistration: true, + completionItem: { + snippetSupport: true, + insertReplaceSupport: true, + documentationFormat: ["markdown", "plaintext"], + labelDetailsSupport: true, + }, + contextSupport: true, + }, }, }, }), @@ -551,16 +577,21 @@ export async function create(input: { return connection }, notify: { - async open(request: { path: string }) { + async open(request: { path: string; buffer?: { text: string; version: number } }) { request.path = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) - const text = await Filesystem.readText(request.path) + // An editor buffer overlays disk (caller owns the version) while edit mode is active; on didClose the server reverts to disk-backed analysis. Without a buffer we keep the disk-read + auto-increment path. + const text = request.buffer ? request.buffer.text : await Filesystem.readText(request.path) const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" const document = files[request.path] if (document !== undefined) { + // Editor owns versions; ignore stale/duplicate (<= stored) to avoid didChange rejections from strict servers. + if (request.buffer && request.buffer.version <= document.version) { + return document.version + } // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only // re-emit diagnostics when the content actually changes, so clearing // here would lose errors for no-op touchFile calls. Let the server's @@ -574,7 +605,7 @@ export async function create(input: { ], }) - const next = document.version + 1 + const next = request.buffer ? request.buffer.version : document.version + 1 files[request.path] = { version: next, text } await connection.sendNotification("textDocument/didChange", { textDocument: { @@ -608,16 +639,31 @@ export async function create(input: { pushDiagnostics.delete(request.path) pullDiagnostics.delete(request.path) + const initialVersion = request.buffer ? request.buffer.version : 0 await connection.sendNotification("textDocument/didOpen", { textDocument: { uri: pathToFileURL(request.path).href, languageId, - version: 0, + version: initialVersion, text, }, }) - files[request.path] = { version: 0, text } - return 0 + files[request.path] = { version: initialVersion, text } + return initialVersion + }, + async close(request: { path: string }) { + request.path = Filesystem.normalizePath( + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), + ) + if (files[request.path] === undefined) return + await connection.sendNotification("textDocument/didClose", { + textDocument: { + uri: pathToFileURL(request.path).href, + }, + }) + delete files[request.path] + pushDiagnostics.delete(request.path) + pullDiagnostics.delete(request.path) }, }, get diagnostics() { @@ -638,6 +684,8 @@ export async function create(input: { await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) }, async shutdown() { + for (const timer of diagnosticEmitTimers.values()) clearTimeout(timer) + diagnosticEmitTimers.clear() connection.end() connection.dispose() await Process.stop(input.server.process) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 0e7cf82d9d1e..f1714b21c95f 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -11,14 +11,10 @@ import { Process } from "@/util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { containsPath } from "@/project/instance-context" +import { containsPath, type InstanceContext } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" -export const Event = { - Updated: EventV2.define({ type: "lsp.updated", schema: {} }), -} - const Position = Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt, @@ -30,6 +26,23 @@ export const Range = Schema.Struct({ }).annotate({ identifier: "Range" }) export type Range = typeof Range.Type +export const DiagnosticOut = Schema.Struct({ + range: Range, + severity: Schema.optional(Schema.Number), + message: Schema.String, + source: Schema.optional(Schema.String), + code: Schema.optional(Schema.Union([Schema.String, Schema.Number])), +}).annotate({ identifier: "Diagnostic" }) +export type DiagnosticOut = typeof DiagnosticOut.Type + +export const Event = { + Updated: EventV2.define({ type: "lsp.updated", schema: {} }), + Diagnostics: EventV2.define({ + type: "lsp.diagnostics", + schema: { path: Schema.String, diagnostics: Schema.Array(DiagnosticOut) }, + }), +} + export const Symbol = Schema.Struct({ name: Schema.String, kind: NonNegativeInt, @@ -123,6 +136,8 @@ export interface Interface { readonly status: () => Effect.Effect readonly hasClients: (file: string) => Effect.Effect readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect + readonly syncBuffer: (input: { file: string; text: string; version: number }) => Effect.Effect + readonly closeBuffer: (file: string) => Effect.Effect readonly diagnostics: () => Effect.Effect> readonly hover: (input: LocInput) => Effect.Effect readonly definition: (input: LocInput) => Effect.Effect @@ -130,6 +145,10 @@ export interface Interface { readonly implementation: (input: LocInput) => Effect.Effect readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]> readonly workspaceSymbol: (query: string) => Effect.Effect + readonly completion: ( + input: LocInput, + context?: { triggerKind: number; triggerCharacter?: string }, + ) => Effect.Effect readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect readonly incomingCalls: (input: LocInput) => Effect.Effect readonly outgoingCalls: (input: LocInput) => Effect.Effect @@ -207,9 +226,29 @@ export const layer = Layer.effect( }), ) + // Capture the instance-fiber runtime so the async callback publishes with the correct routed location, and convert the absolute path to workspace-relative for the event. + const publishDiagnostics = + (ctx: InstanceContext, context: Context.Context) => + (input: { path: string; diagnostics: LSPClient.Diagnostic[] }) => { + const relative = path.relative(ctx.directory, input.path) + Effect.runForkWith(context)( + events.publish(Event.Diagnostics, { + path: relative, + diagnostics: input.diagnostics.map((d) => ({ + range: d.range, + severity: d.severity, + message: d.message, + source: d.source, + code: typeof d.code === "string" || typeof d.code === "number" ? d.code : undefined, + })), + }), + ) + } + const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context if (!containsPath(file, ctx)) return [] as LSPClient.Info[] + const instanceContext = (yield* Effect.context()) as Context.Context const s = yield* InstanceState.get(state) const clients = yield* Effect.promise(async () => { const extension = path.parse(file).ext || file @@ -235,6 +274,7 @@ export const layer = Layer.effect( root, directory: ctx.directory, instance: ctx, + onDiagnostics: publishDiagnostics(ctx, instanceContext), }).catch(async () => { s.broken.add(key) await Process.stop(handle.process) @@ -363,6 +403,24 @@ export const layer = Layer.effect( ) }) + const syncBuffer = Effect.fn("LSP.syncBuffer")(function* (input: { file: string; text: string; version: number }) { + const clients = yield* getClients(input.file) + yield* Effect.promise(() => + Promise.all( + clients.map((client) => + client.notify.open({ path: input.file, buffer: { text: input.text, version: input.version } }), + ), + ).catch(() => {}), + ) + }) + + const closeBuffer = Effect.fn("LSP.closeBuffer")(function* (file: string) { + const clients = yield* getClients(file) + yield* Effect.promise(() => + Promise.all(clients.map((client) => client.notify.close({ path: file }))).catch(() => {}), + ) + }) + const diagnostics = Effect.fn("LSP.diagnostics")(function* () { const results: Record = {} const all = yield* runAll(async (client) => client.diagnostics) @@ -442,6 +500,21 @@ export const layer = Layer.effect( return results.flat() }) + const completion = Effect.fn("LSP.completion")(function* ( + input: LocInput, + context?: { triggerKind: number; triggerCharacter?: string }, + ) { + return yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/completion", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + context: context ?? { triggerKind: 1 /* Invoked */ }, + }) + .catch(() => null), + ) + }) + const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { const results = yield* run(input.file, (client) => client.connection @@ -484,6 +557,8 @@ export const layer = Layer.effect( status, hasClients, touchFile, + syncBuffer, + closeBuffer, diagnostics, hover, definition, @@ -491,6 +566,7 @@ export const layer = Layer.effect( implementation, documentSymbol, workspaceSymbol, + completion, prepareCallHierarchy, incomingCalls, outgoingCalls, diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 60c410408434..591c01d74d58 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -10,6 +10,7 @@ import { EventApi } from "./groups/event" import { ExperimentalApi } from "./groups/experimental" import { FileApi } from "./groups/file" import { InstanceApi } from "./groups/instance" +import { LspFeatureApi } from "./groups/lsp" import { McpApi } from "./groups/mcp" import { PermissionApi } from "./groups/permission" import { ProjectApi } from "./groups/project" @@ -53,6 +54,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ExperimentalApi) .addHttpApi(FileApi) .addHttpApi(InstanceApi) + .addHttpApi(LspFeatureApi) .addHttpApi(McpApi) .addHttpApi(ProjectApi) .addHttpApi(ProjectCopyApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index 389bf9192510..a9cbed5303e4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -99,8 +99,21 @@ export const FilePaths = { list: "/file", content: "/file/content", status: "/file/status", + write: "/file/write", } as const +export const FileWritePayload = Schema.Struct({ + content: Schema.String, + expectedSha: Schema.optional(Schema.String), +}) + +export const FileWriteResult = Schema.Struct({ + path: Schema.String, + written: Schema.Boolean, + conflict: Schema.optional(Schema.Boolean), + sha: Schema.optional(Schema.String), +}).annotate({ identifier: "FileWriteResult" }) + export const FileApi = HttpApi.make("file") .add( HttpApiGroup.make("file") @@ -165,6 +178,18 @@ export const FileApi = HttpApi.make("file") description: "Get the git status of all files in the project.", }), ), + HttpApiEndpoint.put("write", FilePaths.write, { + query: FileQuery, + payload: FileWritePayload, + success: described(FileWriteResult, "Write result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.write", + summary: "Write file", + description: + "Write content to a file in the project, with optional conflict detection.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts new file mode 100644 index 000000000000..b1d6217e60dd --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts @@ -0,0 +1,146 @@ +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { LSP } from "@/lsp/lsp" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" +import { FileQuery } from "./file" +import { described } from "./metadata" + +export const LspFeaturePaths = { + hover: "/lsp/hover", + definition: "/lsp/definition", + references: "/lsp/references", + completion: "/lsp/completion", + diagnostics: "/lsp/diagnostics", + buffer: "/lsp/buffer", + bufferClose: "/lsp/buffer/close", +} as const + +export const LocPayload = Schema.Struct({ + path: Schema.String, + line: NonNegativeInt, + character: NonNegativeInt, + triggerKind: Schema.optional(Schema.Number), + triggerCharacter: Schema.optional(Schema.String), +}) +export type LocPayload = typeof LocPayload.Type + +// `version` is editor-owned and must increase monotonically; stale versions are ignored by the LSP client. +export const BufferPayload = Schema.Struct({ + path: Schema.String, + text: Schema.String, + version: NonNegativeInt, +}) +export type BufferPayload = typeof BufferPayload.Type + +export const BufferClosePayload = Schema.Struct({ + path: Schema.String, +}) +export type BufferClosePayload = typeof BufferClosePayload.Type + +const LspResult = Schema.Unknown + +export const DiagnosticOut = LSP.DiagnosticOut +export type DiagnosticOut = typeof LSP.DiagnosticOut.Type + +export const LspFeatureApi = HttpApi.make("lsp-features") + .add( + HttpApiGroup.make("lsp-features") + .add( + HttpApiEndpoint.post("hover", LspFeaturePaths.hover, { + query: WorkspaceRoutingQuery, + payload: LocPayload, + success: described(LspResult, "Hover result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.hover", + summary: "LSP hover", + description: "Get hover information for a position in a file via LSP.", + }), + ), + HttpApiEndpoint.post("definition", LspFeaturePaths.definition, { + query: WorkspaceRoutingQuery, + payload: LocPayload, + success: described(LspResult, "Definition locations"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.definition", + summary: "LSP definition", + description: "Find the definition of the symbol at a position in a file via LSP.", + }), + ), + HttpApiEndpoint.post("references", LspFeaturePaths.references, { + query: WorkspaceRoutingQuery, + payload: LocPayload, + success: described(LspResult, "Reference locations"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.references", + summary: "LSP references", + description: "Find references to the symbol at a position in a file via LSP.", + }), + ), + HttpApiEndpoint.post("completion", LspFeaturePaths.completion, { + query: WorkspaceRoutingQuery, + payload: LocPayload, + success: described(LspResult, "Completion result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.completion", + summary: "LSP completion", + description: "Get completion suggestions for a position in a file via LSP.", + }), + ), + HttpApiEndpoint.get("diagnostics", LspFeaturePaths.diagnostics, { + query: FileQuery, + success: described(Schema.Array(DiagnosticOut), "Diagnostics for the file"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.diagnostics", + summary: "LSP diagnostics", + description: "Get diagnostics for a single file via LSP.", + }), + ), + HttpApiEndpoint.put("buffer", LspFeaturePaths.buffer, { + query: WorkspaceRoutingQuery, + payload: BufferPayload, + success: described(Schema.Boolean, "Buffer synced"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.buffer", + summary: "LSP buffer sync", + description: + "Push the editor's in-memory buffer (text + monotonic version) so LSP features reflect unsaved edits.", + }), + ), + HttpApiEndpoint.post("bufferClose", LspFeaturePaths.bufferClose, { + query: WorkspaceRoutingQuery, + payload: BufferClosePayload, + success: described(Schema.Boolean, "Buffer closed"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.bufferClose", + summary: "LSP buffer close", + description: "Close the editor buffer so the LSP server reverts to disk-backed analysis.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "lsp-features", + description: "Experimental HttpApi LSP feature routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457d4..efb2aecf2658 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -3,9 +3,11 @@ import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" +import { FileMutation } from "@opencode-ai/core/file-mutation" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Effect, Layer, Option } from "effect" +import { createHash } from "node:crypto" import ignore from "ignore" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -128,6 +130,58 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return [] }) + const write = Effect.fn("FileHttpApi.write")(function* (ctx: { + query: { path: string } + payload: { content: string; expectedSha?: string } + }) { + const directory = (yield* InstanceState.context).directory + const file = path.resolve(directory, ctx.query.path) + if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location")) + + const fileExists = yield* FSUtil.Service.use((fs) => fs.existsSafe(file)) + + if (fileExists) { + const item = yield* filesystem( + FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })), + ) + const text = item.content.includes(0) + ? Option.none() + : yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(item.content)).pipe(Effect.option) + if (Option.isNone(text)) return yield* Effect.die(new Error("Cannot write to binary file")) + } + + const target = { + canonical: file, + resource: path.relative(directory, file), + } + + if (ctx.payload.expectedSha !== undefined) { + // A sha mismatch (or missing file) is returned as a normal result, not an Effect failure, so the editor can surface a conflict. + if (!fileExists) { + return { path: ctx.query.path, written: false as const, conflict: true as const } + } + const currentBytes = yield* FSUtil.Service.use((fs) => fs.readFile(file)).pipe(Effect.orDie) + const currentSha = createHash("sha256").update(currentBytes).digest("hex") + if (currentSha !== ctx.payload.expectedSha) { + return { path: ctx.query.path, written: false as const, conflict: true as const } + } + yield* filesystem( + FileMutation.Service.use((mut) => + mut.writeIfUnchanged({ target, content: ctx.payload.content, expected: currentBytes }), + ), + ).pipe(Effect.orDie) + } else { + yield* filesystem( + FileMutation.Service.use((mut) => mut.writeTextPreservingBom({ target, content: ctx.payload.content })), + ).pipe(Effect.orDie) + } + + const newBytes = yield* FSUtil.Service.use((fs) => fs.readFile(file)).pipe(Effect.orDie) + const newSha = createHash("sha256").update(newBytes).digest("hex") + + return { path: ctx.query.path, written: true as const, sha: newSha } + }) + return handlers .handle("findText", findText) .handle("findFile", findFile) @@ -135,5 +189,6 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("list", list) .handle("content", content) .handle("status", status) + .handle("write", write) }), ).pipe(Layer.provide(LocationServiceMap.layer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts new file mode 100644 index 000000000000..7c7e6c7db19a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts @@ -0,0 +1,112 @@ +import * as InstanceState from "@/effect/instance-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { LSP } from "@/lsp/lsp" +import { Effect } from "effect" +import { fileURLToPath } from "node:url" +import path from "path" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import type { BufferClosePayload, BufferPayload, DiagnosticOut, LocPayload } from "../groups/lsp" + +// Rewrite absolute `file://` location URIs to workspace-relative paths; targets outside the workspace keep their absolute URI. +function rewriteLocations(directory: string, results: unknown[]): unknown[] { + return results.map((entry) => { + if (!entry || typeof entry !== "object") return entry + const loc = entry as Record + const uri = loc["uri"] ?? loc["targetUri"] + if (typeof uri !== "string" || !uri.startsWith("file://")) return entry + let absolute: string + try { + absolute = fileURLToPath(uri) + } catch { + return entry + } + if (!FSUtil.contains(directory, absolute)) return entry + const relative = path.relative(directory, absolute) + const next: Record = { ...loc } + if ("uri" in loc) next["uri"] = relative + if ("targetUri" in loc) next["targetUri"] = relative + return next + }) +} + +export const lspFeatureHandlers = HttpApiBuilder.group(InstanceHttpApi, "lsp-features", (handlers) => + Effect.gen(function* () { + const lsp = yield* LSP.Service + + const resolve = Effect.fnUntraced(function* (relative: string) { + const directory = (yield* InstanceState.context).directory + const file = path.resolve(directory, relative) + if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location")) + return { directory, file } + }) + + const hover = Effect.fn("LspHttpApi.hover")(function* (ctx: { payload: LocPayload }) { + const { file } = yield* resolve(ctx.payload.path) + return yield* lsp.hover({ file, line: ctx.payload.line, character: ctx.payload.character }).pipe(Effect.orDie) + }) + + const definition = Effect.fn("LspHttpApi.definition")(function* (ctx: { payload: LocPayload }) { + const { directory, file } = yield* resolve(ctx.payload.path) + const results = yield* lsp + .definition({ file, line: ctx.payload.line, character: ctx.payload.character }) + .pipe(Effect.orDie) + return rewriteLocations(directory, results) + }) + + const references = Effect.fn("LspHttpApi.references")(function* (ctx: { payload: LocPayload }) { + const { directory, file } = yield* resolve(ctx.payload.path) + const results = yield* lsp + .references({ file, line: ctx.payload.line, character: ctx.payload.character }) + .pipe(Effect.orDie) + return rewriteLocations(directory, results) + }) + + const completion = Effect.fn("LspHttpApi.completion")(function* (ctx: { payload: LocPayload }) { + const { file } = yield* resolve(ctx.payload.path) + const context = + ctx.payload.triggerKind != null + ? { triggerKind: ctx.payload.triggerKind, triggerCharacter: ctx.payload.triggerCharacter } + : undefined + return yield* lsp + .completion({ file, line: ctx.payload.line, character: ctx.payload.character }, context) + .pipe(Effect.orDie) + }) + + const diagnostics = Effect.fn("LspHttpApi.diagnostics")(function* (ctx: { query: { path: string } }) { + const { file } = yield* resolve(ctx.query.path) + const all = yield* lsp.diagnostics().pipe(Effect.orDie) + const entries = all[file] ?? [] + return entries.map( + (d): DiagnosticOut => ({ + range: d.range, + severity: d.severity, + message: d.message, + source: d.source, + code: typeof d.code === "string" || typeof d.code === "number" ? d.code : undefined, + }), + ) + }) + + const buffer = Effect.fn("LspHttpApi.buffer")(function* (ctx: { payload: BufferPayload }) { + const { file } = yield* resolve(ctx.payload.path) + yield* lsp.syncBuffer({ file, text: ctx.payload.text, version: ctx.payload.version }).pipe(Effect.orDie) + return true + }) + + const bufferClose = Effect.fn("LspHttpApi.bufferClose")(function* (ctx: { payload: BufferClosePayload }) { + const { file } = yield* resolve(ctx.payload.path) + yield* lsp.closeBuffer(file).pipe(Effect.orDie) + return true + }) + + return handlers + .handle("hover", hover) + .handle("definition", definition) + .handle("references", references) + .handle("completion", completion) + .handle("diagnostics", diagnostics) + .handle("buffer", buffer) + .handle("bufferClose", bufferClose) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 09e25de8cb89..bc5663316d74 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,6 +84,7 @@ import { experimentalHandlers } from "./handlers/experimental" import { fileHandlers } from "./handlers/file" import { globalHandlers } from "./handlers/global" import { instanceHandlers } from "./handlers/instance" +import { lspFeatureHandlers } from "./handlers/lsp" import { mcpHandlers } from "./handlers/mcp" import { permissionHandlers } from "./handlers/permission" import { projectHandlers } from "./handlers/project" @@ -148,6 +149,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( experimentalHandlers, fileHandlers, instanceHandlers, + lspFeatureHandlers, mcpHandlers, projectHandlers, projectCopyHandlers, diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index e6818009e1f6..54654d9bd026 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -7,6 +7,12 @@ let initializeParams = null let diagnosticRequestCount = 0 let registeredCapability = false const pendingClientRequests = new Map() +let completionResponse = null +let lastCompletionParams = null +// Records textDocument/didOpen, didChange, didClose notifications in order so +// buffer-sync tests can assert exactly what the client sent. +const documentNotifications = [] + let pullConfig = { delayMs: 0, registerOn: undefined, @@ -113,13 +119,12 @@ function handle(raw) { if (data.method === "initialize") { initializeParams = data.params - sendResponse(data.id, { - capabilities: { - textDocumentSync: { - change: 2, - }, + const capabilities = { + textDocumentSync: { + change: 2, }, - }) + } + sendResponse(data.id, { capabilities }) return } @@ -139,16 +144,28 @@ function handle(raw) { } if (data.method === "textDocument/didOpen") { + documentNotifications.push({ method: "didOpen", params: data.params }) maybeRegister("didOpen") return } if (data.method === "textDocument/didChange") { lastChange = data.params + documentNotifications.push({ method: "didChange", params: data.params }) maybeRegister("didChange") return } + if (data.method === "textDocument/didClose") { + documentNotifications.push({ method: "didClose", params: data.params }) + return + } + + if (data.method === "test/get-document-notifications") { + sendResponse(data.id, documentNotifications) + return + } + if (data.method === "test/trigger") { const method = data.params && data.params.method if (method === "client/registerCapability") { @@ -236,6 +253,23 @@ function handle(raw) { return } + if (data.method === "test/set-completion-response") { + completionResponse = data.params?.response ?? null + sendResponse(data.id, null) + return + } + + if (data.method === "test/get-last-completion-params") { + sendResponse(data.id, lastCompletionParams) + return + } + + if (data.method === "textDocument/completion") { + lastCompletionParams = data.params + sendResponse(data.id, completionResponse) + return + } + if (typeof data.id !== "undefined") { sendResponse(data.id, null) } diff --git a/packages/opencode/test/lsp/buffer-sync.test.ts b/packages/opencode/test/lsp/buffer-sync.test.ts new file mode 100644 index 000000000000..febdf647a0ec --- /dev/null +++ b/packages/opencode/test/lsp/buffer-sync.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir, withTestInstance } from "../fixture/fixture" +import { LSPClient } from "@/lsp/client" +import * as LSPServer from "@/lsp/server" + +function spawnFakeServer() { + const { spawn } = require("child_process") + const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + return { + process: spawn(process.execPath, [serverPath], { + stdio: "pipe", + }), + } +} + +async function getNotifications(client: any) { + return (await client.connection.sendRequest("test/get-document-notifications", {})) as Array<{ + method: string + params: any + }> +} + +describe("LSP buffer sync", () => { + test("first syncBuffer sends didOpen with buffer text + version", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.notify.open({ path: file, buffer: { text: "const y = 2\n", version: 3 } }) + + const notifications = await getNotifications(client) + const opens = notifications.filter((n) => n.method === "didOpen") + expect(opens).toHaveLength(1) + expect(opens[0].params.textDocument.uri).toBe(pathToFileURL(file).href) + expect(opens[0].params.textDocument.version).toBe(3) + expect(opens[0].params.textDocument.text).toBe("const y = 2\n") + + await client.shutdown() + }, + }) + }) + + test("second syncBuffer sends incremental didChange with the new version", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.notify.open({ path: file, buffer: { text: "const y = 2\n", version: 1 } }) + await client.notify.open({ path: file, buffer: { text: "const y = 22\n", version: 2 } }) + + const notifications = await getNotifications(client) + const changes = notifications.filter((n) => n.method === "didChange") + expect(changes).toHaveLength(1) + expect(changes[0].params.textDocument.version).toBe(2) + // Incremental change: contentChanges carries a range + the new text. + expect(changes[0].params.contentChanges[0].range).toBeDefined() + expect(changes[0].params.contentChanges[0].text).toBe("const y = 22\n") + + await client.shutdown() + }, + }) + }) + + test("stale or duplicate version is ignored (no notification sent)", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.notify.open({ path: file, buffer: { text: "v5\n", version: 5 } }) + // Stale (lower) and duplicate (equal) versions must be ignored. + const stale = await client.notify.open({ path: file, buffer: { text: "v3\n", version: 3 } }) + const dup = await client.notify.open({ path: file, buffer: { text: "v5again\n", version: 5 } }) + + expect(stale).toBe(5) + expect(dup).toBe(5) + + const notifications = await getNotifications(client) + expect(notifications.filter((n) => n.method === "didOpen")).toHaveLength(1) + expect(notifications.filter((n) => n.method === "didChange")).toHaveLength(0) + + await client.shutdown() + }, + }) + }) + + test("closeBuffer sends didClose and clears state", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.notify.open({ path: file, buffer: { text: "const y = 2\n", version: 1 } }) + await client.notify.close({ path: file }) + + const notifications = await getNotifications(client) + const closes = notifications.filter((n) => n.method === "didClose") + expect(closes).toHaveLength(1) + expect(closes[0].params.textDocument.uri).toBe(pathToFileURL(file).href) + + // State cleared: reopening with a low version sends didOpen again. + await client.notify.open({ path: file, buffer: { text: "again\n", version: 0 } }) + const after = await getNotifications(client) + expect(after.filter((n) => n.method === "didOpen")).toHaveLength(2) + + await client.shutdown() + }, + }) + }) + + test("disk-based notify.open (no buffer) still reads disk + auto-increments", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "from-disk-1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + const v0 = await client.notify.open({ path: file }) + await Bun.write(file, "from-disk-2\n") + const v1 = await client.notify.open({ path: file }) + + expect(v0).toBe(0) + expect(v1).toBe(1) + + const notifications = await getNotifications(client) + const opens = notifications.filter((n) => n.method === "didOpen") + const changes = notifications.filter((n) => n.method === "didChange") + expect(opens).toHaveLength(1) + expect(opens[0].params.textDocument.text).toBe("from-disk-1\n") + expect(changes).toHaveLength(1) + expect(changes[0].params.textDocument.version).toBe(1) + expect(changes[0].params.contentChanges[0].text).toBe("from-disk-2\n") + + await client.shutdown() + }, + }) + }) +}) diff --git a/packages/opencode/test/lsp/completion.test.ts b/packages/opencode/test/lsp/completion.test.ts new file mode 100644 index 000000000000..3a673905498c --- /dev/null +++ b/packages/opencode/test/lsp/completion.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir, withTestInstance } from "../fixture/fixture" +import { LSPClient } from "@/lsp/client" +import * as LSPServer from "@/lsp/server" + +function spawnFakeServer() { + const { spawn } = require("child_process") + const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + return { + process: spawn(process.execPath, [serverPath], { + stdio: "pipe", + }), + } +} + +describe("LSP completion", () => { + test("initialize payload includes the completion capability block", async () => { + const handle = spawnFakeServer() as any + + const client = await withTestInstance({ + directory: process.cwd(), + fn: (ctx) => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + instance: ctx, + }), + }) + + const params = await client.connection.sendRequest("test/get-initialize-params", {}) + + expect(params.capabilities.textDocument.completion).toBeDefined() + expect(params.capabilities.textDocument.completion.dynamicRegistration).toBe(true) + expect(params.capabilities.textDocument.completion.contextSupport).toBe(true) + expect(params.capabilities.textDocument.completion.completionItem.snippetSupport).toBe(true) + expect(params.capabilities.textDocument.completion.completionItem.insertReplaceSupport).toBe(true) + expect(params.capabilities.textDocument.completion.completionItem.documentationFormat).toEqual([ + "markdown", + "plaintext", + ]) + expect(params.capabilities.textDocument.completion.completionItem.labelDetailsSupport).toBe(true) + + await client.shutdown() + }) + + test("sendRequest textDocument/completion with correct uri, position, and context", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.notify.open({ path: file }) + await client.connection.sendRequest("textDocument/completion", { + textDocument: { uri: pathToFileURL(file).href }, + position: { line: 0, character: 6 }, + context: { triggerKind: 1 }, + }) + + const params = await client.connection.sendRequest("test/get-last-completion-params", {}) + expect(params.textDocument.uri).toBe(pathToFileURL(file).href) + expect(params.position.line).toBe(0) + expect(params.position.character).toBe(6) + expect(params.context.triggerKind).toBe(1) + + await client.shutdown() + }, + }) + }) + + test("returns items for a CompletionItem[] response", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + const completionItems = [ + { label: "console", kind: 6 }, + { label: "const", kind: 14 }, + ] + await client.connection.sendRequest("test/set-completion-response", { response: completionItems }) + await client.notify.open({ path: file }) + + const result = await client.connection.sendRequest("textDocument/completion", { + textDocument: { uri: pathToFileURL(file).href }, + position: { line: 0, character: 6 }, + context: { triggerKind: 1 }, + }) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(2) + expect(result[0].label).toBe("console") + expect(result[1].label).toBe("const") + + await client.shutdown() + }, + }) + }) + + test("returns items for a CompletionList response", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + const completionList = { + isIncomplete: false, + items: [ + { label: "Array", kind: 7 }, + { label: "Boolean", kind: 7 }, + ], + } + await client.connection.sendRequest("test/set-completion-response", { response: completionList }) + await client.notify.open({ path: file }) + + const result = await client.connection.sendRequest("textDocument/completion", { + textDocument: { uri: pathToFileURL(file).href }, + position: { line: 0, character: 6 }, + context: { triggerKind: 1 }, + }) + + expect(result).toBeDefined() + expect(result.isIncomplete).toBe(false) + expect(Array.isArray(result.items)).toBe(true) + expect(result.items).toHaveLength(2) + expect(result.items[0].label).toBe("Array") + + await client.shutdown() + }, + }) + }) + + test("swallows a rejected request and returns null", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + // completionResponse is null by default (server returns null for unknown requests) + await client.notify.open({ path: file }) + + let result: any + let threw = false + try { + result = await client.connection.sendRequest("textDocument/completion", { + textDocument: { uri: pathToFileURL(file).href }, + position: { line: 0, character: 6 }, + context: { triggerKind: 1 }, + }) + } catch { + threw = true + } + + expect(threw).toBe(false) + expect(result).toBeNull() + + await client.shutdown() + }, + }) + }) +}) diff --git a/packages/opencode/test/lsp/diagnostics-event.test.ts b/packages/opencode/test/lsp/diagnostics-event.test.ts new file mode 100644 index 000000000000..0882309c2337 --- /dev/null +++ b/packages/opencode/test/lsp/diagnostics-event.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { Deferred, Effect, Layer } from "effect" +import { tmpdir, withTestInstance, TestInstance } from "../fixture/fixture" +import { LSPClient } from "@/lsp/client" +import { LSP } from "@/lsp/lsp" +import * as LSPServer from "@/lsp/server" +import { EventV2Bridge } from "@/event-v2-bridge" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { awaitWithTimeout, testEffect } from "../lib/effect" + +const DEBOUNCE_MS = 150 +const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + +function spawnFakeServer() { + const { spawn } = require("child_process") + return { + process: spawn(process.execPath, [fakeServerPath], { stdio: "pipe" }), + } +} + +function publishDiagnostics(client: any, file: string, diagnostics: any[]) { + return client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + diagnostics, + }) +} + +const sampleDiagnostic = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "boom", + source: "fake", + code: "E001", +} + +describe("LSP diagnostics event (client onDiagnostics)", () => { + test("a single publishDiagnostics emits exactly ONE onDiagnostics after debounce", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const emits: { path: string; diagnostics: any[] }[] = [] + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + onDiagnostics: (e) => emits.push(e), + }) + + await client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } }) + await publishDiagnostics(client, file, [sampleDiagnostic]) + + await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100)) + + expect(emits).toHaveLength(1) + expect(emits[0].path).toBe(file) + expect(emits[0].diagnostics).toHaveLength(1) + expect(emits[0].diagnostics[0].message).toBe("boom") + expect(emits[0].diagnostics[0].code).toBe("E001") + + await client.shutdown() + }, + }) + }) + + test("clearing diagnostics emits an empty array", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const emits: { path: string; diagnostics: any[] }[] = [] + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + onDiagnostics: (e) => emits.push(e), + }) + + await client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } }) + await publishDiagnostics(client, file, [sampleDiagnostic]) + await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100)) + + // Fix the errors -> server publishes an empty set. + await publishDiagnostics(client, file, []) + await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100)) + + expect(emits).toHaveLength(2) + expect(emits[1].path).toBe(file) + expect(emits[1].diagnostics).toEqual([]) + + await client.shutdown() + }, + }) + }) + + test("a burst within the debounce window coalesces to ONE emit", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const emits: { path: string; diagnostics: any[] }[] = [] + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + onDiagnostics: (e) => emits.push(e), + }) + + await client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } }) + + // Fire N rapid publishes well within the 150ms window. + for (let i = 0; i < 5; i++) { + await publishDiagnostics(client, file, [{ ...sampleDiagnostic, message: `boom-${i}` }]) + await new Promise((r) => setTimeout(r, 10)) + } + await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100)) + + expect(emits).toHaveLength(1) + // Last value wins. + expect(emits[0].diagnostics[0].message).toBe("boom-4") + + await client.shutdown() + }, + }) + }) +}) + +const it = testEffect(Layer.mergeAll(EventV2Bridge.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +describe("LSP diagnostics event (lsp.diagnostics over the bus)", () => { + it.instance( + "publishes lsp.diagnostics with a workspace-relative path + DiagnosticOut payload", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const events = yield* EventV2Bridge.Service + const got = yield* Deferred.make<{ path: string; diagnostics: ReadonlyArray }>() + + const unsubscribe = yield* events.listen((event) => { + if (event.type === LSP.Event.Diagnostics.type) { + Deferred.doneUnsafe(got, Effect.succeed(event.data as any)) + } + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) + + const file = path.join(dir, "sample.ts") + yield* Effect.promise(() => Bun.write(file, "const x = 1\n")) + + // Capture the instance-fiber context so the async onDiagnostics callback + // publishes with routed location — mirrors lsp.ts exactly. + const context = yield* Effect.context() + const handle = spawnFakeServer() as any + + const client = yield* Effect.promise(() => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: dir, + directory: dir, + instance: { directory: dir } as any, + onDiagnostics: (e) => { + Effect.runForkWith(context as any)( + events.publish(LSP.Event.Diagnostics, { + path: path.relative(dir, e.path), + diagnostics: e.diagnostics.map((d) => ({ + range: d.range, + severity: d.severity, + message: d.message, + source: d.source, + code: typeof d.code === "string" || typeof d.code === "number" ? d.code : undefined, + })), + }), + ) + }, + }), + ) + + yield* Effect.promise(() => client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } })) + yield* Effect.promise(() => publishDiagnostics(client, file, [sampleDiagnostic])) + + const result = yield* awaitWithTimeout(Deferred.await(got), "lsp.diagnostics event was not published") + expect(result.path).toBe("sample.ts") + expect(result.diagnostics).toHaveLength(1) + expect(result.diagnostics[0].message).toBe("boom") + expect(result.diagnostics[0].code).toBe("E001") + + yield* Effect.promise(() => client.shutdown()) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-file-write.test.ts b/packages/opencode/test/server/httpapi-file-write.test.ts new file mode 100644 index 000000000000..f8ae30c87201 --- /dev/null +++ b/packages/opencode/test/server/httpapi-file-write.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { Context } from "effect" +import * as fs from "fs/promises" +import path from "path" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +const context = Context.empty() as Context.Context + +function request( + route: string, + directory: string, + query: Record, + body: unknown, +) { + const url = new URL(`http://localhost${route}`) + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value) + } + return HttpApiApp.webHandler().handler( + new Request(url, { + method: "PUT", + headers: { + "x-opencode-directory": directory, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + context, + ) +} + +async function sha256(bytes: Uint8Array): Promise { + return createHash("sha256").update(bytes).digest("hex") +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("file.write HttpApi", () => { + test("creates a new file and bytes on disk match", async () => { + await using tmp = await tmpdir({ git: true }) + + const res = await request(FilePaths.write, tmp.path, { path: "new.txt" }, { content: "hello world" }) + + expect(res.status).toBe(200) + const body = await res.json() as { written: boolean; sha: string; path: string } + expect(body.written).toBe(true) + expect(typeof body.sha).toBe("string") + + const diskBytes = await fs.readFile(path.join(tmp.path, "new.txt")) + expect(Buffer.from(diskBytes).toString("utf-8")).toBe("hello world") + }) + + test("overwrites an existing file and returns new sha", async () => { + await using tmp = await tmpdir({ git: true }) + const filePath = path.join(tmp.path, "existing.txt") + await Bun.write(filePath, "old content") + + const res = await request(FilePaths.write, tmp.path, { path: "existing.txt" }, { content: "new content" }) + + expect(res.status).toBe(200) + const body = await res.json() as { written: boolean; sha: string } + expect(body.written).toBe(true) + expect(typeof body.sha).toBe("string") + + const diskBytes = await fs.readFile(filePath) + expect(Buffer.from(diskBytes).toString("utf-8")).toBe("new content") + + const expectedSha = await sha256(new TextEncoder().encode("new content")) + expect(body.sha).toBe(expectedSha) + }) + + test("expectedSha mismatch returns conflict and leaves file unchanged", async () => { + await using tmp = await tmpdir({ git: true }) + const filePath = path.join(tmp.path, "conflict.txt") + await Bun.write(filePath, "original content") + + const stale = "0000000000000000000000000000000000000000000000000000000000000000" + const res = await request( + FilePaths.write, + tmp.path, + { path: "conflict.txt" }, + { content: "new content", expectedSha: stale }, + ) + + expect(res.status).toBe(200) + const body = await res.json() as { written: boolean; conflict: boolean } + expect(body.written).toBe(false) + expect(body.conflict).toBe(true) + + // File must be unchanged on disk + const diskContent = await fs.readFile(filePath, "utf-8") + expect(diskContent).toBe("original content") + }) + + test("expectedSha match writes successfully", async () => { + await using tmp = await tmpdir({ git: true }) + const filePath = path.join(tmp.path, "match.txt") + const originalContent = "original" + await Bun.write(filePath, originalContent) + + const currentBytes = new TextEncoder().encode(originalContent) + const currentSha = await sha256(currentBytes) + + const res = await request( + FilePaths.write, + tmp.path, + { path: "match.txt" }, + { content: "updated", expectedSha: currentSha }, + ) + + expect(res.status).toBe(200) + const body = await res.json() as { written: boolean; sha: string } + expect(body.written).toBe(true) + + const diskContent = await fs.readFile(filePath, "utf-8") + expect(diskContent).toBe("updated") + }) + + test("path escaping the directory is rejected", async () => { + await using tmp = await tmpdir({ git: true }) + + const res = await request( + FilePaths.write, + tmp.path, + { path: "../../etc/passwd" }, + { content: "evil" }, + ) + + // Should result in a server error (500 / defect), not 200 + expect(res.status).toBeGreaterThanOrEqual(500) + }) + + test("writing over a binary file is rejected", async () => { + await using tmp = await tmpdir({ git: true }) + const filePath = path.join(tmp.path, "image.bin") + // Write some null bytes to make a binary file + await fs.writeFile(filePath, Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x02, 0x03])) + + const res = await request(FilePaths.write, tmp.path, { path: "image.bin" }, { content: "text" }) + + expect(res.status).toBeGreaterThanOrEqual(500) + + // File must be unchanged on disk + const diskBytes = await fs.readFile(filePath) + expect(diskBytes).toEqual(Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x02, 0x03])) + }) +}) diff --git a/packages/opencode/test/server/httpapi-lsp.test.ts b/packages/opencode/test/server/httpapi-lsp.test.ts new file mode 100644 index 000000000000..60a22881569e --- /dev/null +++ b/packages/opencode/test/server/httpapi-lsp.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { ConfigProvider, Context, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { LspFeaturePaths } from "../../src/server/routes/instance/httpapi/groups/lsp" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +const context = Context.empty() as Context.Context + +// These tests exercise the real LSP service. The fixtures disable LSP servers +// (config.lsp false / plain .txt files), so every feature method resolves with +// no attached client. That is exactly the "no client for language" path: the +// endpoints must route and return an empty/null result, never a 500. + +function post(route: string, directory: string, body: unknown) { + const url = new URL(`http://localhost${route}`) + return HttpApiApp.webHandler().handler( + new Request(url, { + method: "POST", + headers: { + "x-opencode-directory": directory, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + context, + ) +} + +function get(route: string, directory: string, query: Record) { + const url = new URL(`http://localhost${route}`) + for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v) + return HttpApiApp.webHandler().handler( + new Request(url, { + method: "GET", + headers: { "x-opencode-directory": directory }, + }), + context, + ) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("lsp feature HttpApi", () => { + const loc = { path: "file.ts", line: 0, character: 0 } + + test("hover routes and returns null/empty (no client)", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n") + const res = await post(LspFeaturePaths.hover, tmp.path, loc) + expect(res.status).toBe(200) + const body = await res.json() + // hover returns the array of per-client results; with no clients it is empty + expect(Array.isArray(body) ? body.length : body).toBeFalsy() + }) + + test("definition routes and returns empty array (no client)", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n") + const res = await post(LspFeaturePaths.definition, tmp.path, loc) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test("references routes and returns empty array (no client)", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n") + const res = await post(LspFeaturePaths.references, tmp.path, loc) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test("completion routes and returns null/empty (no client)", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n") + const res = await post(LspFeaturePaths.completion, tmp.path, { + ...loc, + triggerKind: 1, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body) ? body.length : body).toBeFalsy() + }) + + test("diagnostics routes and returns empty array for the requested file", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n") + const res = await get(LspFeaturePaths.diagnostics, tmp.path, { path: "file.ts" }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test("path escaping the directory is rejected", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + const res = await post(LspFeaturePaths.hover, tmp.path, { + path: "../../etc/passwd", + line: 0, + character: 0, + }) + expect(res.status).toBeGreaterThanOrEqual(500) + }) +}) + +function authApp(password: string) { + const handler = HttpRouter.toWebHandler( + HttpApiApp.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password }), + ), + ), + ), + { disableLogger: true }, + ).handler + return (req: Request) => handler(req, HttpApiApp.context) +} + +describe("lsp feature HttpApi authorization", () => { + test("unauthorized request is rejected", async () => { + await using tmp = await tmpdir({ git: true, config: { lsp: false } }) + const fetch = authApp("secret") + const url = new URL(`http://localhost${LspFeaturePaths.hover}`) + const res = await fetch( + new Request(url, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ path: "file.ts", line: 0, character: 0 }), + }), + ) + expect(res.status).toBe(401) + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 08828018a4a3..8b2618978774 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -138,6 +138,8 @@ const lsp = Layer.succeed( status: () => Effect.succeed([]), hasClients: () => Effect.succeed(false), touchFile: () => Effect.void, + syncBuffer: () => Effect.void, + closeBuffer: () => Effect.void, diagnostics: () => Effect.succeed({}), hover: () => Effect.succeed(undefined), definition: () => Effect.succeed([]), @@ -148,6 +150,7 @@ const lsp = Layer.succeed( prepareCallHierarchy: () => Effect.succeed([]), incomingCalls: () => Effect.succeed([]), outgoingCalls: () => Effect.succeed([]), + completion: () => Effect.succeed(null), }), ) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e12518..1922ed13c036 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -62,6 +62,8 @@ const lsp = Layer.succeed( status: () => Effect.succeed([]), hasClients: () => Effect.succeed(false), touchFile: () => Effect.void, + syncBuffer: () => Effect.void, + closeBuffer: () => Effect.void, diagnostics: () => Effect.succeed({}), hover: () => Effect.succeed(undefined), definition: () => Effect.succeed([]), @@ -72,6 +74,7 @@ const lsp = Layer.succeed( prepareCallHierarchy: () => Effect.succeed([]), incomingCalls: () => Effect.succeed([]), outgoingCalls: () => Effect.succeed([]), + completion: () => Effect.succeed(null), }), ) diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index ddcf14e9ae39..e64d926330c7 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -38,6 +38,8 @@ const lsp = Layer.succeed( status: () => Effect.succeed([]), hasClients: () => Effect.succeed(true), touchFile: () => Effect.void, + syncBuffer: () => Effect.void, + closeBuffer: () => Effect.void, diagnostics: () => Effect.succeed({}), hover: () => Effect.succeed([]), definition: () => Effect.succeed([]), @@ -52,6 +54,7 @@ const lsp = Layer.succeed( prepareCallHierarchy: () => Effect.succeed([]), incomingCalls: () => Effect.succeed([]), outgoingCalls: () => Effect.succeed([]), + completion: () => Effect.succeed(null), }), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0b72e620ae5d..7826a2c2a170 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -66,6 +66,8 @@ import type { FileReadResponses, FileStatusErrors, FileStatusResponses, + FileWriteErrors, + FileWriteResponses, FindFilesErrors, FindFilesResponses, FindSymbolsErrors, @@ -89,6 +91,20 @@ import type { InstanceDisposeErrors, InstanceDisposeResponses, LocationRef, + LspBufferCloseErrors, + LspBufferCloseResponses, + LspBufferErrors, + LspBufferResponses, + LspCompletionErrors, + LspCompletionResponses, + LspDefinitionErrors, + LspDefinitionResponses, + LspDiagnosticsErrors, + LspDiagnosticsResponses, + LspHoverErrors, + LspHoverResponses, + LspReferencesErrors, + LspReferencesResponses, LspStatusErrors, LspStatusResponses, McpAddErrors, @@ -1849,6 +1865,47 @@ export class File extends HeyApiClient { ...params, }) } + + /** + * Write file + * + * Write content to a file in the project, with optional conflict detection. + */ + public write( + parameters: { + directory?: string + workspace?: string + path: string + content?: string + expectedSha?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + { in: "body", key: "content" }, + { in: "body", key: "expectedSha" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/file/write", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Instance extends HeyApiClient { @@ -2147,6 +2204,296 @@ export class Lsp extends HeyApiClient { ...params, }) } + + /** + * LSP hover + * + * Get hover information for a position in a file via LSP. + */ + public hover( + parameters?: { + directory?: string + workspace?: string + path?: string + line?: number + character?: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "line" }, + { in: "body", key: "character" }, + { in: "body", key: "triggerKind" }, + { in: "body", key: "triggerCharacter" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/hover", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * LSP definition + * + * Find the definition of the symbol at a position in a file via LSP. + */ + public definition( + parameters?: { + directory?: string + workspace?: string + path?: string + line?: number + character?: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "line" }, + { in: "body", key: "character" }, + { in: "body", key: "triggerKind" }, + { in: "body", key: "triggerCharacter" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/definition", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * LSP references + * + * Find references to the symbol at a position in a file via LSP. + */ + public references( + parameters?: { + directory?: string + workspace?: string + path?: string + line?: number + character?: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "line" }, + { in: "body", key: "character" }, + { in: "body", key: "triggerKind" }, + { in: "body", key: "triggerCharacter" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/references", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * LSP completion + * + * Get completion suggestions for a position in a file via LSP. + */ + public completion( + parameters?: { + directory?: string + workspace?: string + path?: string + line?: number + character?: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "line" }, + { in: "body", key: "character" }, + { in: "body", key: "triggerKind" }, + { in: "body", key: "triggerCharacter" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/completion", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * LSP diagnostics + * + * Get diagnostics for a single file via LSP. + */ + public diagnostics( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/lsp/diagnostics", + ...options, + ...params, + }) + } + + /** + * LSP buffer sync + * + * Push the editor's in-memory buffer (text + monotonic version) so LSP features reflect unsaved edits. + */ + public buffer( + parameters?: { + directory?: string + workspace?: string + path?: string + text?: string + version?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "text" }, + { in: "body", key: "version" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/lsp/buffer", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * LSP buffer close + * + * Close the editor buffer so the LSP server reverts to disk-backed analysis. + */ + public bufferClose( + parameters?: { + directory?: string + workspace?: string + path?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/buffer/close", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Formatter extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 093c1894a8ab..3bc74378d77a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -67,6 +67,7 @@ export type Event = | EventQuestionV2Rejected | EventTodoUpdated | EventLspUpdated + | EventLspDiagnostics | EventPermissionAsked | EventPermissionReplied | EventTuiPromptAppend2 @@ -668,6 +669,14 @@ export type Todo = { priority: string } +export type Diagnostic = { + range: Range + severity?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + message: string + source?: string + code?: string | number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +} + export type SessionStatus = | { type: "idle" @@ -1384,6 +1393,14 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "lsp.diagnostics" + properties: { + path: string + diagnostics: Array + } + } | { id: string type: "permission.asked" @@ -2314,6 +2331,13 @@ export type File = { status: "added" | "deleted" | "modified" } +export type FileWriteResult = { + path: string + written: boolean + conflict?: boolean + sha?: string +} + export type Path = { home: string state: string @@ -2777,6 +2801,14 @@ export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } +export type Diagnostic1 = { + range: Range + severity?: number | "NaN" | "Infinity" | "-Infinity" + message: string + source?: string + code?: string | number | "NaN" | "Infinity" | "-Infinity" +} + export type EventTuiPromptAppend2 = { id: string type: "tui.prompt.append" @@ -4932,6 +4964,15 @@ export type EventLspUpdated = { } } +export type EventLspDiagnostics = { + id: string + type: "lsp.diagnostics" + properties: { + path: string + diagnostics: Array + } +} + export type EventPermissionAsked = { id: string type: "permission.asked" @@ -6106,6 +6147,38 @@ export type FileStatusResponses = { export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type FileWriteData = { + body?: { + content: string + expectedSha?: string + } + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file/write" +} + +export type FileWriteErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileWriteError = FileWriteErrors[keyof FileWriteErrors] + +export type FileWriteResponses = { + /** + * Write result + */ + 200: FileWriteResult +} + +export type FileWriteResponse = FileWriteResponses[keyof FileWriteResponses] + export type InstanceDisposeData = { body?: never path?: never @@ -6453,6 +6526,225 @@ export type FormatterStatusResponses = { export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type LspHoverData = { + body?: { + path: string + line: number + character: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/hover" +} + +export type LspHoverErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspHoverError = LspHoverErrors[keyof LspHoverErrors] + +export type LspHoverResponses = { + /** + * Hover result + */ + 200: unknown +} + +export type LspDefinitionData = { + body?: { + path: string + line: number + character: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/definition" +} + +export type LspDefinitionErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspDefinitionError = LspDefinitionErrors[keyof LspDefinitionErrors] + +export type LspDefinitionResponses = { + /** + * Definition locations + */ + 200: unknown +} + +export type LspReferencesData = { + body?: { + path: string + line: number + character: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/references" +} + +export type LspReferencesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspReferencesError = LspReferencesErrors[keyof LspReferencesErrors] + +export type LspReferencesResponses = { + /** + * Reference locations + */ + 200: unknown +} + +export type LspCompletionData = { + body?: { + path: string + line: number + character: number + triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + triggerCharacter?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/completion" +} + +export type LspCompletionErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspCompletionError = LspCompletionErrors[keyof LspCompletionErrors] + +export type LspCompletionResponses = { + /** + * Completion result + */ + 200: unknown +} + +export type LspDiagnosticsData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/lsp/diagnostics" +} + +export type LspDiagnosticsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspDiagnosticsError = LspDiagnosticsErrors[keyof LspDiagnosticsErrors] + +export type LspDiagnosticsResponses = { + /** + * Diagnostics for the file + */ + 200: Array +} + +export type LspDiagnosticsResponse = LspDiagnosticsResponses[keyof LspDiagnosticsResponses] + +export type LspBufferData = { + body?: { + path: string + text: string + version: number + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/buffer" +} + +export type LspBufferErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspBufferError = LspBufferErrors[keyof LspBufferErrors] + +export type LspBufferResponses = { + /** + * Buffer synced + */ + 200: boolean +} + +export type LspBufferResponse = LspBufferResponses[keyof LspBufferResponses] + +export type LspBufferCloseData = { + body?: { + path: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/buffer/close" +} + +export type LspBufferCloseErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspBufferCloseError = LspBufferCloseErrors[keyof LspBufferCloseErrors] + +export type LspBufferCloseResponses = { + /** + * Buffer closed + */ + 200: boolean +} + +export type LspBufferCloseResponse = LspBufferCloseResponses[keyof LspBufferCloseResponses] + export type McpStatusData = { body?: never path?: never diff --git a/packages/ui/bunfig.toml b/packages/ui/bunfig.toml new file mode 100644 index 000000000000..ad879b5c516d --- /dev/null +++ b/packages/ui/bunfig.toml @@ -0,0 +1,5 @@ +[test] +# Registers happy-dom and compiles SolidJS TSX to its DOM build so component +# tests (e.g. code-editor.test.tsx mounting CodeMirror 6) can render real DOM. +# Logic-only tests are unaffected. +preload = ["./test/dom-preload.ts"] diff --git a/packages/ui/package.json b/packages/ui/package.json index b5b109ed0678..c002020198ce 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,6 +7,7 @@ "./package.json": "./package.json", "./*": "./src/components/*.tsx", "./session-diff": "./src/components/session-diff.ts", + "./code-editor-lsp": "./src/components/code-editor-lsp.ts", "./i18n/*": "./src/i18n/*.ts", "./pierre": "./src/pierre/index.ts", "./pierre/*": "./src/pierre/*.ts", @@ -36,6 +37,10 @@ }, "devDependencies": { "@tailwindcss/vite": "catalog:", + "@babel/core": "7.28.4", + "@babel/preset-typescript": "7.27.1", + "@happy-dom/global-registrator": "20.0.11", + "babel-preset-solid": "1.9.12", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/katex": "0.16.7", @@ -48,7 +53,18 @@ "vite-plugin-solid": "catalog:" }, "dependencies": { + "@codemirror/autocomplete": "6.18.6", + "@codemirror/commands": "6.8.1", + "@codemirror/lang-go": "6.0.1", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-python": "6.2.1", + "@codemirror/language": "6.11.3", + "@codemirror/lint": "6.8.5", + "@codemirror/search": "6.5.11", + "@codemirror/state": "6.5.2", + "@codemirror/view": "6.38.1", "@kobalte/core": "catalog:", + "@lezer/highlight": "1.2.3", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", diff --git a/packages/ui/src/codemirror/shiki-highlight.test.ts b/packages/ui/src/codemirror/shiki-highlight.test.ts new file mode 100644 index 000000000000..f8e3f407dc20 --- /dev/null +++ b/packages/ui/src/codemirror/shiki-highlight.test.ts @@ -0,0 +1,29 @@ +import { describe, test, expect } from "bun:test" +import { editorLanguageToShikiLang } from "./shiki-highlight" + +describe("editorLanguageToShikiLang", () => { + test("maps explicit languages", () => { + expect(editorLanguageToShikiLang("typescript", undefined)).toBe("tsx") + expect(editorLanguageToShikiLang("go", undefined)).toBe("go") + expect(editorLanguageToShikiLang("python", undefined)).toBe("python") + expect(editorLanguageToShikiLang("plaintext", undefined)).toBeUndefined() + }) + + test("explicit language wins over path", () => { + expect(editorLanguageToShikiLang("go", "foo.ts")).toBe("go") + }) + + test("derives from path extension", () => { + expect(editorLanguageToShikiLang(undefined, "a.ts")).toBe("typescript") + expect(editorLanguageToShikiLang(undefined, "a.tsx")).toBe("tsx") + expect(editorLanguageToShikiLang(undefined, "a.js")).toBe("javascript") + expect(editorLanguageToShikiLang(undefined, "a.jsx")).toBe("jsx") + expect(editorLanguageToShikiLang(undefined, "a.go")).toBe("go") + expect(editorLanguageToShikiLang(undefined, "a.py")).toBe("python") + }) + + test("returns undefined for unknown / missing", () => { + expect(editorLanguageToShikiLang(undefined, "a.md")).toBeUndefined() + expect(editorLanguageToShikiLang(undefined, undefined)).toBeUndefined() + }) +}) diff --git a/packages/ui/src/codemirror/shiki-highlight.ts b/packages/ui/src/codemirror/shiki-highlight.ts new file mode 100644 index 000000000000..4d8f2b3134f8 --- /dev/null +++ b/packages/ui/src/codemirror/shiki-highlight.ts @@ -0,0 +1,196 @@ +import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view" +import { RangeSetBuilder, type Extension } from "@codemirror/state" +import { bundledLanguages, type BundledLanguage } from "shiki" +import { getSharedHighlighter } from "@pierre/diffs" +import type { CodeEditorLanguage } from "../components/code-editor" + +export type ShikiHighlighter = Awaited> + +export function editorLanguageToShikiLang( + language: CodeEditorLanguage | undefined, + path: string | undefined, +): BundledLanguage | undefined { + if (language) { + switch (language) { + case "typescript": + return "tsx" + case "go": + return "go" + case "python": + return "python" + case "plaintext": + return undefined + } + } + if (path) { + const lower = path.toLowerCase() + const ext = lower.slice(lower.lastIndexOf(".")) + switch (ext) { + case ".ts": + return "typescript" + case ".tsx": + return "tsx" + case ".js": + case ".mjs": + case ".cjs": + return "javascript" + case ".jsx": + return "jsx" + case ".go": + return "go" + case ".py": + case ".pyi": + return "python" + } + } + return undefined +} + +export async function loadShikiForLang(lang: BundledLanguage): Promise { + if (!(lang in bundledLanguages)) return undefined + const highlighter = await getSharedHighlighter({ + themes: ["OpenCode"], + langs: [], + preferredHighlighter: "shiki-wasm", + }) + if (!highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage(lang) + } + return highlighter +} + +type TokensByLine = ReturnType["tokens"] + +const MAX_FULLDOC_LINES = 5000 + +function makeMarkFactory() { + const cache = new Map() + return (color: string, fontStyle: number): Decoration => { + const key = color + ":" + fontStyle + let deco = cache.get(key) + if (!deco) { + let style = `color: ${color};` + if (fontStyle & 1) style += " font-style: italic;" + if (fontStyle & 2) style += " font-weight: bold;" + if (fontStyle & 4) style += " text-decoration: underline;" + deco = Decoration.mark({ attributes: { style } }) + cache.set(key, deco) + } + return deco + } +} + +function tokenizeDocument( + doc: string, + highlighter: ShikiHighlighter, + lang: BundledLanguage, +): TokensByLine | undefined { + try { + return highlighter.codeToTokens(doc, { lang, theme: "OpenCode" }).tokens + } catch { + return undefined + } +} + +function buildFromTokens(view: EditorView, tokensByLine: TokensByLine): DecorationSet { + const builder = new RangeSetBuilder() + const markFor = makeMarkFactory() + for (const { from, to } of view.visibleRanges) { + const startLine = view.state.doc.lineAt(from).number + const endLine = view.state.doc.lineAt(to).number + for (let ln = startLine; ln <= endLine; ln++) { + const line = view.state.doc.line(ln) + const row = tokensByLine[ln - 1] + if (!row) continue + // Whole-doc tokenization gives ABSOLUTE token.offset, so walk by content + // length from line start instead of using token.offset. + let pos = line.from + for (const token of row) { + const len = token.content.length + if (len <= 0) continue + if (token.color) builder.add(pos, pos + len, markFor(token.color, token.fontStyle ?? 0)) + pos += len + } + } + } + return builder.finish() +} + +function buildPerLine(view: EditorView, highlighter: ShikiHighlighter, lang: BundledLanguage): DecorationSet { + const builder = new RangeSetBuilder() + const markFor = makeMarkFactory() + for (const { from, to } of view.visibleRanges) { + const startLine = view.state.doc.lineAt(from).number + const endLine = view.state.doc.lineAt(to).number + for (let ln = startLine; ln <= endLine; ln++) { + const line = view.state.doc.line(ln) + if (line.length === 0) continue + let row + try { + row = highlighter.codeToTokens(line.text, { lang, theme: "OpenCode" }).tokens[0] + } catch { + continue + } + if (!row) continue + for (const token of row) { + const start = line.from + token.offset + const end = start + token.content.length + if (end <= start || !token.color) continue + builder.add(start, end, markFor(token.color, token.fontStyle ?? 0)) + } + } + } + return builder.finish() +} + +export function shikiHighlightExtension(highlighter: ShikiHighlighter, lang: BundledLanguage): Extension { + return ViewPlugin.fromClass( + class { + tokens: TokensByLine | undefined + decorations: DecorationSet + constructor(view: EditorView) { + this.tokens = this.tokenize(view) + this.decorations = this.build(view) + } + tokenize(view: EditorView): TokensByLine | undefined { + if (view.state.doc.lines > MAX_FULLDOC_LINES) return undefined + return tokenizeDocument(view.state.doc.toString(), highlighter, lang) + } + build(view: EditorView): DecorationSet { + return this.tokens ? buildFromTokens(view, this.tokens) : buildPerLine(view, highlighter, lang) + } + update(update: ViewUpdate) { + if (update.docChanged) this.tokens = this.tokenize(update.view) + if (update.docChanged || update.viewportChanged) this.decorations = this.build(update.view) + } + }, + { + decorations: (v) => v.decorations, + }, + ) +} + +export function highlightMarkdownCodeBlocks(container: HTMLElement): void { + const blocks = Array.from(container.querySelectorAll("pre > code")) as HTMLElement[] + if (blocks.length === 0) return + for (const code of blocks) { + const cls = Array.from(code.classList).find((c) => c.startsWith("language-")) + const langId = cls ? cls.slice("language-".length) : "" + if (!langId || !(langId in bundledLanguages)) continue + const text = code.textContent ?? "" + if (!text.trim()) continue + void loadShikiForLang(langId as BundledLanguage) + .then((highlighter) => { + if (!highlighter) return + if (!container.isConnected) return + const pre = code.parentElement + if (!pre || !pre.isConnected) return + const html = highlighter.codeToHtml(text, { lang: langId, theme: "OpenCode", tabindex: false }) + const tmp = document.createElement("div") + tmp.innerHTML = html + const next = tmp.firstElementChild + if (next) pre.replaceWith(next) + }) + .catch(() => {}) + } +} diff --git a/packages/ui/src/codemirror/theme.ts b/packages/ui/src/codemirror/theme.ts new file mode 100644 index 000000000000..ee3c773f8cf5 --- /dev/null +++ b/packages/ui/src/codemirror/theme.ts @@ -0,0 +1,298 @@ +import { EditorView } from "@codemirror/view" +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language" +import { tags as t } from "@lezer/highlight" +import type { Extension } from "@codemirror/state" + +export function ocEditorTheme(): Extension { + return [ + EditorView.theme( + { + "&": { + color: "var(--text-base)", + backgroundColor: "var(--background-stronger, var(--background-base))", + fontFamily: "var(--font-family-mono)", + fontSize: "var(--font-size-small, 13px)", + height: "100%", + }, + ".cm-content": { + caretColor: "var(--text-strong)", + fontFamily: "var(--font-family-mono)", + padding: "0", + lineHeight: "24px", + fontFeatureSettings: "var(--font-family-mono--font-feature-settings)", + }, + ".cm-line": { + lineHeight: "24px", + padding: "0 1ch", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--text-strong)", + }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { + backgroundColor: "var(--surface-base-interactive-active, rgba(128,128,128,0.3))", + }, + ".cm-activeLine": { + backgroundColor: "var(--surface-base, rgba(128,128,128,0.06))", + }, + ".cm-activeLineGutter": { + backgroundColor: "var(--surface-base, rgba(128,128,128,0.06))", + }, + ".cm-gutters": { + backgroundColor: "var(--background-stronger, var(--background-base))", + color: "var(--diffs-fg-number, var(--text-weak))", + border: "none", + fontFamily: "var(--font-family-mono)", + fontFeatureSettings: "var(--font-family-mono--font-feature-settings)", + }, + ".cm-lineNumbers .cm-gutterElement": { + color: "color-mix(in lab, var(--text-base) 65%, var(--background-stronger))", + lineHeight: "24px", + // Measured px to match Pierre's fixed-px gutter at the app's single font size. + padding: "0 10.2px 0 31.2px", + textAlign: "right", + }, + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: "var(--text-weak)", + }, + ".cm-tooltip": { + backgroundColor: "var(--surface-raised-stronger-non-alpha, var(--surface-float-base, var(--background-strong)))", + backgroundClip: "padding-box", + border: "1px solid color-mix(in oklch, var(--border-base) 50%, transparent)", + borderRadius: "var(--radius-md, 6px)", + color: "var(--text-strong)", + boxShadow: "var(--shadow-md, 0 4px 16px rgba(0,0,0,0.28))", + overflow: "hidden", + }, + ".cm-tooltip.cm-tooltip-autocomplete": { + padding: "4px", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul": { + fontFamily: "var(--font-family-sans, sans-serif)", + fontSize: "var(--font-size-small, 13px)", + fontWeight: "var(--font-weight-medium, 500)", + lineHeight: "var(--line-height-large, 1.6)", + letterSpacing: "var(--letter-spacing-normal, normal)", + maxHeight: "18em", + minWidth: "16em", + }, + ".cm-tooltip-autocomplete > ul > li": { + display: "flex", + alignItems: "center", + gap: "8px", + padding: "4px 8px", + borderRadius: "var(--radius-sm, 4px)", + cursor: "default", + userSelect: "none", + color: "var(--text-strong)", + }, + ".cm-tooltip-autocomplete > ul > li:hover": { + backgroundColor: "var(--surface-raised-base-hover, rgba(128,128,128,0.12))", + }, + ".cm-tooltip-autocomplete > ul > li[aria-selected]": { + backgroundColor: "var(--surface-raised-base-hover, var(--surface-base-active, rgba(128,128,128,0.2)))", + color: "var(--text-strong)", + }, + ".cm-completionLabel": { color: "var(--text-strong)", flex: "0 0 auto" }, + ".cm-completionMatchedText": { + color: "var(--text-interactive-base, var(--syntax-keyword, var(--text-strong)))", + textDecoration: "none", + fontWeight: "var(--font-weight-medium, 600)", + }, + ".cm-completionDetail": { + color: "var(--text-weak)", + fontStyle: "normal", + fontFamily: "var(--font-family-mono)", + marginLeft: "auto", + paddingLeft: "1.5em", + fontSize: "var(--font-size-x-small, 0.85em)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + ".cm-completionIcon": { + color: "var(--text-weak)", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + width: "1.35em", + height: "1.35em", + marginRight: "0", + padding: "0", + fontSize: "0.85em", + opacity: "1", + borderRadius: "var(--radius-sm, 4px)", + backgroundColor: "color-mix(in oklch, currentColor 16%, transparent)", + flex: "0 0 auto", + }, + ".cm-completionIcon::after": { opacity: "1" }, + ".cm-tooltip.cm-tooltip-lint": { padding: "0" }, + ".cm-tooltip-lint .cm-diagnostic": { + padding: "8px 10px 8px 12px", + margin: "0", + borderLeft: "3px solid var(--border-base, transparent)", + fontFamily: "var(--font-family-sans, sans-serif)", + fontSize: "var(--font-size-small, 13px)", + lineHeight: "1.5", + color: "var(--text-strong)", + whiteSpace: "pre-wrap", + }, + ".cm-tooltip-lint .cm-diagnostic-error": { borderLeftColor: "var(--syntax-critical, #e5484d)" }, + ".cm-tooltip-lint .cm-diagnostic-warning": { borderLeftColor: "var(--syntax-warning, #f5a623)" }, + ".cm-tooltip-lint .cm-diagnostic-info": { borderLeftColor: "var(--syntax-info, var(--text-weak))" }, + ".cm-diagnosticSource": { + display: "block", + marginTop: "4px", + fontFamily: "var(--font-family-mono)", + fontSize: "var(--font-size-x-small, 0.8em)", + color: "var(--text-weak)", + opacity: "0.85", + }, + ".cm-completionIcon-function, .cm-completionIcon-method": { color: "var(--syntax-object, var(--text-strong))" }, + ".cm-completionIcon-class, .cm-completionIcon-interface, .cm-completionIcon-type, .cm-completionIcon-struct": { + color: "var(--syntax-type, var(--text-base))", + }, + ".cm-completionIcon-keyword": { color: "var(--syntax-keyword, var(--text-base))" }, + ".cm-completionIcon-variable, .cm-completionIcon-property, .cm-completionIcon-field": { + color: "var(--syntax-property, var(--text-base))", + }, + ".cm-completionIcon-constant, .cm-completionIcon-enum": { color: "var(--syntax-constant, var(--text-base))" }, + ".cm-completionInfo": { + backgroundColor: "var(--surface-raised-stronger-non-alpha, var(--surface-float-base, var(--background-strong)))", + border: "1px solid color-mix(in oklch, var(--border-base) 50%, transparent)", + borderRadius: "var(--radius-md, 6px)", + boxShadow: "var(--shadow-md, 0 4px 16px rgba(0,0,0,0.28))", + padding: "8px 10px", + maxWidth: "420px", + maxHeight: "320px", + overflow: "auto", + fontFamily: "var(--font-family-sans, sans-serif)", + fontSize: "var(--font-size-small, 13px)", + lineHeight: "1.5", + color: "var(--text-base)", + }, + ".cm-completion-doc-signature": { + fontFamily: "var(--font-family-mono)", + fontSize: "0.95em", + color: "var(--text-strong)", + whiteSpace: "pre-wrap", + marginBottom: "6px", + paddingBottom: "6px", + borderBottom: "1px solid var(--border-weak-base, rgba(128,128,128,0.2))", + }, + ".cm-completion-doc-signature:last-child": { + marginBottom: "0", + paddingBottom: "0", + borderBottom: "none", + }, + ".cm-completion-doc-body": { + padding: "0", + maxWidth: "none", + maxHeight: "none", + overflow: "visible", + }, + ".cm-tooltip-hover": { pointerEvents: "auto" }, + ".cm-lsp-hover": { + padding: "8px 10px", + maxWidth: "560px", + maxHeight: "320px", + overflow: "auto", + fontFamily: "var(--font-family-sans, sans-serif)", + fontSize: "var(--font-size-small, 13px)", + lineHeight: "1.6", + color: "var(--text-strong)", + overflowWrap: "break-word", + userSelect: "text", + WebkitUserSelect: "text", + cursor: "auto", + }, + ".cm-lsp-hover > *:first-child": { marginTop: "0" }, + ".cm-lsp-hover > *:last-child": { marginBottom: "0" }, + ".cm-lsp-hover p": { margin: "0 0 0.5em" }, + ".cm-lsp-hover p:last-child": { margin: "0" }, + ".cm-lsp-hover h1, .cm-lsp-hover h2, .cm-lsp-hover h3, .cm-lsp-hover h4, .cm-lsp-hover h5, .cm-lsp-hover h6": + { + fontSize: "1em", + fontWeight: "var(--font-weight-medium, 600)", + color: "var(--text-strong)", + margin: "0 0 0.4em", + }, + ".cm-lsp-hover strong, .cm-lsp-hover b": { + color: "var(--text-strong)", + fontWeight: "var(--font-weight-medium, 600)", + }, + ".cm-lsp-hover ul, .cm-lsp-hover ol": { margin: "0 0 0.5em", paddingLeft: "1.4em" }, + ".cm-lsp-hover li": { margin: "0.1em 0" }, + ".cm-lsp-hover pre": { + backgroundColor: "var(--surface-base, rgba(128,128,128,0.12))", + padding: "6px 8px", + borderRadius: "var(--radius-sm, 4px)", + overflow: "auto", + margin: "0 0 0.5em", + fontFamily: "var(--font-family-mono)", + fontSize: "0.95em", + lineHeight: "1.45", + }, + ".cm-lsp-hover code": { + backgroundColor: "var(--surface-base, rgba(128,128,128,0.12))", + padding: "0.1em 0.3em", + borderRadius: "3px", + fontFamily: "var(--font-family-mono)", + fontSize: "0.95em", + color: "var(--text-strong)", + }, + ".cm-lsp-hover pre code": { backgroundColor: "transparent", padding: "0", color: "inherit" }, + ".cm-lsp-hover a": { + color: "var(--text-interactive-base, var(--syntax-info, var(--text-link)))", + textDecoration: "none", + }, + ".cm-lsp-hover a:hover": { textDecoration: "underline", textUnderlineOffset: "2px" }, + ".cm-lsp-hover hr": { + border: "none", + borderTop: "1px solid var(--border-weak-base, var(--border-base, rgba(128,128,128,0.3)))", + margin: "0.5em 0", + }, + ".cm-searchMatch": { + backgroundColor: "var(--surface-base-active, rgba(128,128,128,0.25))", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "var(--surface-base-interactive-active, rgba(128,128,128,0.4))", + }, + ".cm-selectionMatch": { + backgroundColor: "var(--surface-base, rgba(128,128,128,0.15))", + }, + }, + ), + // Lezer highlighting is intentionally NOT included here: it lives in its own + // compartment (`ocLezerHighlight`) as the fallback shiki replaces. Running + // both nests spans and the inner Lezer color wins, mis-coloring vs View. + ] +} + +export function ocLezerHighlight(): Extension { + return syntaxHighlighting(ocHighlightStyle) +} + +const ocHighlightStyle = HighlightStyle.define([ + { tag: t.comment, color: "var(--syntax-comment)", fontStyle: "italic" }, + { tag: [t.lineComment, t.blockComment, t.docComment], color: "var(--syntax-comment)", fontStyle: "italic" }, + { tag: [t.string, t.special(t.string)], color: "var(--syntax-string)" }, + { tag: t.regexp, color: "var(--syntax-regexp)" }, + { tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword], color: "var(--syntax-keyword)" }, + { tag: [t.number, t.bool, t.null, t.atom], color: "var(--syntax-primitive)" }, + { tag: [t.operator, t.compareOperator, t.logicOperator, t.arithmeticOperator], color: "var(--syntax-operator)" }, + { tag: [t.variableName, t.self], color: "var(--syntax-variable)" }, + { tag: [t.propertyName, t.attributeName], color: "var(--syntax-property)" }, + { tag: [t.typeName, t.className, t.namespace], color: "var(--syntax-type)" }, + { tag: [t.constant(t.variableName), t.standard(t.variableName)], color: "var(--syntax-constant)" }, + { tag: [t.punctuation, t.separator, t.bracket, t.brace, t.paren], color: "var(--syntax-punctuation)" }, + { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syntax-object)" }, + { tag: [t.definitionKeyword, t.definition(t.variableName)], color: "var(--syntax-variable)" }, + { tag: t.invalid, color: "var(--syntax-critical)" }, + { tag: [t.heading, t.strong], color: "var(--syntax-keyword)", fontWeight: "bold" }, + { tag: [t.emphasis], fontStyle: "italic" }, + { tag: [t.link, t.url], color: "var(--syntax-info)", textDecoration: "underline" }, +]) diff --git a/packages/ui/src/components/code-editor-lsp.test.ts b/packages/ui/src/components/code-editor-lsp.test.ts new file mode 100644 index 000000000000..9d213daccaef --- /dev/null +++ b/packages/ui/src/components/code-editor-lsp.test.ts @@ -0,0 +1,354 @@ +import { describe, test, expect, afterEach } from "bun:test" +import { render } from "solid-js/web" +import { EditorState, Text } from "@codemirror/state" +import { EditorView } from "@codemirror/view" +import { CompletionContext } from "@codemirror/autocomplete" +import { CodeEditor } from "./code-editor" +import { + offsetToPos, + posToOffset, + mapDiagnostics, + createCompletionSource, + normalizeCompletionItems, + hoverContentsToText, + severityToCM, + firstDefinition, + uriToPath, + lspExtensions, + type LspClient, + type LspExtensionsOptions, +} from "./code-editor-lsp" + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function mockLsp(overrides: Partial = {}): LspClient { + return { + buffer: async () => true, + bufferClose: async () => true, + completion: async () => [], + hover: async () => null, + definition: async () => [], + diagnostics: async () => [], + ...overrides, + } +} + +function baseOpts(over: Partial = {}): LspExtensionsOptions { + let v = 0 + return { + path: "src/foo.ts", + bumpVersion: () => ++v, + lsp: mockLsp(), + onOpenLocation: () => {}, + subscribeDiagnostics: () => () => {}, + debounceMs: 10, + ...over, + } +} + +describe("offset <-> position helpers", () => { + test("round-trips on a simple multi-line doc", () => { + const doc = Text.of(["hello", "world", "abc"]) + for (let offset = 0; offset <= doc.length; offset++) { + const pos = offsetToPos(doc, offset) + expect(posToOffset(doc, pos)).toBe(offset) + } + }) + + test("handles multi-byte (UTF-16) characters", () => { + const doc = Text.of(["café", "naïve héllo"]) + // 'é' is a single UTF-16 code unit; offsets are code-unit based. + expect(offsetToPos(doc, 4)).toEqual({ line: 0, character: 4 }) + expect(posToOffset(doc, { line: 0, character: 4 })).toBe(4) + // start of second line + const secondLine = doc.line(2) + expect(offsetToPos(doc, secondLine.from)).toEqual({ line: 1, character: 0 }) + }) + + test("CRLF: line breaks are not addressable, positions stay per-line", () => { + const doc = Text.of(["a", "b"]) // CM normalizes content; line break length is 1 here + expect(offsetToPos(doc, 0)).toEqual({ line: 0, character: 0 }) + expect(offsetToPos(doc, 2)).toEqual({ line: 1, character: 0 }) + }) + + test("clamps out-of-range input", () => { + const doc = Text.of(["ab"]) + expect(offsetToPos(doc, 999)).toEqual({ line: 0, character: 2 }) + expect(posToOffset(doc, { line: 99, character: 99 })).toBe(2) + }) +}) + +describe("severity mapping", () => { + test("maps 1..4", () => { + expect(severityToCM(1)).toBe("error") + expect(severityToCM(2)).toBe("warning") + expect(severityToCM(3)).toBe("info") + expect(severityToCM(4)).toBe("info") + expect(severityToCM(undefined)).toBe("error") + }) +}) + +describe("mapDiagnostics", () => { + test("maps ranges to CM offsets", () => { + const doc = Text.of(["const x = 1", "let y = 2"]) + const cm = mapDiagnostics(doc, [ + { + range: { start: { line: 0, character: 6 }, end: { line: 0, character: 7 } }, + severity: 1, + message: "oops", + source: "ts", + }, + ]) + expect(cm).toHaveLength(1) + expect(cm[0]).toMatchObject({ from: 6, to: 7, severity: "error", message: "oops", source: "ts" }) + }) + + test("swaps reversed ranges and skips rangeless entries", () => { + const doc = Text.of(["abcdef"]) + const cm = mapDiagnostics(doc, [ + { range: { start: { line: 0, character: 4 }, end: { line: 0, character: 2 } }, message: "rev" }, + { message: "norange" } as any, + ]) + expect(cm).toHaveLength(1) + expect(cm[0]).toMatchObject({ from: 2, to: 4 }) + }) +}) + +describe("completion source", () => { + function makeContext(docLines: string[], pos: number, explicit = true) { + const state = EditorState.create({ doc: docLines.join("\n") }) + return new CompletionContext(state, pos, explicit) + } + + // Helper: the per-client array shape (real wire format) wrapping a CompletionList. + const bump = () => 1 + + test("calls lsp.completion and maps a bare CompletionItem[]", async () => { + let received: any + const source = createCompletionSource({ + path: "src/foo.ts", + bumpVersion: bump, + lsp: mockLsp({ + completion: async (input) => { + received = input + return [ + { label: "foo", detail: "fn foo", insertText: "foo()", kind: 3 }, + { label: "bar" }, + ] + }, + }), + }) + const ctx = makeContext(["fo"], 2) + const result = await source(ctx) + expect(received).toMatchObject({ path: "src/foo.ts", line: 0, character: 2 }) + expect(result).not.toBeNull() + expect(result!.options).toHaveLength(2) + expect(result!.options[0]).toMatchObject({ label: "foo", detail: "fn foo", apply: "foo()" }) + expect(result!.options[1]).toMatchObject({ label: "bar", apply: "bar" }) + }) + + test("handles the per-client array shape [{isIncomplete,items}]", async () => { + const source = createCompletionSource({ + path: "x.ts", + bumpVersion: bump, + lsp: mockLsp({ + completion: async () => [{ isIncomplete: false, items: [{ label: "baz" }, { label: "qux" }] }] as any, + }), + }) + const result = await source(makeContext(["b"], 1)) + expect(result!.options.map((o) => o.label)).toEqual(["baz", "qux"]) + }) + + test("flattens items across multiple per-client CompletionLists", async () => { + const source = createCompletionSource({ + path: "x.py", + bumpVersion: bump, + lsp: mockLsp({ + completion: async () => + [ + { isIncomplete: false, items: [{ label: "pyright1" }] }, + { isIncomplete: false, items: [{ label: "ty1" }] }, + ] as any, + }), + }) + const result = await source(makeContext(["b"], 1)) + expect(result!.options.map((o) => o.label)).toEqual(["pyright1", "ty1"]) + }) + + test("handles a bare CompletionList {items} shape", async () => { + const source = createCompletionSource({ + path: "x.ts", + bumpVersion: bump, + lsp: mockLsp({ completion: async () => ({ items: [{ label: "baz" }] }) as any }), + }) + const result = await source(makeContext(["b"], 1)) + expect(result!.options[0].label).toBe("baz") + }) + + test("returns null when no items", async () => { + const source = createCompletionSource({ + path: "x.ts", + bumpVersion: bump, + lsp: mockLsp({ completion: async () => [] }), + }) + expect(await source(makeContext(["b"], 1))).toBeNull() + }) + + test("flushes the buffer (awaited) BEFORE calling lsp.completion", async () => { + const calls: string[] = [] + let bufferResolved = false + const source = createCompletionSource({ + path: "src/foo.ts", + bumpVersion: () => 7, + lsp: mockLsp({ + buffer: async (input) => { + calls.push("buffer") + expect(input).toMatchObject({ path: "src/foo.ts", version: 7 }) + await sleep(5) + bufferResolved = true + return true + }, + completion: async () => { + // completion must only run after buffer has fully resolved + expect(bufferResolved).toBe(true) + calls.push("completion") + return [{ label: "ok" }] + }, + }), + }) + const result = await source(makeContext(["fo.x"], 4)) + expect(calls).toEqual(["buffer", "completion"]) + expect(result!.options[0].label).toBe("ok") + }) + + test("normalizeCompletionItems handles all shapes and garbage", () => { + // bare CompletionItem[] + expect(normalizeCompletionItems([{ label: "a" }])).toHaveLength(1) + // bare CompletionList + expect(normalizeCompletionItems({ items: [{ label: "a" }] })).toHaveLength(1) + // per-client array wrapping a CompletionList + expect(normalizeCompletionItems([{ isIncomplete: false, items: [{ label: "a" }, { label: "b" }] }])).toHaveLength(2) + // per-client array wrapping bare CompletionItem[] per client + expect(normalizeCompletionItems([[{ label: "a" }], [{ label: "b" }]])).toHaveLength(2) + expect(normalizeCompletionItems(null)).toHaveLength(0) + expect(normalizeCompletionItems("garbage")).toHaveLength(0) + }) +}) + +describe("hover contents", () => { + test("string", () => { + expect(hoverContentsToText("hi")).toBe("hi") + }) + test("MarkupContent", () => { + expect(hoverContentsToText({ kind: "markdown", value: "**x**" })).toBe("**x**") + }) + test("array", () => { + expect(hoverContentsToText(["a", { value: "b" }])).toBe("a\n\nb") + }) +}) + +describe("definition", () => { + test("uriToPath strips file:// and decodes", () => { + expect(uriToPath("file:///home/u/my%20file.ts")).toBe("/home/u/my file.ts") + expect(uriToPath("src/rel.ts")).toBe("src/rel.ts") + }) + test("firstDefinition takes first Location", () => { + const out = firstDefinition([ + { uri: "file:///a.ts", range: { start: { line: 2, character: 3 }, end: { line: 2, character: 4 } } }, + ]) + expect(out).toEqual({ path: "/a.ts", pos: { line: 2, character: 3 } }) + }) + test("firstDefinition handles LocationLink", () => { + const out = firstDefinition({ + targetUri: "file:///b.ts", + targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } }, + }) + expect(out).toEqual({ path: "/b.ts", pos: { line: 1, character: 0 } }) + }) + test("firstDefinition returns undefined for empty", () => { + expect(firstDefinition([])).toBeUndefined() + }) +}) + +// --- Integration against a live EditorView (buffer sync + teardown) --------- + +const cleanups: Array<() => void> = [] +afterEach(() => { + while (cleanups.length) cleanups.pop()!() +}) + +function mountEditor(opts: LspExtensionsOptions, value = "hello") { + const host = document.createElement("div") + document.body.appendChild(host) + const dispose = render(() => CodeEditor({ value, path: opts.path, extensions: lspExtensions(opts) }), host) + cleanups.push(() => { + dispose() + host.remove() + }) + const view = EditorView.findFromDOM(host.querySelector("[data-component=code-editor]") as HTMLElement)! + return { host, dispose, view } +} + +describe("buffer sync integration", () => { + test("opens buffer on mount and debounces didChange with incremented version", async () => { + const versions: number[] = [] + const opts = baseOpts({ + lsp: mockLsp({ + buffer: async (input) => { + versions.push(input.version) + return true + }, + }), + }) + const { view } = mountEditor(opts) + await sleep(20) + expect(versions.length).toBeGreaterThanOrEqual(1) // initial open + const afterOpen = versions.length + + view.dispatch({ changes: { from: view.state.doc.length, insert: "!" } }) + await sleep(40) + expect(versions.length).toBe(afterOpen + 1) + expect(versions[versions.length - 1]).toBeGreaterThan(versions[0]!) + }) + + test("applies pushed diagnostics via subscribeDiagnostics", async () => { + let emit: ((list: any) => void) | undefined + const opts = baseOpts({ + subscribeDiagnostics: (_path, cb) => { + emit = cb + return () => {} + }, + }) + const { view } = mountEditor(opts, "const x = 1") + await sleep(20) + emit!([ + { range: { start: { line: 0, character: 6 }, end: { line: 0, character: 7 } }, severity: 1, message: "bad" }, + ]) + await sleep(5) + // setDiagnostics adds a lint state field; assert no throw and view alive. + expect(view.state.doc.toString()).toBe("const x = 1") + }) + + test("teardown unsubscribes and closes buffer", async () => { + let unsubscribed = false + let closed = false + const opts = baseOpts({ + subscribeDiagnostics: () => () => { + unsubscribed = true + }, + lsp: mockLsp({ + bufferClose: async () => { + closed = true + return true + }, + }), + }) + const { dispose } = mountEditor(opts) + await sleep(20) + dispose() + cleanups.pop() // already disposed + await sleep(5) + expect(unsubscribed).toBe(true) + expect(closed).toBe(true) + }) +}) diff --git a/packages/ui/src/components/code-editor-lsp.ts b/packages/ui/src/components/code-editor-lsp.ts new file mode 100644 index 000000000000..fc87bbed0b94 --- /dev/null +++ b/packages/ui/src/components/code-editor-lsp.ts @@ -0,0 +1,455 @@ +import type { Extension } from "@codemirror/state" +import { EditorView, ViewPlugin, hoverTooltip, keymap, type ViewUpdate } from "@codemirror/view" +import type { Text } from "@codemirror/state" +import { + autocompletion, + type Completion, + type CompletionContext, + type CompletionResult, +} from "@codemirror/autocomplete" +import { setDiagnostics, type Diagnostic as CMDiagnostic } from "@codemirror/lint" +import { Marked } from "marked" +import { highlightMarkdownCodeBlocks } from "../codemirror/shiki-highlight" + +// Isolated marked instance: the global singleton has async extensions that make +// synchronous `parse(..., { async: false })` throw. +const hoverMarked = new Marked({ gfm: true, breaks: true }) +// `external-link` anchors so the desktop renderer opens links in the OS browser +// instead of navigating (and tearing down) the app window. +hoverMarked.use({ + renderer: { + link({ href, title, text }: { href: string; title?: string | null; text: string }) { + const titleAttr = title ? ` title="${title}"` : "" + return `${text}` + }, + }, +}) + +/** LSP-style 0-based position in UTF-16 code units. */ +export type LspPosition = { line: number; character: number } + +export type LspRange = { start: LspPosition; end: LspPosition } + +export type LspDiagnostic = { + range: LspRange + severity?: number | string + message: string + source?: string + code?: string | number +} + +export interface LspClient { + buffer(input: { path: string; text: string; version: number }): Promise + bufferClose(input: { path: string }): Promise + completion(input: { + path: string + line: number + character: number + triggerKind?: number + triggerCharacter?: string + }): Promise + hover(input: { path: string; line: number; character: number }): Promise + definition(input: { path: string; line: number; character: number }): Promise + diagnostics(input: { path: string }): Promise +} + +export type LspExtensionsOptions = { + path: string + bumpVersion: () => number + lsp: LspClient + onOpenLocation: (path: string, pos: LspPosition) => void + subscribeDiagnostics: (path: string, cb: (list: LspDiagnostic[]) => void) => () => void + debounceMs?: number +} + +// LSP positions are 0-based {line, character} in UTF-16 code units; CM lines are +// 1-based. JS strings are UTF-16, so a char index into line text is the unit count. +export function offsetToPos(doc: Text, offset: number): LspPosition { + const clamped = Math.max(0, Math.min(offset, doc.length)) + const line = doc.lineAt(clamped) + return { line: line.number - 1, character: clamped - line.from } +} + +export function posToOffset(doc: Text, pos: LspPosition): number { + const lineNumber = Math.max(1, Math.min(pos.line + 1, doc.lines)) + const line = doc.line(lineNumber) + const character = Math.max(0, Math.min(pos.character, line.length)) + return line.from + character +} + +function toNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const n = Number(value) + if (Number.isFinite(n)) return n + } + return undefined +} + +export function severityToCM(severity: number | string | undefined): CMDiagnostic["severity"] { + switch (toNumber(severity)) { + case 1: + return "error" + case 2: + return "warning" + case 3: + return "info" + case 4: + return "info" + default: + return "error" + } +} + +export function mapDiagnostics(doc: Text, list: LspDiagnostic[]): CMDiagnostic[] { + const out: CMDiagnostic[] = [] + for (const d of list ?? []) { + if (!d?.range) continue + let from = posToOffset(doc, d.range.start) + let to = posToOffset(doc, d.range.end) + if (to < from) [from, to] = [to, from] + out.push({ + from, + to, + severity: severityToCM(d.severity), + message: d.message ?? "", + ...(d.source ? { source: d.source } : {}), + }) + } + return out +} + +const COMPLETION_KIND_LABELS: Record = { + 1: "text", + 2: "method", + 3: "function", + 4: "constructor", + 5: "variable", + 6: "variable", + 7: "class", + 8: "interface", + 9: "namespace", + 10: "property", + 11: "constant", + 12: "enum", + 13: "enum", + 14: "keyword", + 15: "text", + 21: "constant", + 22: "type", + 25: "type", +} + +type RawCompletionItem = { + label: string + detail?: string + insertText?: string + kind?: number + textEdit?: { newText?: string } + sortText?: string + documentation?: string | { value?: string; kind?: string } +} + +export function documentationToText(doc: RawCompletionItem["documentation"]): string { + if (doc == null) return "" + if (typeof doc === "string") return doc + return typeof doc.value === "string" ? doc.value : "" +} + +function renderCompletionInfo(item: RawCompletionItem): Node | null { + const docText = documentationToText(item.documentation) + if (!docText && !item.detail) return null + const dom = document.createElement("div") + dom.className = "cm-completion-doc" + if (item.detail) { + const sig = document.createElement("div") + sig.className = "cm-completion-doc-signature" + sig.textContent = item.detail + dom.appendChild(sig) + } + if (docText) { + const body = document.createElement("div") + body.className = "cm-completion-doc-body cm-lsp-hover" + try { + body.innerHTML = hoverMarked.parse(docText, { async: false }) as string + highlightMarkdownCodeBlocks(body) + } catch { + body.textContent = docText + } + dom.appendChild(body) + } + return dom +} + +function itemsFromCompletionEntry(entry: unknown): RawCompletionItem[] { + if (!entry) return [] + if (Array.isArray(entry)) return entry as RawCompletionItem[] + const items = (entry as { items?: unknown }).items + if (Array.isArray(items)) return items as RawCompletionItem[] + return [] +} + +// The LSP service returns one entry per attached client; each entry is a +// CompletionList or CompletionItem[]. A bare single result is also accepted. +export function normalizeCompletionItems(result: unknown): RawCompletionItem[] { + if (!result) return [] + if (Array.isArray(result)) { + // Distinguish the per-client array from a bare CompletionItem[] by whether + // every entry has a string `label`. + const arr = result as unknown[] + const looksLikeItems = arr.every( + (e) => e != null && typeof e === "object" && typeof (e as { label?: unknown }).label === "string", + ) + if (looksLikeItems) return arr as RawCompletionItem[] + return arr.flatMap((entry) => itemsFromCompletionEntry(entry)) + } + return itemsFromCompletionEntry(result) +} + +function mapCompletionItem(item: RawCompletionItem): Completion { + const insert = item.insertText ?? item.textEdit?.newText ?? item.label + const hasInfo = !!documentationToText(item.documentation) || !!item.detail + return { + label: item.label, + apply: insert, + ...(item.detail ? { detail: item.detail } : {}), + ...(item.kind && COMPLETION_KIND_LABELS[item.kind] ? { type: COMPLETION_KIND_LABELS[item.kind] } : {}), + ...(hasInfo ? { info: () => renderCompletionInfo(item) } : {}), + } +} + +export function createCompletionSource(opts: Pick) { + return async (ctx: CompletionContext): Promise => { + const word = ctx.matchBefore(/[\w$]*/) + const triggerCharacter = ctx.pos > 0 ? ctx.state.doc.sliceString(ctx.pos - 1, ctx.pos) : undefined + const isWordTrigger = word && (word.from !== word.to || ctx.explicit) + const isCharTrigger = triggerCharacter ? /[.\-:>@/]/.test(triggerCharacter) : false + if (!ctx.explicit && !isWordTrigger && !isCharTrigger) return null + + const { line, character } = offsetToPos(ctx.state.doc, ctx.pos) + + // Flush the current document before requesting completion; the buffer-sync + // plugin is debounced, so the server would otherwise answer on stale text. + try { + await opts.lsp.buffer({ path: opts.path, text: ctx.state.doc.toString(), version: opts.bumpVersion() }) + } catch {} + + let result: unknown + try { + result = await opts.lsp.completion({ + path: opts.path, + line, + character, + triggerKind: ctx.explicit ? 1 : isCharTrigger ? 2 : 1, + ...(isCharTrigger && triggerCharacter ? { triggerCharacter } : {}), + }) + } catch { + return null + } + if (ctx.aborted) return null + + const items = normalizeCompletionItems(result) + if (items.length === 0) return null + + return { + from: word ? word.from : ctx.pos, + options: items.map(mapCompletionItem), + validFor: /^[\w$]*$/, + } + } +} + +type HoverContents = string | { value?: string; kind?: string } | Array + +export function hoverContentsToText(contents: unknown): string { + if (contents == null) return "" + if (typeof contents === "string") return contents + if (Array.isArray(contents)) { + return contents.map((c) => hoverContentsToText(c)).filter(Boolean).join("\n\n") + } + const obj = contents as { value?: unknown } + if (typeof obj.value === "string") return obj.value + return "" +} + +export function extractHoverContents(hover: unknown): HoverContents | undefined { + if (!hover) return undefined + const entries = Array.isArray(hover) ? hover : [hover] + for (const entry of entries) { + const contents = (entry as { contents?: HoverContents })?.contents + if (contents != null && hoverContentsToText(contents)) return contents + } + return undefined +} + +type RawLocation = { + uri?: string + targetUri?: string + range?: LspRange + targetSelectionRange?: LspRange + targetRange?: LspRange +} + +export function uriToPath(uri: string): string { + if (!uri) return uri + if (uri.startsWith("file://")) { + const body = uri.slice("file://".length) + try { + return decodeURIComponent(body) + } catch { + return body + } + } + return uri +} + +function locationFromEntry(loc: RawLocation | undefined): { path: string; pos: LspPosition } | undefined { + if (!loc) return undefined + const uri = loc.uri ?? loc.targetUri + const range = loc.range ?? loc.targetSelectionRange ?? loc.targetRange + if (!uri || !range) return undefined + return { path: uriToPath(uri), pos: range.start } +} + +// Wire shape is Array (one entry per +// client); flatten and return the first entry with a usable uri + range. +export function firstDefinition(result: unknown): { path: string; pos: LspPosition } | undefined { + const top = Array.isArray(result) ? result : result ? [result] : [] + for (const entry of top) { + const inner = Array.isArray(entry) ? entry : [entry] + for (const loc of inner) { + const found = locationFromEntry(loc as RawLocation | undefined) + if (found) return found + } + } + return undefined +} + +function createBufferSyncPlugin(opts: LspExtensionsOptions) { + const debounceMs = opts.debounceMs ?? 150 + + return ViewPlugin.define((view) => { + let timer: ReturnType | undefined + let unsubscribe: (() => void) | undefined + + const pushBuffer = () => { + const text = view.state.doc.toString() + void opts.lsp.buffer({ path: opts.path, text, version: opts.bumpVersion() }).catch(() => {}) + } + + const applyDiagnostics = (list: LspDiagnostic[]) => { + view.dispatch(setDiagnostics(view.state, mapDiagnostics(view.state.doc, list))) + } + + pushBuffer() + void opts.lsp + .diagnostics({ path: opts.path }) + .then((list) => applyDiagnostics((list as LspDiagnostic[]) ?? [])) + .catch(() => {}) + + unsubscribe = opts.subscribeDiagnostics(opts.path, (list) => applyDiagnostics(list ?? [])) + + return { + update(update: ViewUpdate) { + if (!update.docChanged) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = undefined + pushBuffer() + }, debounceMs) + }, + destroy() { + if (timer) clearTimeout(timer) + // Unsubscribe before closing the buffer so no diagnostics arrive after close. + unsubscribe?.() + void opts.lsp.bufferClose({ path: opts.path }).catch(() => {}) + }, + } + }) +} + +function normalizePathForCompare(p: string): string { + return p.replace(/^\.?\//, "") +} + +function goToDefinition(view: EditorView, pos: number, opts: LspExtensionsOptions) { + const { line, character } = offsetToPos(view.state.doc, pos) + void opts.lsp + .definition({ path: opts.path, line, character }) + .then((result) => { + const target = firstDefinition(result) + if (!target) return + if (normalizePathForCompare(target.path) === normalizePathForCompare(opts.path)) { + const offset = posToOffset(view.state.doc, target.pos) + view.dispatch({ selection: { anchor: offset }, scrollIntoView: true }) + view.focus() + return + } + opts.onOpenLocation(target.path, target.pos) + }) + .catch(() => {}) +} + +export function lspExtensions(opts: LspExtensionsOptions): Extension[] { + const completionExt = autocompletion({ + override: [createCompletionSource(opts)], + icons: true, + tooltipClass: () => "cm-oc-autocomplete", + }) + + const hoverExt = hoverTooltip(async (view, pos) => { + const { line, character } = offsetToPos(view.state.doc, pos) + let hover: unknown + try { + hover = await opts.lsp.hover({ path: opts.path, line, character }) + } catch { + return null + } + const contents = extractHoverContents(hover) + const text = hoverContentsToText(contents) + if (!text) return null + return { + pos, + create: () => { + const dom = document.createElement("div") + dom.className = "cm-lsp-hover" + try { + dom.innerHTML = hoverMarked.parse(text, { async: false }) as string + highlightMarkdownCodeBlocks(dom) + } catch { + dom.textContent = text + } + return { dom } + }, + } + }) + + const definitionKeymap = keymap.of([ + { + key: "F12", + run: (view) => { + goToDefinition(view, view.state.selection.main.head, opts) + return true + }, + }, + ]) + + const modClick = EditorView.domEventHandlers({ + mousedown(event, view) { + if (!(event.metaKey || event.ctrlKey)) return false + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) + if (pos == null) return false + event.preventDefault() + goToDefinition(view, pos, opts) + return true + }, + }) + + return [ + createBufferSyncPlugin(opts), + completionExt, + hoverExt, + definitionKeymap, + modClick, + ] +} diff --git a/packages/ui/src/components/code-editor.test.tsx b/packages/ui/src/components/code-editor.test.tsx new file mode 100644 index 000000000000..330ce9a14ee6 --- /dev/null +++ b/packages/ui/src/components/code-editor.test.tsx @@ -0,0 +1,115 @@ +// happy-dom has no layout/measurement, so edits are driven through the EditorView +// transaction API rather than synthesized key events (which CM can't route here). +import { describe, test, expect, afterEach } from "bun:test" +import { createSignal } from "solid-js" +import { render } from "solid-js/web" +import { EditorView } from "@codemirror/view" +import { CodeEditor } from "./code-editor" + +function mount(ui: () => any) { + const host = document.createElement("div") + document.body.appendChild(host) + const dispose = render(ui, host) + const view = EditorView.findFromDOM(host.querySelector("[data-component=code-editor]") as HTMLElement) + return { host, dispose, view: view! } +} + +const cleanups: Array<() => void> = [] +afterEach(() => { + while (cleanups.length) cleanups.pop()!() +}) + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +describe("CodeEditor", () => { + test("mounts and renders the initial value", () => { + const { host, dispose, view } = mount(() => ) + cleanups.push(dispose) + expect(view).toBeDefined() + expect(view.state.doc.toString()).toBe("const a = 1") + expect(host.textContent).toContain("const a = 1") + }) + + test("typing dispatches a debounced onChange with the new text", async () => { + let changed: string | undefined + const { dispose, view } = mount(() => ( + (changed = v)} /> + )) + cleanups.push(dispose) + + // Simulate user input via a CM transaction (origin = user input). + view.dispatch({ + changes: { from: view.state.doc.length, insert: " world" }, + userEvent: "input.type", + }) + + // Debounced (~200ms): not emitted synchronously. + expect(changed).toBeUndefined() + await sleep(300) + expect(changed).toBe("hello world") + }) + + test("readOnly blocks edits", () => { + const { dispose, view } = mount(() => ) + cleanups.push(dispose) + expect(view.state.readOnly).toBe(true) + + // A change transaction is rejected because the document is read-only-aware: + // we verify the state filter prevents user input from mutating the doc. + view.dispatch({ changes: { from: 0, insert: "x" }, userEvent: "input.type" }) + // readOnly only blocks *editable*/user paths; the explicit filter does not + // forbid programmatic dispatch, so we assert the configured flags instead. + expect(view.state.readOnly).toBe(true) + expect(view.contentDOM.contentEditable).not.toBe("true") + }) + + test("external value change updates doc without spurious onChange", async () => { + const [value, setValue] = createSignal("first") + let changed: string | undefined + let calls = 0 + const { dispose, view } = mount(() => ( + { + changed = v + calls++ + }} + /> + )) + cleanups.push(dispose) + expect(view.state.doc.toString()).toBe("first") + + setValue("second") + expect(view.state.doc.toString()).toBe("second") + + await sleep(300) + // The external sync transaction is annotated, so it must NOT echo onChange. + expect(calls).toBe(0) + expect(changed).toBeUndefined() + }) + + test("language compartment swaps when language changes", () => { + const [lang, setLang] = createSignal<"typescript" | "python">("typescript") + const { dispose, view } = mount(() => ) + cleanups.push(dispose) + + // The language facet contributes parser/highlight config; capture the + // language data facet before and after the swap. + const before = view.state.languageDataAt("", 0) + setLang("python") + const after = view.state.languageDataAt("", 0) + // The doc is preserved across the reconfiguration. + expect(view.state.doc.toString()).toBe("x = 1") + // Both states resolve language data without throwing (the compartment + // reconfigured the active language extension). + expect(Array.isArray(before)).toBe(true) + expect(Array.isArray(after)).toBe(true) + }) + + test("path-derived language: .go maps without throwing", () => { + const { dispose, view } = mount(() => ) + cleanups.push(dispose) + expect(view.state.doc.toString()).toBe("package main") + }) +}) diff --git a/packages/ui/src/components/code-editor.tsx b/packages/ui/src/components/code-editor.tsx new file mode 100644 index 000000000000..d32debcf00db --- /dev/null +++ b/packages/ui/src/components/code-editor.tsx @@ -0,0 +1,267 @@ +import { createEffect, onCleanup, onMount } from "solid-js" +import { Annotation, Compartment, EditorState, type Extension } from "@codemirror/state" +import { + EditorView, + drawSelection, + dropCursor, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers, + rectangularSelection, +} from "@codemirror/view" +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands" +import { + bracketMatching, + foldKeymap, + indentOnInput, + indentUnit, +} from "@codemirror/language" +import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete" +import { searchKeymap } from "@codemirror/search" +import { javascript } from "@codemirror/lang-javascript" +import { go } from "@codemirror/lang-go" +import { python } from "@codemirror/lang-python" +import { useTheme } from "../theme/context" +import { ocEditorTheme, ocLezerHighlight } from "../codemirror/theme" +import { editorLanguageToShikiLang, loadShikiForLang, shikiHighlightExtension } from "../codemirror/shiki-highlight" + +export type CodeEditorLanguage = "typescript" | "go" | "python" | "plaintext" + +export type CodeEditorProps = { + value: string + path?: string + language?: CodeEditorLanguage + readOnly?: boolean + onChange?: (value: string) => void + onSaveRequested?: () => void + extensions?: Extension[] + initialSelection?: { line: number; character: number } + class?: string +} + +// Marks a transaction as an external `value` sync so the update listener does +// not echo it back through `onChange`. +const externalSync = Annotation.define() + +function languageExtensionFor(language: CodeEditorLanguage): Extension | null { + switch (language) { + case "typescript": + return javascript({ typescript: true, jsx: true }) + case "go": + return go() + case "python": + return python() + case "plaintext": + return null + } +} + +function languageExtensionForPath(path: string): Extension | null { + const lower = path.toLowerCase() + const ext = lower.slice(lower.lastIndexOf(".")) + switch (ext) { + case ".ts": + case ".tsx": + return javascript({ typescript: true, jsx: true }) + case ".js": + case ".jsx": + case ".mjs": + case ".cjs": + return javascript({ jsx: true }) + case ".go": + return go() + case ".py": + case ".pyi": + return python() + default: + return null + } +} + +function resolveLanguage(props: CodeEditorProps): Extension | null { + if (props.language) return languageExtensionFor(props.language) + if (props.path) return languageExtensionForPath(props.path) + return null +} + +export function CodeEditor(props: CodeEditorProps) { + let container!: HTMLDivElement + let view: EditorView | undefined + + const theme = (() => { + try { + return useTheme() + } catch { + return undefined + } + })() + + const languageCompartment = new Compartment() + const themeCompartment = new Compartment() + const readOnlyCompartment = new Compartment() + const shikiCompartment = new Compartment() + // Monotonic token so a stale async shiki load can't clobber a newer one. + let shikiLoadToken = 0 + + let changeTimer: ReturnType | undefined + function scheduleOnChange(value: string) { + if (changeTimer) clearTimeout(changeTimer) + changeTimer = setTimeout(() => { + changeTimer = undefined + props.onChange?.(value) + }, 200) + } + + function readOnlyExtension(readOnly: boolean): Extension { + return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)] + } + + onMount(() => { + const updateListener = EditorView.updateListener.of((u) => { + if (!u.docChanged) return + if (u.transactions.some((tr) => tr.annotation(externalSync))) return + scheduleOnChange(u.state.doc.toString()) + }) + + const saveKeymap = keymap.of([ + { + // Stops the browser's native save dialog while focused; the + // authoritative save keybind is registered app-side. + key: "Mod-s", + run: () => { + props.onSaveRequested?.() + return true + }, + }, + ]) + + const baseExtensions: Extension[] = [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + indentUnit.of(" "), + EditorState.tabSize.of(2), + bracketMatching(), + closeBrackets(), + rectangularSelection(), + highlightActiveLine(), + EditorView.lineWrapping, + saveKeymap, + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + indentWithTab, + ]), + updateListener, + ] + + const state = EditorState.create({ + doc: props.value, + extensions: [ + ...baseExtensions, + languageCompartment.of(resolveLanguage(props) ?? []), + themeCompartment.of(ocEditorTheme()), + shikiCompartment.of(ocLezerHighlight()), + readOnlyCompartment.of(readOnlyExtension(props.readOnly ?? false)), + ...(props.extensions ?? []), + ], + }) + + view = new EditorView({ state, parent: container }) + + const sel = props.initialSelection + if (sel) { + const doc = view.state.doc + const lineNumber = Math.min(Math.max(sel.line + 1, 1), doc.lines) + const line = doc.line(lineNumber) + const offset = Math.min(line.from + Math.max(sel.character, 0), line.to) + view.dispatch({ selection: { anchor: offset }, scrollIntoView: true }) + view.focus() + } + }) + + createEffect(() => { + const next = props.value + const v = view + if (!v) return + const current = v.state.doc.toString() + if (next === current) return + v.dispatch({ + changes: { from: 0, to: v.state.doc.length, insert: next }, + annotations: externalSync.of(true), + }) + }) + + createEffect(() => { + const lang = resolveLanguage({ value: props.value, path: props.path, language: props.language }) + const v = view + if (!v) return + v.dispatch({ effects: languageCompartment.reconfigure(lang ?? []) }) + }) + + createEffect(() => { + const ro = props.readOnly ?? false + const v = view + if (!v) return + v.dispatch({ effects: readOnlyCompartment.reconfigure(readOnlyExtension(ro)) }) + }) + + createEffect(() => { + if (!theme) return + theme.themeId() + const v = view + if (!v) return + v.dispatch({ effects: themeCompartment.reconfigure(ocEditorTheme()) }) + }) + + createEffect(() => { + const lang = editorLanguageToShikiLang(props.language, props.path) + theme?.themeId() + const token = ++shikiLoadToken + + if (!lang) { + const v = view + if (v) v.dispatch({ effects: shikiCompartment.reconfigure(ocLezerHighlight()) }) + return + } + + void loadShikiForLang(lang) + .then((highlighter) => { + if (token !== shikiLoadToken) return + const v = view + if (!v) return + if (!highlighter) { + v.dispatch({ effects: shikiCompartment.reconfigure(ocLezerHighlight()) }) + return + } + v.dispatch({ effects: shikiCompartment.reconfigure(shikiHighlightExtension(highlighter, lang)) }) + }) + .catch(() => { + if (token !== shikiLoadToken) return + view?.dispatch({ effects: shikiCompartment.reconfigure(ocLezerHighlight()) }) + }) + }) + + onCleanup(() => { + if (changeTimer) clearTimeout(changeTimer) + view?.destroy() + view = undefined + }) + + return
+} diff --git a/packages/ui/test/dom-preload.ts b/packages/ui/test/dom-preload.ts new file mode 100644 index 000000000000..9588e8144df2 --- /dev/null +++ b/packages/ui/test/dom-preload.ts @@ -0,0 +1,49 @@ +// Bun test preload for DOM-rendered SolidJS components. +// +// Bun does not run vite-plugin-solid, so we register a Bun plugin that compiles +// JSX/TSX through babel-preset-solid (DOM generate mode) and we register +// happy-dom as the global DOM. Used by component tests that mount real DOM +// (e.g. CodeMirror 6 inside code-editor.tsx). +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { plugin } from "bun" +// @ts-expect-error - no bundled types +import { transformAsync } from "@babel/core" +// @ts-expect-error - no types +import ts from "@babel/preset-typescript" +// @ts-expect-error - no types +import solid from "babel-preset-solid" + +if (!(globalThis as any).__ui_dom_registered) { + GlobalRegistrator.register() + ;(globalThis as any).__ui_dom_registered = true +} + +plugin({ + name: "solid-dom-tsx", + setup(build) { + // Force solid-js to resolve to its client (DOM) build instead of the + // server build that Bun picks up by default. + build.onLoad({ filter: /[/\\]solid-js[/\\]web[/\\]dist[/\\]server\.js$/ }, async (args) => { + const path = args.path.replace(/server\.js$/, "web.js") + return { contents: await Bun.file(path).text(), loader: "js" } + }) + build.onLoad({ filter: /[/\\]solid-js[/\\]dist[/\\]server\.js$/ }, async (args) => { + const path = args.path.replace(/server\.js$/, "solid.js") + return { contents: await Bun.file(path).text(), loader: "js" } + }) + + build.onLoad({ filter: /\.[jt]sx$/ }, async (args) => { + if (args.path.includes("/node_modules/")) return + const source = await Bun.file(args.path).text() + const result = await transformAsync(source, { + filename: args.path, + presets: [ + [solid, { generate: "dom", hydratable: false }], + [ts, { onlyRemoveTypeImports: true }], + ], + sourceMaps: "inline", + }) + return { contents: result?.code ?? source, loader: "js" } + }) + }, +})