feat(terminal) refactoring and stability improvements (#98)
* feat(terminal): replace xterm with ghostty-web Replace xterm.js terminal with ghostty-web implementation Add terminal serialization support for state restoration Apply custom patches to ghostty-web for enhancements * feat(terminal): add bun-pty backend support Switch terminal to ghostty-web with bun-pty backend for better performance Auto-detect and prefer Bun runtime when available for terminal sessions Update terminal viewport write queue handling for improved reliability * fix(terminal): prevent unnecessary resize events Only report terminal resize when dimensions actually change Simplify chunk processing state tracking Disable terminal transparency for consistent rendering * feat(terminal): increase scrollback and buffer limits Increase terminal scrollback buffer from 10k to 50k lines Increase terminal buffer limit from 256k to 1M bytes Add rate limiting and improve output handling for terminal streams
This commit is contained in:
committed by
GitHub
parent
dca8be01ca
commit
110b0e5d8f
@@ -152,9 +152,9 @@ Backend: `packages/ui/src/lib/gitApi.ts` + `packages/web/server/index.js` (simpl
|
|||||||
|
|
||||||
### Terminal
|
### Terminal
|
||||||
`packages/ui/src/components/views/`: TerminalView
|
`packages/ui/src/components/views/`: TerminalView
|
||||||
`packages/ui/src/components/terminal/`: TerminalViewport (Xterm.js with FitAddon)
|
`packages/ui/src/components/terminal/`: TerminalViewport (ghostty-web with FitAddon)
|
||||||
`packages/ui/src/stores/`: useTerminalStore
|
`packages/ui/src/stores/`: useTerminalStore
|
||||||
Backend: `packages/web/server/index.js` (node-pty wrapper with SSE)
|
Backend: `packages/web/server/index.js` (bun-pty wrapper with SSE)
|
||||||
|
|
||||||
### Theme System
|
### Theme System
|
||||||
`packages/ui/src/lib/theme/`: themes (2 definitions), cssGenerator, syntaxThemeGenerator
|
`packages/ui/src/lib/theme/`: themes (2 definitions), cssGenerator, syntaxThemeGenerator
|
||||||
|
|||||||
@@ -23,17 +23,17 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"bun-pty": "^0.4.5",
|
||||||
"@xterm/xterm": "^5.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"electron-context-menu": "^4.1.1",
|
"electron-context-menu": "^4.1.1",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ghostty-web": "0.3.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.1.7",
|
||||||
|
"patch-package": "^8.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
@@ -115,17 +116,15 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
|
||||||
"@xterm/xterm": "^5.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ghostty-web": "0.3.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.0.0",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
@@ -206,15 +205,15 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"bun-pty": "^0.4.5",
|
||||||
"@xterm/xterm": "^5.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ghostty-web": "0.3.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -969,9 +968,7 @@
|
|||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||||
|
|
||||||
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
|
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
|
||||||
|
|
||||||
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
|
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
@@ -1071,6 +1068,8 @@
|
|||||||
|
|
||||||
"builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
|
"builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
|
||||||
|
|
||||||
|
"bun-pty": ["bun-pty@0.4.5", "", {}, "sha512-r8NL1C+z0Dicl9gyi0QV0DAPEBgoKO5CJuecbeS8fpfEkxBHy8XrJ7ibVBS+YRLWjcky3EKl8BY7nY+l4Jv8DQ=="],
|
||||||
|
|
||||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
@@ -1079,6 +1078,8 @@
|
|||||||
|
|
||||||
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
|
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
|
||||||
|
|
||||||
|
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
@@ -1469,6 +1470,8 @@
|
|||||||
|
|
||||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
|
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
|
||||||
|
|
||||||
"flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="],
|
"flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="],
|
||||||
|
|
||||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||||
@@ -1519,6 +1522,8 @@
|
|||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
|
"ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
|
||||||
|
|
||||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
|
|
||||||
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||||
@@ -1649,7 +1654,7 @@
|
|||||||
|
|
||||||
"is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
|
"is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
|
||||||
|
|
||||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
@@ -1669,9 +1674,9 @@
|
|||||||
|
|
||||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||||
|
|
||||||
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||||
|
|
||||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||||
|
|
||||||
@@ -1697,6 +1702,8 @@
|
|||||||
|
|
||||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
|
"json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
|
||||||
|
|
||||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||||
|
|
||||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||||
@@ -1707,6 +1714,8 @@
|
|||||||
|
|
||||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||||
|
|
||||||
|
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
|
||||||
|
|
||||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||||
|
|
||||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
@@ -1721,6 +1730,8 @@
|
|||||||
|
|
||||||
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||||
|
|
||||||
|
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
|
||||||
|
|
||||||
"langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="],
|
"langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="],
|
||||||
|
|
||||||
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
||||||
@@ -2001,7 +2012,7 @@
|
|||||||
|
|
||||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
|
"oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
|
||||||
|
|
||||||
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
|
||||||
|
|
||||||
"openchamber": ["openchamber@workspace:packages/vscode"],
|
"openchamber": ["openchamber@workspace:packages/vscode"],
|
||||||
|
|
||||||
@@ -2035,6 +2046,8 @@
|
|||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
|
"patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="],
|
||||||
|
|
||||||
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
|
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
@@ -2227,6 +2240,8 @@
|
|||||||
|
|
||||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
@@ -2257,7 +2272,7 @@
|
|||||||
|
|
||||||
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||||
|
|
||||||
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
|
||||||
|
|
||||||
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
|
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
|
||||||
|
|
||||||
@@ -2529,6 +2544,8 @@
|
|||||||
|
|
||||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
|
||||||
|
"@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||||
|
|
||||||
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
@@ -2679,6 +2696,8 @@
|
|||||||
|
|
||||||
"globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
"globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||||
|
|
||||||
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
|
|
||||||
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
@@ -2697,6 +2716,8 @@
|
|||||||
|
|
||||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||||
|
|
||||||
|
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||||
|
|
||||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
|
|
||||||
"keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="],
|
"keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="],
|
||||||
@@ -2773,6 +2794,8 @@
|
|||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||||
|
|
||||||
|
"wsl-utils/is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||||
|
|
||||||
"zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
|
"zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
@@ -2801,6 +2824,8 @@
|
|||||||
|
|
||||||
"app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|
||||||
|
"archiver-utils/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
"archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
"archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
"archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
@@ -2837,6 +2862,8 @@
|
|||||||
|
|
||||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||||
|
|
||||||
|
"lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|||||||
+5
-3
@@ -34,6 +34,7 @@
|
|||||||
"lint:ui": "bun run --cwd packages/ui lint",
|
"lint:ui": "bun run --cwd packages/ui lint",
|
||||||
"lint:desktop": "bun run --cwd packages/desktop lint",
|
"lint:desktop": "bun run --cwd packages/desktop lint",
|
||||||
"clean": "bun run --filter '*' clean",
|
"clean": "bun run --filter '*' clean",
|
||||||
|
"postinstall": "patch-package",
|
||||||
"dev:web": "bun run --cwd packages/web build:watch",
|
"dev:web": "bun run --cwd packages/web build:watch",
|
||||||
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
|
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
|
||||||
"dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"",
|
"dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"",
|
||||||
@@ -74,8 +75,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"ghostty-web": "0.3.0",
|
||||||
"@xterm/xterm": "^5.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -84,7 +84,8 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.0.0",
|
"bun-pty": "^0.4.5",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.1.7",
|
||||||
|
"patch-package": "^8.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
|
|||||||
@@ -73,11 +73,13 @@ async fn run_once(
|
|||||||
let prefix = opencode.api_prefix();
|
let prefix = opencode.api_prefix();
|
||||||
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
||||||
|
|
||||||
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
|
if let Some(dir) = opencode
|
||||||
|
.get_working_directory()
|
||||||
|
.to_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
let mut parsed = reqwest::Url::parse(&url)?;
|
let mut parsed = reqwest::Url::parse(&url)?;
|
||||||
parsed
|
parsed.query_pairs_mut().append_pair("directory", &dir);
|
||||||
.query_pairs_mut()
|
|
||||||
.append_pair("directory", &dir);
|
|
||||||
url = parsed.to_string();
|
url = parsed.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{DesktopRuntime, SettingsStore};
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
use crate::path_utils::expand_tilde_path;
|
||||||
|
use crate::{DesktopRuntime, SettingsStore};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashSet, VecDeque},
|
collections::{HashSet, VecDeque},
|
||||||
@@ -233,7 +233,11 @@ pub async fn search_files(
|
|||||||
let match_all = normalized_query.is_empty();
|
let match_all = normalized_query.is_empty();
|
||||||
|
|
||||||
// Collect more candidates for fuzzy matching, then sort and trim
|
// Collect more candidates for fuzzy matching, then sort and trim
|
||||||
let collect_limit = if match_all { limit } else { (limit * 3).max(200) };
|
let collect_limit = if match_all {
|
||||||
|
limit
|
||||||
|
} else {
|
||||||
|
(limit * 3).max(200)
|
||||||
|
};
|
||||||
|
|
||||||
let mut candidates: Vec<ScoredFileHit> = Vec::new();
|
let mut candidates: Vec<ScoredFileHit> = Vec::new();
|
||||||
let mut queue = VecDeque::new();
|
let mut queue = VecDeque::new();
|
||||||
@@ -314,16 +318,14 @@ pub async fn search_files(
|
|||||||
|
|
||||||
// Sort by score descending, then by path length, then alphabetically
|
// Sort by score descending, then by path length, then alphabetically
|
||||||
if !match_all {
|
if !match_all {
|
||||||
candidates.sort_by(|a, b| {
|
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
|
||||||
match b.score.cmp(&a.score) {
|
std::cmp::Ordering::Equal => {
|
||||||
std::cmp::Ordering::Equal => {
|
match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
|
||||||
match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
|
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path),
|
||||||
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path),
|
other => other,
|
||||||
other => other,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
other => other,
|
|
||||||
}
|
}
|
||||||
|
other => other,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +499,11 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let search_start = if last_index < 0 { 0 } else { (last_index + 1) as usize };
|
let search_start = if last_index < 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(last_index + 1) as usize
|
||||||
|
};
|
||||||
let idx = c[search_start..].iter().position(|&c_char| c_char == *ch);
|
let idx = c[search_start..].iter().position(|&c_char| c_char == *ch);
|
||||||
|
|
||||||
match idx {
|
match idx {
|
||||||
@@ -520,7 +526,8 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
|
|||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
score += 12;
|
score += 12;
|
||||||
} else if let Some(prev) = c.get(idx - 1) {
|
} else if let Some(prev) = c.get(idx - 1) {
|
||||||
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' {
|
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' '
|
||||||
|
{
|
||||||
score += 10;
|
score += 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{DesktopRuntime, SettingsStore};
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
use crate::path_utils::expand_tilde_path;
|
||||||
|
use crate::{DesktopRuntime, SettingsStore};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
@@ -435,7 +435,10 @@ async fn resolve_git_paths(root: &Path, path_str: &str) -> (PathBuf, PathBuf, St
|
|||||||
input_path.to_path_buf()
|
input_path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
let from_root = root.join(input_path);
|
let from_root = root.join(input_path);
|
||||||
if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS).await.is_ok() {
|
if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
from_root
|
from_root
|
||||||
} else {
|
} else {
|
||||||
repo_root.join(input_path)
|
repo_root.join(input_path)
|
||||||
@@ -832,13 +835,10 @@ pub async fn get_git_status(
|
|||||||
|
|
||||||
let mut selected_base: Option<String> = None;
|
let mut selected_base: Option<String> = None;
|
||||||
for candidate in base_candidates {
|
for candidate in base_candidates {
|
||||||
let verified = run_git_with_allowed_exit(
|
let verified =
|
||||||
&["rev-parse", "--verify", &candidate],
|
run_git_with_allowed_exit(&["rev-parse", "--verify", &candidate], &path, &[1])
|
||||||
&path,
|
.await
|
||||||
&[1],
|
.unwrap_or_default();
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if !verified.trim().is_empty() {
|
if !verified.trim().is_empty() {
|
||||||
selected_base = Some(candidate);
|
selected_base = Some(candidate);
|
||||||
@@ -916,7 +916,9 @@ pub async fn get_git_diff(
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif"];
|
const IMAGE_EXTENSIONS: &[&str] = &[
|
||||||
|
"png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif",
|
||||||
|
];
|
||||||
|
|
||||||
fn is_image_file(path: &str) -> bool {
|
fn is_image_file(path: &str) -> bool {
|
||||||
if let Some(ext) = path.rsplit('.').next() {
|
if let Some(ext) = path.rsplit('.').next() {
|
||||||
@@ -970,7 +972,11 @@ fn cap_ipc_payload(value: String) -> String {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
truncate_string_to_char_boundary(value, GIT_FILE_IPC_MAX_CHARS, "\n…(truncated for desktop)\n")
|
truncate_string_to_char_boundary(
|
||||||
|
value,
|
||||||
|
GIT_FILE_IPC_MAX_CHARS,
|
||||||
|
"\n…(truncated for desktop)\n",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
|
async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
|
||||||
@@ -983,8 +989,8 @@ pub async fn get_git_file_diff(
|
|||||||
path_str: String,
|
path_str: String,
|
||||||
state: State<'_, DesktopRuntime>,
|
state: State<'_, DesktopRuntime>,
|
||||||
) -> Result<(String, String), String> {
|
) -> Result<(String, String), String> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|
||||||
|
|
||||||
let root = validate_git_path(&directory, state.settings())
|
let root = validate_git_path(&directory, state.settings())
|
||||||
.await
|
.await
|
||||||
@@ -992,7 +998,11 @@ pub async fn get_git_file_diff(
|
|||||||
|
|
||||||
let (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await;
|
let (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await;
|
||||||
let is_image = is_image_file(&relative_path);
|
let is_image = is_image_file(&relative_path);
|
||||||
let mime_type = if is_image { get_image_mime_type(&relative_path) } else { "" };
|
let mime_type = if is_image {
|
||||||
|
get_image_mime_type(&relative_path)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
// Original from HEAD
|
// Original from HEAD
|
||||||
let original = if is_image {
|
let original = if is_image {
|
||||||
@@ -1035,47 +1045,50 @@ pub async fn get_git_file_diff(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Modified from working tree (if file exists)
|
// Modified from working tree (if file exists)
|
||||||
let modified = if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await {
|
let modified =
|
||||||
if metadata.is_file() {
|
if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await {
|
||||||
if is_image {
|
if metadata.is_file() {
|
||||||
// For images, read as binary and convert to data URL
|
if is_image {
|
||||||
if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES {
|
// For images, read as binary and convert to data URL
|
||||||
String::new()
|
if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS),
|
||||||
|
fs::read(&full_path),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(bytes)) => {
|
||||||
|
format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes))
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
match tokio::time::timeout(
|
match read_file_bytes_limited_with_timeout(
|
||||||
std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS),
|
&full_path,
|
||||||
fs::read(&full_path),
|
GIT_FILE_TEXT_MAX_BYTES,
|
||||||
|
GIT_FILE_DIFF_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(bytes)) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)),
|
Ok((bytes, truncated)) => {
|
||||||
_ => String::new(),
|
let mut text = String::from_utf8_lossy(&bytes).to_string();
|
||||||
|
if truncated {
|
||||||
|
text.push_str("\n…(truncated)\n");
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
Err(_) => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match read_file_bytes_limited_with_timeout(
|
String::new()
|
||||||
&full_path,
|
|
||||||
GIT_FILE_TEXT_MAX_BYTES,
|
|
||||||
GIT_FILE_DIFF_TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok((bytes, truncated)) => {
|
|
||||||
let mut text = String::from_utf8_lossy(&bytes).to_string();
|
|
||||||
if truncated {
|
|
||||||
text.push_str("\n…(truncated)\n");
|
|
||||||
}
|
|
||||||
text
|
|
||||||
}
|
|
||||||
Err(_) => String::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((cap_ipc_payload(original), cap_ipc_payload(modified)))
|
Ok((cap_ipc_payload(original), cap_ipc_payload(modified)))
|
||||||
}
|
}
|
||||||
@@ -1144,31 +1157,32 @@ pub async fn get_git_branches(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Discover actual remote heads so we can drop stale remote-tracking refs
|
// Discover actual remote heads so we can drop stale remote-tracking refs
|
||||||
let allowed_remote_heads: Option<HashSet<String>> = match run_git_bytes_with_allowed_exit_timeout(
|
let allowed_remote_heads: Option<HashSet<String>> =
|
||||||
&["ls-remote", "--heads", "origin"],
|
match run_git_bytes_with_allowed_exit_timeout(
|
||||||
&root,
|
&["ls-remote", "--heads", "origin"],
|
||||||
&[0],
|
&root,
|
||||||
GIT_LS_REMOTE_TIMEOUT_MS,
|
&[0],
|
||||||
)
|
GIT_LS_REMOTE_TIMEOUT_MS,
|
||||||
.await
|
)
|
||||||
{
|
.await
|
||||||
Ok(bytes) => {
|
{
|
||||||
let ls_remote = String::from_utf8_lossy(&bytes);
|
Ok(bytes) => {
|
||||||
let mut set = HashSet::new();
|
let ls_remote = String::from_utf8_lossy(&bytes);
|
||||||
for line in ls_remote.lines() {
|
let mut set = HashSet::new();
|
||||||
if let Some((_, ref_name)) = line.split_once('\t') {
|
for line in ls_remote.lines() {
|
||||||
if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") {
|
if let Some((_, ref_name)) = line.split_once('\t') {
|
||||||
set.insert(stripped.to_string());
|
if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") {
|
||||||
|
set.insert(stripped.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(set)
|
||||||
}
|
}
|
||||||
Some(set)
|
Err(err) => {
|
||||||
}
|
warn!("Failed to list remote heads: {}", err);
|
||||||
Err(err) => {
|
None
|
||||||
warn!("Failed to list remote heads: {}", err);
|
}
|
||||||
None
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Structured for-each-ref output so we can mark remotes consistently with the web runtime
|
// Structured for-each-ref output so we can mark remotes consistently with the web runtime
|
||||||
let output = run_git(
|
let output = run_git(
|
||||||
@@ -1518,21 +1532,15 @@ pub async fn git_push(
|
|||||||
let remote_key = format!("branch.{}.remote", branch_name);
|
let remote_key = format!("branch.{}.remote", branch_name);
|
||||||
let merge_key = format!("branch.{}.merge", branch_name);
|
let merge_key = format!("branch.{}.merge", branch_name);
|
||||||
|
|
||||||
let upstream_remote = run_git_with_allowed_exit(
|
let upstream_remote =
|
||||||
&["config", "--get", &remote_key],
|
run_git_with_allowed_exit(&["config", "--get", &remote_key], &root, &[1])
|
||||||
&root,
|
.await
|
||||||
&[1],
|
.unwrap_or_default();
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let upstream_merge = run_git_with_allowed_exit(
|
let upstream_merge =
|
||||||
&["config", "--get", &merge_key],
|
run_git_with_allowed_exit(&["config", "--get", &merge_key], &root, &[1])
|
||||||
&root,
|
.await
|
||||||
&[1],
|
.unwrap_or_default();
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() {
|
if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() {
|
||||||
args.push("--set-upstream".to_string());
|
args.push("--set-upstream".to_string());
|
||||||
@@ -1904,7 +1912,10 @@ pub async fn get_commit_files(
|
|||||||
file.path.clone()
|
file.path.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(status) = status_map.get(&base_path).or_else(|| status_map.get(&file.path)) {
|
if let Some(status) = status_map
|
||||||
|
.get(&base_path)
|
||||||
|
.or_else(|| status_map.get(&file.path))
|
||||||
|
{
|
||||||
file.change_type = status.clone();
|
file.change_type = status.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
|
pub mod notifications;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod notifications;
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::DesktopRuntime;
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
use crate::path_utils::expand_tilde_path;
|
||||||
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use serde_json::{json, Value};
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::DesktopRuntime;
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
use crate::path_utils::expand_tilde_path;
|
||||||
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -160,7 +160,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
|
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
|
||||||
let parsed = n
|
let parsed = n
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.or_else(|| n.as_i64().and_then(|value| if value >= 0 { Some(value as u64) } else { None }))
|
.or_else(|| {
|
||||||
|
n.as_i64()
|
||||||
|
.and_then(|value| if value >= 0 { Some(value as u64) } else { None })
|
||||||
|
})
|
||||||
.or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
|
.or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
|
||||||
if let Some(value) = parsed {
|
if let Some(value) = parsed {
|
||||||
let clamped = value.max(1).min(365);
|
let clamped = value.max(1).min(365);
|
||||||
@@ -198,13 +201,31 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
let mut catalogs: Vec<Value> = vec![];
|
let mut catalogs: Vec<Value> = vec![];
|
||||||
|
|
||||||
for entry in arr {
|
for entry in arr {
|
||||||
let Some(obj) = entry.as_object() else { continue };
|
let Some(obj) = entry.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||||
let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim();
|
let label = obj
|
||||||
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.get("label")
|
||||||
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.and_then(|v| v.as_str())
|
||||||
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let source = obj
|
||||||
|
.get("source")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let subpath = obj
|
||||||
|
.get("subpath")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let git_identity_id = obj
|
||||||
|
.get("gitIdentityId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
if id.is_empty() || label.is_empty() || source.is_empty() {
|
if id.is_empty() || label.is_empty() || source.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use log::error;
|
use log::error;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -6,8 +7,9 @@ use std::{
|
|||||||
env,
|
env,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::Arc,
|
||||||
thread,
|
thread,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tauri::{Emitter, State, Window};
|
use tauri::{Emitter, State, Window};
|
||||||
|
|
||||||
@@ -18,6 +20,10 @@ const DEFAULT_LOCALE: &str = "en_US.UTF-8";
|
|||||||
const TERM_PROGRAM_NAME: &str = "OpenChamber";
|
const TERM_PROGRAM_NAME: &str = "OpenChamber";
|
||||||
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
// Emit at most ~60fps and avoid tiny payload spam.
|
||||||
|
const EMIT_INTERVAL: Duration = Duration::from_millis(16);
|
||||||
|
const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
pub struct TerminalSession {
|
pub struct TerminalSession {
|
||||||
pub master: Box<dyn MasterPty + Send>,
|
pub master: Box<dyn MasterPty + Send>,
|
||||||
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
@@ -94,7 +100,7 @@ pub async fn create_terminal_session(
|
|||||||
let child = Arc::new(Mutex::new(child));
|
let child = Arc::new(Mutex::new(child));
|
||||||
|
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
state.sessions.lock().unwrap().insert(
|
state.sessions.lock().insert(
|
||||||
session_id.clone(),
|
session_id.clone(),
|
||||||
TerminalSession {
|
TerminalSession {
|
||||||
master,
|
master,
|
||||||
@@ -115,21 +121,18 @@ pub async fn send_terminal_input(
|
|||||||
data: String,
|
data: String,
|
||||||
state: State<'_, TerminalState>,
|
state: State<'_, TerminalState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sessions = state.sessions.lock().unwrap();
|
let writer = {
|
||||||
let Some(session) = sessions.get(&session_id) else {
|
let sessions = state.sessions.lock();
|
||||||
return Err("Terminal session not found".to_string());
|
let Some(session) = sessions.get(&session_id) else {
|
||||||
|
return Err("Terminal session not found".to_string());
|
||||||
|
};
|
||||||
|
session.writer.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut writer = session
|
let mut guard = writer.lock();
|
||||||
.writer
|
guard
|
||||||
.lock()
|
|
||||||
.map_err(|_| "Terminal busy".to_string())?;
|
|
||||||
writer
|
|
||||||
.write_all(data.as_bytes())
|
.write_all(data.as_bytes())
|
||||||
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
||||||
writer
|
|
||||||
.flush()
|
|
||||||
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +143,7 @@ pub async fn resize_terminal(
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
state: State<'_, TerminalState>,
|
state: State<'_, TerminalState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut sessions = state.sessions.lock().unwrap();
|
let mut sessions = state.sessions.lock();
|
||||||
let Some(session) = sessions.get_mut(&session_id) else {
|
let Some(session) = sessions.get_mut(&session_id) else {
|
||||||
return Err("Terminal session not found".to_string());
|
return Err("Terminal session not found".to_string());
|
||||||
};
|
};
|
||||||
@@ -154,6 +157,7 @@ pub async fn resize_terminal(
|
|||||||
pixel_height: 0,
|
pixel_height: 0,
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
|
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,15 +166,10 @@ pub async fn close_terminal(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, TerminalState>,
|
state: State<'_, TerminalState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let session = {
|
let session = { state.sessions.lock().remove(&session_id) };
|
||||||
let mut sessions = state.sessions.lock().unwrap();
|
|
||||||
sessions.remove(&session_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(session) = session {
|
if let Some(session) = session {
|
||||||
if let Ok(mut child) = session.child.lock() {
|
let _ = session.child.lock().kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -191,11 +190,9 @@ pub async fn restart_terminal_session(
|
|||||||
window: Window,
|
window: Window,
|
||||||
) -> Result<CreateTerminalResponse, String> {
|
) -> Result<CreateTerminalResponse, String> {
|
||||||
{
|
{
|
||||||
let mut sessions = state.sessions.lock().unwrap();
|
let session = state.sessions.lock().remove(&payload.session_id);
|
||||||
if let Some(session) = sessions.remove(&payload.session_id) {
|
if let Some(session) = session {
|
||||||
if let Ok(mut child) = session.child.lock() {
|
let _ = session.child.lock().kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +236,7 @@ pub async fn restart_terminal_session(
|
|||||||
let child = Arc::new(Mutex::new(child));
|
let child = Arc::new(Mutex::new(child));
|
||||||
|
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
state.sessions.lock().unwrap().insert(
|
state.sessions.lock().insert(
|
||||||
session_id.clone(),
|
session_id.clone(),
|
||||||
TerminalSession {
|
TerminalSession {
|
||||||
master,
|
master,
|
||||||
@@ -265,65 +262,145 @@ pub async fn force_kill_terminal(
|
|||||||
payload: ForceKillPayload,
|
payload: ForceKillPayload,
|
||||||
state: State<'_, TerminalState>,
|
state: State<'_, TerminalState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut sessions = state.sessions.lock().unwrap();
|
let mut sessions = state.sessions.lock();
|
||||||
|
|
||||||
if let Some(session_id) = payload.session_id {
|
if let Some(session_id) = payload.session_id {
|
||||||
// Kill by session_id
|
|
||||||
if let Some(session) = sessions.remove(&session_id) {
|
if let Some(session) = sessions.remove(&session_id) {
|
||||||
if let Ok(mut child) = session.child.lock() {
|
let _ = session.child.lock().kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if let Some(cwd) = payload.cwd {
|
return Ok(());
|
||||||
let ids: Vec<String> = sessions.keys().cloned().collect();
|
}
|
||||||
for id in ids {
|
|
||||||
if let Some(session) = sessions.remove(&id) {
|
// Current API ignores cwd; keep behavior but avoid holding poisoned locks.
|
||||||
if let Ok(mut child) = session.child.lock() {
|
let _ = payload.cwd;
|
||||||
let _ = child.kill();
|
|
||||||
}
|
let ids: Vec<String> = sessions.keys().cloned().collect();
|
||||||
}
|
for id in ids {
|
||||||
}
|
if let Some(session) = sessions.remove(&id) {
|
||||||
let _ = cwd;
|
let _ = session.child.lock().kill();
|
||||||
} else {
|
|
||||||
let ids: Vec<String> = sessions.keys().cloned().collect();
|
|
||||||
for id in ids {
|
|
||||||
if let Some(session) = sessions.remove(&id) {
|
|
||||||
if let Ok(mut child) = session.child.lock() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
fn spawn_reader_thread(reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut buffer = [0u8; 16384];
|
use std::sync::mpsc;
|
||||||
let event_name = format!("terminal://{}", session_id);
|
|
||||||
loop {
|
let event_name = format!("terminal://{}", session_id);
|
||||||
match reader.read(&mut buffer) {
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => {
|
// Dedicated blocking reader thread.
|
||||||
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
|
let reader_handle = thread::spawn(move || {
|
||||||
if data.is_empty() {
|
let mut reader = reader;
|
||||||
continue;
|
let mut buffer = [0u8; 16384];
|
||||||
}
|
loop {
|
||||||
|
match reader.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if tx.send(buffer[..n].to_vec()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut pending = String::new();
|
||||||
|
let mut pending_bytes: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
let flush = |pending: &mut String| -> bool {
|
||||||
|
if pending.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_data = std::mem::take(pending);
|
||||||
|
let payload = serde_json::json!({ "type": "data", "data": payload_data });
|
||||||
|
|
||||||
|
match window.emit(&event_name, payload) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(error) => {
|
||||||
|
error!("Failed to emit terminal data: {error}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let decode_pending = |pending_bytes: &mut Vec<u8>, pending: &mut String| {
|
||||||
|
loop {
|
||||||
|
match std::str::from_utf8(pending_bytes) {
|
||||||
|
Ok(text) => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
pending.push_str(text);
|
||||||
|
}
|
||||||
|
pending_bytes.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let valid = error.valid_up_to();
|
||||||
|
if valid > 0 {
|
||||||
|
let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or("");
|
||||||
|
if !text.is_empty() {
|
||||||
|
pending.push_str(text);
|
||||||
|
}
|
||||||
|
pending_bytes.drain(..valid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incomplete UTF-8 at end; wait for more bytes.
|
||||||
|
if error.error_len().is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid leading byte; consume 1 byte and replace.
|
||||||
|
if !pending_bytes.is_empty() {
|
||||||
|
pending_bytes.drain(..1);
|
||||||
|
pending.push('\u{FFFD}');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(error) =
|
|
||||||
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
|
|
||||||
{
|
|
||||||
error!("Failed to emit terminal data: {error}");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
}
|
||||||
error!("Terminal read error: {error}");
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(EMIT_INTERVAL) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
pending_bytes.extend_from_slice(&bytes);
|
||||||
|
decode_pending(&mut pending_bytes, &mut pending);
|
||||||
|
|
||||||
|
if pending.len() >= EMIT_MAX_BUFFER_BYTES {
|
||||||
|
if !flush(&mut pending) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
// Flush any buffered output even if the PTY is idle.
|
||||||
|
if !pending_bytes.is_empty() {
|
||||||
|
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
|
||||||
|
pending_bytes.clear();
|
||||||
|
}
|
||||||
|
if !flush(&mut pending) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
if !pending_bytes.is_empty() {
|
||||||
|
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
|
||||||
|
pending_bytes.clear();
|
||||||
|
}
|
||||||
|
let _ = flush(&mut pending);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = reader_handle.join();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +411,7 @@ fn spawn_exit_watcher(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
) {
|
) {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let status = {
|
let status = { child.lock().wait() };
|
||||||
let mut guard = child.lock().expect("terminal child poisoned");
|
|
||||||
guard.wait()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (exit_code, signal) = match status {
|
let (exit_code, signal) = match status {
|
||||||
Ok(status) => (
|
Ok(status) => (
|
||||||
@@ -358,8 +432,7 @@ fn spawn_exit_watcher(
|
|||||||
});
|
});
|
||||||
let _ = window.emit(&event_name, payload);
|
let _ = window.emit(&event_name, payload);
|
||||||
|
|
||||||
let mut sessions = sessions.lock().unwrap();
|
sessions.lock().remove(&session_id);
|
||||||
sessions.remove(&session_id);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,9 +461,7 @@ fn shell_accepts_login_flag(shell_path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
|
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
|
||||||
let maybe_path = input
|
let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir());
|
||||||
.map(|value| PathBuf::from(value))
|
|
||||||
.or_else(|| dirs::home_dir());
|
|
||||||
|
|
||||||
let Some(path) = maybe_path else {
|
let Some(path) = maybe_path else {
|
||||||
return Err("Unable to determine working directory".to_string());
|
return Err("Unable to determine working directory".to_string());
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod assistant_notifications;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod assistant_notifications;
|
|
||||||
mod session_activity;
|
|
||||||
mod opencode_auth;
|
mod opencode_auth;
|
||||||
mod opencode_config;
|
mod opencode_config;
|
||||||
mod opencode_manager;
|
mod opencode_manager;
|
||||||
mod window_state;
|
|
||||||
mod path_utils;
|
mod path_utils;
|
||||||
|
mod session_activity;
|
||||||
mod skills_catalog;
|
mod skills_catalog;
|
||||||
|
mod window_state;
|
||||||
|
|
||||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
use assistant_notifications::spawn_assistant_notifications;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{to_bytes, Body},
|
body::{to_bytes, Body},
|
||||||
extract::{OriginalUri, State},
|
extract::{OriginalUri, State},
|
||||||
@@ -22,23 +28,22 @@ use axum::{
|
|||||||
routing::{any, get, post},
|
routing::{any, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use assistant_notifications::spawn_assistant_notifications;
|
|
||||||
use session_activity::spawn_session_activity_tracker;
|
|
||||||
use commands::files::{create_directory, list_directory, search_files};
|
use commands::files::{create_directory, list_directory, search_files};
|
||||||
use commands::git::{
|
use commands::git::{
|
||||||
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
|
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
|
||||||
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
||||||
ensure_openchamber_ignored, generate_commit_message, get_commit_files, get_current_git_identity,
|
ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
||||||
get_git_branches, get_git_diff, get_git_file_diff, get_git_identities, get_git_log, get_git_status,
|
get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
||||||
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
|
get_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push,
|
||||||
revert_git_file, set_git_identity, update_git_identity,
|
is_linked_worktree, list_git_worktrees, remove_git_worktree, revert_git_file, set_git_identity,
|
||||||
|
update_git_identity,
|
||||||
};
|
};
|
||||||
use commands::logs::fetch_desktop_logs;
|
use commands::logs::fetch_desktop_logs;
|
||||||
|
use commands::notifications::desktop_notify;
|
||||||
use commands::permissions::{
|
use commands::permissions::{
|
||||||
pick_directory, process_directory_selection, request_directory_access,
|
pick_directory, process_directory_selection, request_directory_access,
|
||||||
restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory,
|
restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory,
|
||||||
};
|
};
|
||||||
use commands::notifications::desktop_notify;
|
|
||||||
use commands::settings::{load_settings, restart_opencode, save_settings};
|
use commands::settings::{load_settings, restart_opencode, save_settings};
|
||||||
use commands::terminal::{
|
use commands::terminal::{
|
||||||
close_terminal, create_terminal_session, force_kill_terminal, resize_terminal,
|
close_terminal, create_terminal_session, force_kill_terminal, resize_terminal,
|
||||||
@@ -47,13 +52,15 @@ use commands::terminal::{
|
|||||||
use futures_util::StreamExt as FuturesStreamExt;
|
use futures_util::StreamExt as FuturesStreamExt;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use opencode_manager::OpenCodeManager;
|
use opencode_manager::OpenCodeManager;
|
||||||
|
use path_utils::expand_tilde_path;
|
||||||
use portpicker::pick_unused_port;
|
use portpicker::pick_unused_port;
|
||||||
use reqwest::{header, Body as ReqwestBody, Client};
|
use reqwest::{header, Body as ReqwestBody, Client};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tauri::{Emitter, Manager};
|
use session_activity::spawn_session_activity_tracker;
|
||||||
#[cfg(feature = "devtools")]
|
#[cfg(feature = "devtools")]
|
||||||
use tauri::WebviewWindow;
|
use tauri::WebviewWindow;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
use tauri_plugin_dialog::init as dialog_plugin;
|
use tauri_plugin_dialog::init as dialog_plugin;
|
||||||
use tauri_plugin_fs::init as fs_plugin;
|
use tauri_plugin_fs::init as fs_plugin;
|
||||||
use tauri_plugin_log::{Target, TargetKind};
|
use tauri_plugin_log::{Target, TargetKind};
|
||||||
@@ -66,7 +73,6 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use window_state::{load_window_state, persist_window_state, WindowStateManager};
|
use window_state::{load_window_state, persist_window_state, WindowStateManager};
|
||||||
use path_utils::expand_tilde_path;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -134,8 +140,10 @@ const MENU_ITEM_HELP_DIALOG_ID: &str = "openchamber_help_dialog";
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs";
|
const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs";
|
||||||
|
|
||||||
const GITHUB_BUG_REPORT_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml";
|
const GITHUB_BUG_REPORT_URL: &str =
|
||||||
const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml";
|
"https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml";
|
||||||
|
const GITHUB_FEATURE_REQUEST_URL: &str =
|
||||||
|
"https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml";
|
||||||
const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA";
|
const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -149,7 +157,9 @@ pub(crate) struct DesktopRuntime {
|
|||||||
impl DesktopRuntime {
|
impl DesktopRuntime {
|
||||||
fn initialize_sync() -> Result<Self> {
|
fn initialize_sync() -> Result<Self> {
|
||||||
let settings = Arc::new(SettingsStore::new()?);
|
let settings = Arc::new(SettingsStore::new()?);
|
||||||
let initial_dir = tauri::async_runtime::block_on(settings.last_directory()).ok().flatten();
|
let initial_dir = tauri::async_runtime::block_on(settings.last_directory())
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
|
let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
|
||||||
|
|
||||||
let client = Client::builder().build()?;
|
let client = Client::builder().build()?;
|
||||||
@@ -265,7 +275,13 @@ struct ServerInfoPayload {
|
|||||||
async fn desktop_server_info(
|
async fn desktop_server_info(
|
||||||
state: tauri::State<'_, DesktopRuntime>,
|
state: tauri::State<'_, DesktopRuntime>,
|
||||||
) -> Result<ServerInfoPayload, String> {
|
) -> Result<ServerInfoPayload, String> {
|
||||||
let has_last_directory = state.settings().last_directory().await.ok().flatten().is_some();
|
let has_last_directory = state
|
||||||
|
.settings()
|
||||||
|
.last_directory()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
Ok(ServerInfoPayload {
|
Ok(ServerInfoPayload {
|
||||||
server_port: state.server_port,
|
server_port: state.server_port,
|
||||||
opencode_port: state.opencode.current_port(),
|
opencode_port: state.opencode.current_port(),
|
||||||
@@ -300,10 +316,14 @@ fn get_macos_major_version() -> isize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn adjust_traffic_lights_position<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, x: f64, y: f64) {
|
fn adjust_traffic_lights_position<R: tauri::Runtime>(
|
||||||
use objc2::runtime::AnyObject;
|
window: &tauri::WebviewWindow<R>,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
) {
|
||||||
use objc2::msg_send;
|
use objc2::msg_send;
|
||||||
use objc2_foundation::{NSRect, NSPoint};
|
use objc2::runtime::AnyObject;
|
||||||
|
use objc2_foundation::{NSPoint, NSRect};
|
||||||
|
|
||||||
if let Ok(ns_window) = window.ns_window() {
|
if let Ok(ns_window) = window.ns_window() {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -338,8 +358,12 @@ fn prevent_app_nap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
|
fn build_macos_menu<R: tauri::Runtime>(
|
||||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID};
|
app: &tauri::AppHandle<R>,
|
||||||
|
) -> tauri::Result<tauri::menu::Menu<R>> {
|
||||||
|
use tauri::menu::{
|
||||||
|
Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
||||||
|
};
|
||||||
|
|
||||||
let pkg_info = app.package_info();
|
let pkg_info = app.package_info();
|
||||||
|
|
||||||
@@ -360,13 +384,7 @@ fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Resu
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
// App menu items
|
// App menu items
|
||||||
let settings = MenuItem::with_id(
|
let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?;
|
||||||
app,
|
|
||||||
MENU_ITEM_SETTINGS_ID,
|
|
||||||
"Settings",
|
|
||||||
true,
|
|
||||||
Some("Cmd+,"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let command_palette = MenuItem::with_id(
|
let command_palette = MenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
@@ -402,13 +420,8 @@ fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Resu
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
// View menu items
|
// View menu items
|
||||||
let open_git_tab = MenuItem::with_id(
|
let open_git_tab =
|
||||||
app,
|
MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Ctrl+G"))?;
|
||||||
MENU_ITEM_OPEN_GIT_TAB_ID,
|
|
||||||
"Git",
|
|
||||||
true,
|
|
||||||
Some("Ctrl+G"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let open_diff_tab = MenuItem::with_id(
|
let open_diff_tab = MenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
@@ -664,12 +677,21 @@ fn main() {
|
|||||||
info!("[macos] Detected macOS version: {}", macos_version);
|
info!("[macos] Detected macOS version: {}", macos_version);
|
||||||
|
|
||||||
let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 };
|
let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 };
|
||||||
if let Err(error) =
|
if let Err(error) = apply_vibrancy(
|
||||||
apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, Some(corner_radius))
|
&window,
|
||||||
{
|
NSVisualEffectMaterial::Sidebar,
|
||||||
warn!("[desktop:vibrancy] Failed to apply macOS vibrancy: {}", error);
|
None,
|
||||||
|
Some(corner_radius),
|
||||||
|
) {
|
||||||
|
warn!(
|
||||||
|
"[desktop:vibrancy] Failed to apply macOS vibrancy: {}",
|
||||||
|
error
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
info!("[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}", corner_radius);
|
info!(
|
||||||
|
"[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}",
|
||||||
|
corner_radius
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if macos_version < 26 {
|
if macos_version < 26 {
|
||||||
@@ -691,7 +713,11 @@ fn main() {
|
|||||||
|
|
||||||
let app_handle = app.app_handle().clone();
|
let app_handle = app.app_handle().clone();
|
||||||
let runtime_clone = runtime.clone();
|
let runtime_clone = runtime.clone();
|
||||||
let has_initial_dir = tauri::async_runtime::block_on(runtime.settings().last_directory()).ok().flatten().is_some();
|
let has_initial_dir =
|
||||||
|
tauri::async_runtime::block_on(runtime.settings().last_directory())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// Only start opencode if we have a saved directory, otherwise frontend will prompt
|
// Only start opencode if we have a saved directory, otherwise frontend will prompt
|
||||||
if has_initial_dir {
|
if has_initial_dir {
|
||||||
@@ -700,7 +726,9 @@ fn main() {
|
|||||||
info!("[desktop] No saved directory - waiting for user to select one");
|
info!("[desktop] No saved directory - waiting for user to select one");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await {
|
if let Err(e) =
|
||||||
|
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
|
||||||
|
{
|
||||||
warn!("Failed to restore bookmarks on startup: {}", e);
|
warn!("Failed to restore bookmarks on startup: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,8 +756,12 @@ fn main() {
|
|||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let _ = app_handle.emit("server.instance.disposed", ());
|
let _ = app_handle.emit("server.instance.disposed", ());
|
||||||
if runtime.opencode_manager().is_cli_available() {
|
if runtime.opencode_manager().is_cli_available() {
|
||||||
if let Err(err) = runtime.opencode_manager().ensure_running().await {
|
if let Err(err) =
|
||||||
warn!("[desktop:watchdog] Failed to restart OpenCode: {err}");
|
runtime.opencode_manager().ensure_running().await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"[desktop:watchdog] Failed to restart OpenCode: {err}"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
backoff_ms = 1000;
|
backoff_ms = 1000;
|
||||||
}
|
}
|
||||||
@@ -779,10 +811,12 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let changed = match &last_snapshot {
|
let changed = match &last_snapshot {
|
||||||
Some(prev) => prev.ok != snapshot.ok
|
Some(prev) => {
|
||||||
|| prev.port != snapshot.port
|
prev.ok != snapshot.ok
|
||||||
|| prev.api_prefix != snapshot.api_prefix
|
|| prev.port != snapshot.port
|
||||||
|| prev.cli_available != snapshot.cli_available,
|
|| prev.api_prefix != snapshot.api_prefix
|
||||||
|
|| prev.cli_available != snapshot.cli_available
|
||||||
|
}
|
||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -990,7 +1024,9 @@ fn main() {
|
|||||||
tauri::WindowEvent::Focused(true) => {
|
tauri::WindowEvent::Focused(true) => {
|
||||||
// Clear dock badge and underlying badge state when the window gains focus
|
// Clear dock badge and underlying badge state when the window gains focus
|
||||||
let _ = window.set_badge_count(None);
|
let _ = window.set_badge_count(None);
|
||||||
let _ = window.app_handle().emit("openchamber:clear-badge-sessions", ());
|
let _ = window
|
||||||
|
.app_handle()
|
||||||
|
.emit("openchamber:clear-badge-sessions", ());
|
||||||
}
|
}
|
||||||
tauri::WindowEvent::Moved(position) => {
|
tauri::WindowEvent::Moved(position) => {
|
||||||
let is_maximized = window.is_maximized().unwrap_or(false);
|
let is_maximized = window.is_maximized().unwrap_or(false);
|
||||||
@@ -1037,7 +1073,6 @@ fn main() {
|
|||||||
app.run(|_app_handle, _event| {});
|
app.run(|_app_handle, _event| {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) {
|
fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) {
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
if let Err(error) = run_http_server(port, state, shutdown_rx).await {
|
if let Err(error) = run_http_server(port, state, shutdown_rx).await {
|
||||||
@@ -1053,7 +1088,10 @@ async fn run_http_server(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/health", get(health_handler))
|
.route("/health", get(health_handler))
|
||||||
.route("/api/openchamber/models-metadata", get(models_metadata_handler))
|
.route(
|
||||||
|
"/api/openchamber/models-metadata",
|
||||||
|
get(models_metadata_handler),
|
||||||
|
)
|
||||||
.route("/api/opencode/directory", post(change_directory_handler))
|
.route("/api/opencode/directory", post(change_directory_handler))
|
||||||
.route("/api", any(proxy_to_opencode))
|
.route("/api", any(proxy_to_opencode))
|
||||||
.route("/api/{*rest}", any(proxy_to_opencode))
|
.route("/api/{*rest}", any(proxy_to_opencode))
|
||||||
@@ -1084,7 +1122,9 @@ async fn health_handler(State(state): State<ServerState>) -> Json<HealthResponse
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn models_metadata_handler(State(state): State<ServerState>) -> Result<Json<Value>, StatusCode> {
|
async fn models_metadata_handler(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let cached_payload: Option<Value> = {
|
let cached_payload: Option<Value> = {
|
||||||
let cache = state.models_metadata_cache.lock().await;
|
let cache = state.models_metadata_cache.lock().await;
|
||||||
@@ -1150,12 +1190,17 @@ fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response<Body>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> {
|
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> {
|
||||||
json_response(status, ConfigErrorResponse {
|
json_response(
|
||||||
error: message.into(),
|
status,
|
||||||
})
|
ConfigErrorResponse {
|
||||||
|
error: message.into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_request_payload(req: Request<Body>) -> Result<HashMap<String, Value>, Response<Body>> {
|
async fn parse_request_payload(
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<HashMap<String, Value>, Response<Body>> {
|
||||||
let (_, body) = req.into_parts();
|
let (_, body) = req.into_parts();
|
||||||
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
|
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
|
||||||
.await
|
.await
|
||||||
@@ -1174,14 +1219,12 @@ async fn refresh_opencode_after_config_change(
|
|||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<(), Response<Body>> {
|
) -> Result<(), Response<Body>> {
|
||||||
info!("[desktop:config] Restarting OpenCode after {}", reason);
|
info!("[desktop:config] Restarting OpenCode after {}", reason);
|
||||||
state
|
state.opencode.restart().await.map_err(|err| {
|
||||||
.opencode
|
config_error_response(
|
||||||
.restart()
|
|
||||||
.await
|
|
||||||
.map_err(|err| config_error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Failed to restart OpenCode: {}", err),
|
format!("Failed to restart OpenCode: {}", err),
|
||||||
))?;
|
)
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,7 +1271,8 @@ async fn handle_agent_route(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Extract scope from payload if present
|
// Extract scope from payload if present
|
||||||
let scope = payload.get("scope")
|
let scope = payload
|
||||||
|
.get("scope")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| match s {
|
.and_then(|s| match s {
|
||||||
"project" => Some(opencode_config::AgentScope::Project),
|
"project" => Some(opencode_config::AgentScope::Project),
|
||||||
@@ -1236,7 +1280,9 @@ async fn handle_agent_route(
|
|||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
|
|
||||||
match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope).await {
|
match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(resp) =
|
if let Err(resp) =
|
||||||
refresh_opencode_after_config_change(state, "agent creation").await
|
refresh_opencode_after_config_change(state, "agent creation").await
|
||||||
@@ -1302,35 +1348,37 @@ async fn handle_agent_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::DELETE => match opencode_config::delete_agent(&name, Some(&working_directory)).await {
|
Method::DELETE => {
|
||||||
Ok(()) => {
|
match opencode_config::delete_agent(&name, Some(&working_directory)).await {
|
||||||
if let Err(resp) =
|
Ok(()) => {
|
||||||
refresh_opencode_after_config_change(state, "agent deletion").await
|
if let Err(resp) =
|
||||||
{
|
refresh_opencode_after_config_change(state, "agent deletion").await
|
||||||
return Ok(resp);
|
{
|
||||||
}
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json_response(
|
Ok(json_response(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
ConfigActionResponse {
|
ConfigActionResponse {
|
||||||
success: true,
|
success: true,
|
||||||
requires_reload: true,
|
requires_reload: true,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Agent {} deleted successfully. Reloading interface...",
|
"Agent {} deleted successfully. Reloading interface...",
|
||||||
name
|
name
|
||||||
),
|
),
|
||||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("[desktop:config] Failed to delete agent {}: {}", name, err);
|
||||||
|
Ok(config_error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
}
|
||||||
error!("[desktop:config] Failed to delete agent {}: {}", name, err);
|
|
||||||
Ok(config_error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1366,9 +1414,7 @@ struct SkillFileResponse {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_skill_list_route(
|
async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>, StatusCode> {
|
||||||
state: &ServerState,
|
|
||||||
) -> Result<Response<Body>, StatusCode> {
|
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory = state.opencode.get_working_directory();
|
||||||
let discovered = opencode_config::discover_skills(Some(&working_directory));
|
let discovered = opencode_config::discover_skills(Some(&working_directory));
|
||||||
|
|
||||||
@@ -1385,12 +1431,18 @@ async fn handle_skill_list_route(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("[desktop:config] Failed to get skill sources for {}: {}", skill.name, err);
|
error!(
|
||||||
|
"[desktop:config] Failed to get skill sources for {}: {}",
|
||||||
|
skill.name, err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills })))
|
Ok(json_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
serde_json::json!({ "skills": skills }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_skill_route(
|
async fn handle_skill_route(
|
||||||
@@ -1410,17 +1462,37 @@ async fn handle_skill_route(
|
|||||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||||
Ok(sources) => {
|
Ok(sources) => {
|
||||||
if !sources.md.exists {
|
if !sources.md.exists {
|
||||||
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
|
return Ok(config_error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Skill not found",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
match opencode_config::read_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await {
|
match opencode_config::read_skill_supporting_file(
|
||||||
Ok(content) => Ok(json_response(StatusCode::OK, SkillFileResponse { path: fp.clone(), content })),
|
std::path::Path::new(&skill_dir),
|
||||||
Err(_) => Ok(config_error_response(StatusCode::NOT_FOUND, "File not found")),
|
fp,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(content) => Ok(json_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
SkillFileResponse {
|
||||||
|
path: fp.clone(),
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Err(_) => Ok(config_error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"File not found",
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("[desktop:config] Failed to read skill sources: {}", err);
|
error!("[desktop:config] Failed to read skill sources: {}", err);
|
||||||
Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill"))
|
Ok(config_error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to read skill",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1430,25 +1502,46 @@ async fn handle_skill_route(
|
|||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
let content = payload
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||||
Ok(sources) => {
|
Ok(sources) => {
|
||||||
if !sources.md.exists {
|
if !sources.md.exists {
|
||||||
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
|
return Ok(config_error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Skill not found",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
match opencode_config::write_skill_supporting_file(std::path::Path::new(&skill_dir), fp, content).await {
|
match opencode_config::write_skill_supporting_file(
|
||||||
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse {
|
std::path::Path::new(&skill_dir),
|
||||||
success: true,
|
fp,
|
||||||
requires_reload: false,
|
content,
|
||||||
message: format!("File {} saved successfully", fp),
|
)
|
||||||
reload_delay_ms: 0,
|
.await
|
||||||
})),
|
{
|
||||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
Ok(()) => Ok(json_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
ConfigActionResponse {
|
||||||
|
success: true,
|
||||||
|
requires_reload: false,
|
||||||
|
message: format!("File {} saved successfully", fp),
|
||||||
|
reload_delay_ms: 0,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Err(err) => Ok(config_error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
err.to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
Err(err) => Ok(config_error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
err.to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::DELETE => {
|
Method::DELETE => {
|
||||||
@@ -1456,20 +1549,37 @@ async fn handle_skill_route(
|
|||||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||||
Ok(sources) => {
|
Ok(sources) => {
|
||||||
if !sources.md.exists {
|
if !sources.md.exists {
|
||||||
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
|
return Ok(config_error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Skill not found",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
match opencode_config::delete_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await {
|
match opencode_config::delete_skill_supporting_file(
|
||||||
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse {
|
std::path::Path::new(&skill_dir),
|
||||||
success: true,
|
fp,
|
||||||
requires_reload: false,
|
)
|
||||||
message: format!("File {} deleted successfully", fp),
|
.await
|
||||||
reload_delay_ms: 0,
|
{
|
||||||
})),
|
Ok(()) => Ok(json_response(
|
||||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
StatusCode::OK,
|
||||||
|
ConfigActionResponse {
|
||||||
|
success: true,
|
||||||
|
requires_reload: false,
|
||||||
|
message: format!("File {} deleted successfully", fp),
|
||||||
|
reload_delay_ms: 0,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Err(err) => Ok(config_error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
err.to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
Err(err) => Ok(config_error_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
err.to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
||||||
@@ -1508,7 +1618,8 @@ async fn handle_skill_route(
|
|||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
|
|
||||||
let scope = payload.get("scope")
|
let scope = payload
|
||||||
|
.get("scope")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| match s {
|
.and_then(|s| match s {
|
||||||
"project" => Some(opencode_config::SkillScope::Project),
|
"project" => Some(opencode_config::SkillScope::Project),
|
||||||
@@ -1516,7 +1627,14 @@ async fn handle_skill_route(
|
|||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
|
|
||||||
match opencode_config::create_skill(&name, &payload, Some(&working_directory), scope).await {
|
match opencode_config::create_skill(
|
||||||
|
&name,
|
||||||
|
&payload,
|
||||||
|
Some(&working_directory),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(resp) =
|
if let Err(resp) =
|
||||||
refresh_opencode_after_config_change(state, "skill creation").await
|
refresh_opencode_after_config_change(state, "skill creation").await
|
||||||
@@ -1552,7 +1670,8 @@ async fn handle_skill_route(
|
|||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
|
|
||||||
match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await {
|
match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(resp) =
|
if let Err(resp) =
|
||||||
refresh_opencode_after_config_change(state, "skill update").await
|
refresh_opencode_after_config_change(state, "skill update").await
|
||||||
@@ -1582,37 +1701,39 @@ async fn handle_skill_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::DELETE => match opencode_config::delete_skill(&name, Some(&working_directory)).await {
|
Method::DELETE => {
|
||||||
Ok(()) => {
|
match opencode_config::delete_skill(&name, Some(&working_directory)).await {
|
||||||
if let Err(resp) =
|
Ok(()) => {
|
||||||
refresh_opencode_after_config_change(state, "skill deletion").await
|
if let Err(resp) =
|
||||||
{
|
refresh_opencode_after_config_change(state, "skill deletion").await
|
||||||
return Ok(resp);
|
{
|
||||||
}
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json_response(
|
Ok(json_response(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
ConfigActionResponse {
|
ConfigActionResponse {
|
||||||
success: true,
|
success: true,
|
||||||
requires_reload: true,
|
requires_reload: true,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Skill {} deleted successfully. Reloading interface...",
|
"Skill {} deleted successfully. Reloading interface...",
|
||||||
name
|
name
|
||||||
),
|
),
|
||||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("[desktop:config] Failed to delete skill {}: {}", name, err);
|
||||||
|
let status = if err.to_string().contains("not found") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
};
|
||||||
|
Ok(config_error_response(status, err.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
}
|
||||||
error!("[desktop:config] Failed to delete skill {}: {}", name, err);
|
|
||||||
let status = if err.to_string().contains("not found") {
|
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
} else {
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
};
|
|
||||||
Ok(config_error_response(status, err.to_string()))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1661,7 +1782,8 @@ async fn handle_command_route(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Extract scope from payload if present
|
// Extract scope from payload if present
|
||||||
let scope = payload.get("scope")
|
let scope = payload
|
||||||
|
.get("scope")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| match s {
|
.and_then(|s| match s {
|
||||||
"project" => Some(opencode_config::CommandScope::Project),
|
"project" => Some(opencode_config::CommandScope::Project),
|
||||||
@@ -1669,7 +1791,9 @@ async fn handle_command_route(
|
|||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
|
|
||||||
match opencode_config::create_command(&name, &payload, Some(&working_directory), scope).await {
|
match opencode_config::create_command(&name, &payload, Some(&working_directory), scope)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(resp) =
|
if let Err(resp) =
|
||||||
refresh_opencode_after_config_change(state, "command creation").await
|
refresh_opencode_after_config_change(state, "command creation").await
|
||||||
@@ -1691,7 +1815,10 @@ async fn handle_command_route(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("[desktop:config] Failed to create command {}: {}", name, err);
|
error!(
|
||||||
|
"[desktop:config] Failed to create command {}: {}",
|
||||||
|
name, err
|
||||||
|
);
|
||||||
Ok(config_error_response(
|
Ok(config_error_response(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
err.to_string(),
|
err.to_string(),
|
||||||
@@ -1727,7 +1854,10 @@ async fn handle_command_route(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("[desktop:config] Failed to update command {}: {}", name, err);
|
error!(
|
||||||
|
"[desktop:config] Failed to update command {}: {}",
|
||||||
|
name, err
|
||||||
|
);
|
||||||
Ok(config_error_response(
|
Ok(config_error_response(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
err.to_string(),
|
err.to_string(),
|
||||||
@@ -1735,37 +1865,42 @@ async fn handle_command_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::DELETE => match opencode_config::delete_command(&name, Some(&working_directory)).await {
|
Method::DELETE => {
|
||||||
Ok(()) => {
|
match opencode_config::delete_command(&name, Some(&working_directory)).await {
|
||||||
if let Err(resp) =
|
Ok(()) => {
|
||||||
refresh_opencode_after_config_change(state, "command deletion").await
|
if let Err(resp) =
|
||||||
{
|
refresh_opencode_after_config_change(state, "command deletion").await
|
||||||
return Ok(resp);
|
{
|
||||||
}
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json_response(
|
Ok(json_response(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
ConfigActionResponse {
|
ConfigActionResponse {
|
||||||
success: true,
|
success: true,
|
||||||
requires_reload: true,
|
requires_reload: true,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Command {} deleted successfully. Reloading interface...",
|
"Command {} deleted successfully. Reloading interface...",
|
||||||
name
|
name
|
||||||
),
|
),
|
||||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"[desktop:config] Failed to delete command {}: {}",
|
||||||
|
name, err
|
||||||
|
);
|
||||||
|
let status = if err.to_string().contains("not found") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
};
|
||||||
|
Ok(config_error_response(status, err.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
}
|
||||||
error!("[desktop:config] Failed to delete command {}: {}", name, err);
|
|
||||||
let status = if err.to_string().contains("not found") {
|
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
} else {
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
};
|
|
||||||
Ok(config_error_response(status, err.to_string()))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1818,25 +1953,26 @@ async fn handle_config_routes(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
||||||
let scan_request = match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
|
let scan_request =
|
||||||
Ok(v) => v,
|
match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
|
||||||
Err(_) => {
|
Ok(v) => v,
|
||||||
return Ok(json_response(
|
Err(_) => {
|
||||||
StatusCode::BAD_REQUEST,
|
return Ok(json_response(
|
||||||
skills_catalog::SkillsRepoScanResponse {
|
StatusCode::BAD_REQUEST,
|
||||||
ok: false,
|
skills_catalog::SkillsRepoScanResponse {
|
||||||
items: None,
|
ok: false,
|
||||||
error: Some(skills_catalog::SkillsRepoError {
|
items: None,
|
||||||
kind: "invalidSource".to_string(),
|
error: Some(skills_catalog::SkillsRepoError {
|
||||||
message: "Malformed scan request".to_string(),
|
kind: "invalidSource".to_string(),
|
||||||
ssh_only: None,
|
message: "Malformed scan request".to_string(),
|
||||||
identities: None,
|
ssh_only: None,
|
||||||
conflicts: None,
|
identities: None,
|
||||||
}),
|
conflicts: None,
|
||||||
},
|
}),
|
||||||
))
|
},
|
||||||
}
|
))
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let response = skills_catalog::scan_repository(scan_request).await;
|
let response = skills_catalog::scan_repository(scan_request).await;
|
||||||
let status = if response.ok {
|
let status = if response.ok {
|
||||||
@@ -1857,26 +1993,27 @@ async fn handle_config_routes(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
||||||
let install_request = match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
|
let install_request =
|
||||||
Ok(v) => v,
|
match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
|
||||||
Err(_) => {
|
Ok(v) => v,
|
||||||
return Ok(json_response(
|
Err(_) => {
|
||||||
StatusCode::BAD_REQUEST,
|
return Ok(json_response(
|
||||||
skills_catalog::SkillsInstallResponse {
|
StatusCode::BAD_REQUEST,
|
||||||
ok: false,
|
skills_catalog::SkillsInstallResponse {
|
||||||
installed: None,
|
ok: false,
|
||||||
skipped: None,
|
installed: None,
|
||||||
error: Some(skills_catalog::SkillsRepoError {
|
skipped: None,
|
||||||
kind: "invalidSource".to_string(),
|
error: Some(skills_catalog::SkillsRepoError {
|
||||||
message: "Malformed install request".to_string(),
|
kind: "invalidSource".to_string(),
|
||||||
ssh_only: None,
|
message: "Malformed install request".to_string(),
|
||||||
identities: None,
|
ssh_only: None,
|
||||||
conflicts: None,
|
identities: None,
|
||||||
}),
|
conflicts: None,
|
||||||
},
|
}),
|
||||||
))
|
},
|
||||||
}
|
))
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let working_directory = state.opencode.get_working_directory();
|
let working_directory = state.opencode.get_working_directory();
|
||||||
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
||||||
@@ -1904,7 +2041,7 @@ async fn handle_config_routes(
|
|||||||
if let Some(files_start) = rest.find("/files/") {
|
if let Some(files_start) = rest.find("/files/") {
|
||||||
let name = &rest[..files_start];
|
let name = &rest[..files_start];
|
||||||
let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/"
|
let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/"
|
||||||
// Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md")
|
// Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md")
|
||||||
let file_path = urlencoding::decode(file_path_encoded)
|
let file_path = urlencoding::decode(file_path_encoded)
|
||||||
.map(|s| s.into_owned())
|
.map(|s| s.into_owned())
|
||||||
.unwrap_or_else(|_| file_path_encoded.to_string());
|
.unwrap_or_else(|_| file_path_encoded.to_string());
|
||||||
@@ -1914,7 +2051,8 @@ async fn handle_config_routes(
|
|||||||
"Skill name is required",
|
"Skill name is required",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)).await;
|
return handle_skill_route(&state, method, req, name.to_string(), Some(file_path))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = rest.trim();
|
let trimmed = rest.trim();
|
||||||
@@ -1939,8 +2077,7 @@ async fn handle_config_routes(
|
|||||||
ConfigActionResponse {
|
ConfigActionResponse {
|
||||||
success: true,
|
success: true,
|
||||||
requires_reload: true,
|
requires_reload: true,
|
||||||
message: "Configuration reloaded successfully. Refreshing interface..."
|
message: "Configuration reloaded successfully. Refreshing interface...".to_string(),
|
||||||
.to_string(),
|
|
||||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -2063,12 +2200,12 @@ async fn change_directory_handler(
|
|||||||
.set_working_directory(resolved_path.clone())
|
.set_working_directory(resolved_path.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!(
|
error!(
|
||||||
"[desktop:http] ERROR: Failed to set working directory: {}",
|
"[desktop:http] ERROR: Failed to set working directory: {}",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
state.opencode.restart().await.map_err(|e| {
|
state.opencode.restart().await.map_err(|e| {
|
||||||
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e);
|
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e);
|
||||||
|
|||||||
@@ -94,5 +94,3 @@ pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,11 @@ struct JsonEntrySource {
|
|||||||
section: Option<Value>,
|
section: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_json_entry_source(layers: &ConfigLayers, section_key: &str, entry_name: &str) -> JsonEntrySource {
|
fn get_json_entry_source(
|
||||||
|
layers: &ConfigLayers,
|
||||||
|
section_key: &str,
|
||||||
|
entry_name: &str,
|
||||||
|
) -> JsonEntrySource {
|
||||||
if let Some(ref custom_path) = layers.paths.custom {
|
if let Some(ref custom_path) = layers.paths.custom {
|
||||||
if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) {
|
if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) {
|
||||||
if let Some(value) = section.get(entry_name) {
|
if let Some(value) = section.get(entry_name) {
|
||||||
@@ -308,7 +312,10 @@ async fn ensure_project_agent_dir(working_directory: &Path) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Determine agent scope based on where the .md file exists
|
/// Determine agent scope based on where the .md file exists
|
||||||
pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (Option<AgentScope>, Option<PathBuf>) {
|
pub fn get_agent_scope(
|
||||||
|
agent_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
) -> (Option<AgentScope>, Option<PathBuf>) {
|
||||||
if let Some(wd) = working_directory {
|
if let Some(wd) = working_directory {
|
||||||
let project_path = get_project_agent_path(wd, agent_name);
|
let project_path = get_project_agent_path(wd, agent_name);
|
||||||
if project_path.exists() {
|
if project_path.exists() {
|
||||||
@@ -325,7 +332,11 @@ pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (O
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the path where an agent should be written based on scope
|
/// Get the path where an agent should be written based on scope
|
||||||
fn get_agent_write_path(agent_name: &str, working_directory: Option<&Path>, requested_scope: Option<AgentScope>) -> (AgentScope, PathBuf) {
|
fn get_agent_write_path(
|
||||||
|
agent_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
requested_scope: Option<AgentScope>,
|
||||||
|
) -> (AgentScope, PathBuf) {
|
||||||
// For updates: check existing location first (project takes precedence)
|
// For updates: check existing location first (project takes precedence)
|
||||||
let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory);
|
let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory);
|
||||||
if let Some(path) = existing_path {
|
if let Some(path) = existing_path {
|
||||||
@@ -368,7 +379,10 @@ async fn ensure_project_command_dir(working_directory: &Path) -> Result<PathBuf>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Determine command scope based on where the .md file exists
|
/// Determine command scope based on where the .md file exists
|
||||||
pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -> (Option<CommandScope>, Option<PathBuf>) {
|
pub fn get_command_scope(
|
||||||
|
command_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
) -> (Option<CommandScope>, Option<PathBuf>) {
|
||||||
if let Some(wd) = working_directory {
|
if let Some(wd) = working_directory {
|
||||||
let project_path = get_project_command_path(wd, command_name);
|
let project_path = get_project_command_path(wd, command_name);
|
||||||
if project_path.exists() {
|
if project_path.exists() {
|
||||||
@@ -385,7 +399,11 @@ pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the path where a command should be written based on scope
|
/// Get the path where a command should be written based on scope
|
||||||
fn get_command_write_path(command_name: &str, working_directory: Option<&Path>, requested_scope: Option<CommandScope>) -> (CommandScope, PathBuf) {
|
fn get_command_write_path(
|
||||||
|
command_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
requested_scope: Option<CommandScope>,
|
||||||
|
) -> (CommandScope, PathBuf) {
|
||||||
// For updates: check existing location first (project takes precedence)
|
// For updates: check existing location first (project takes precedence)
|
||||||
let (existing_scope, existing_path) = get_command_scope(command_name, working_directory);
|
let (existing_scope, existing_path) = get_command_scope(command_name, working_directory);
|
||||||
if let Some(path) = existing_path {
|
if let Some(path) = existing_path {
|
||||||
@@ -396,7 +414,10 @@ fn get_command_write_path(command_name: &str, working_directory: Option<&Path>,
|
|||||||
let scope = requested_scope.unwrap_or(CommandScope::User);
|
let scope = requested_scope.unwrap_or(CommandScope::User);
|
||||||
if scope == CommandScope::Project {
|
if scope == CommandScope::Project {
|
||||||
if let Some(wd) = working_directory {
|
if let Some(wd) = working_directory {
|
||||||
return (CommandScope::Project, get_project_command_path(wd, command_name));
|
return (
|
||||||
|
CommandScope::Project,
|
||||||
|
get_project_command_path(wd, command_name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,7 +627,10 @@ async fn write_md_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get information about where agent configuration is stored
|
/// Get information about where agent configuration is stored
|
||||||
pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> {
|
pub async fn get_agent_sources(
|
||||||
|
agent_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
) -> Result<ConfigSources> {
|
||||||
ensure_dirs().await?;
|
ensure_dirs().await?;
|
||||||
|
|
||||||
// Check project level first (takes precedence)
|
// Check project level first (takes precedence)
|
||||||
@@ -687,7 +711,7 @@ pub async fn create_agent(
|
|||||||
agent_name: &str,
|
agent_name: &str,
|
||||||
config: &HashMap<String, Value>,
|
config: &HashMap<String, Value>,
|
||||||
working_directory: Option<&Path>,
|
working_directory: Option<&Path>,
|
||||||
scope: Option<AgentScope>
|
scope: Option<AgentScope>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ensure_dirs().await?;
|
ensure_dirs().await?;
|
||||||
|
|
||||||
@@ -741,7 +765,12 @@ pub async fn create_agent(
|
|||||||
|
|
||||||
// Write .md file
|
// Write .md file
|
||||||
write_md_file(&target_path, &frontmatter, &prompt).await?;
|
write_md_file(&target_path, &frontmatter, &prompt).await?;
|
||||||
info!("Created new agent: {} (scope: {:?}, path: {})", agent_name, target_scope, target_path.display());
|
info!(
|
||||||
|
"Created new agent: {} (scope: {:?}, path: {})",
|
||||||
|
agent_name,
|
||||||
|
target_scope,
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -799,7 +828,10 @@ pub async fn update_agent(
|
|||||||
Some(parse_md_file(&md_path).await?)
|
Some(parse_md_file(&md_path).await?)
|
||||||
} else if is_builtin_override {
|
} else if is_builtin_override {
|
||||||
// Only create new md data for built-in overrides
|
// Only create new md data for built-in overrides
|
||||||
Some(MdData { frontmatter: HashMap::new(), body: String::new() })
|
Some(MdData {
|
||||||
|
frontmatter: HashMap::new(),
|
||||||
|
body: String::new(),
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -836,8 +868,7 @@ pub async fn update_agent(
|
|||||||
md_modified = true;
|
md_modified = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str())
|
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) {
|
||||||
{
|
|
||||||
if is_prompt_file_reference(prompt_ref) {
|
if is_prompt_file_reference(prompt_ref) {
|
||||||
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
|
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
|
||||||
write_prompt_file(&prompt_file_path, &normalized_value).await?;
|
write_prompt_file(&prompt_file_path, &normalized_value).await?;
|
||||||
@@ -941,7 +972,10 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) ->
|
|||||||
let project_path = get_project_agent_path(wd, agent_name);
|
let project_path = get_project_agent_path(wd, agent_name);
|
||||||
if project_path.exists() {
|
if project_path.exists() {
|
||||||
fs::remove_file(&project_path).await?;
|
fs::remove_file(&project_path).await?;
|
||||||
info!("Deleted project-level agent .md file: {}", project_path.display());
|
info!(
|
||||||
|
"Deleted project-level agent .md file: {}",
|
||||||
|
project_path.display()
|
||||||
|
);
|
||||||
deleted = true;
|
deleted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1038,10 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get information about where command configuration is stored
|
/// Get information about where command configuration is stored
|
||||||
pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> {
|
pub async fn get_command_sources(
|
||||||
|
command_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
) -> Result<ConfigSources> {
|
||||||
ensure_dirs().await?;
|
ensure_dirs().await?;
|
||||||
|
|
||||||
// Check project level first (takes precedence)
|
// Check project level first (takes precedence)
|
||||||
@@ -1085,7 +1122,7 @@ pub async fn create_command(
|
|||||||
command_name: &str,
|
command_name: &str,
|
||||||
config: &HashMap<String, Value>,
|
config: &HashMap<String, Value>,
|
||||||
working_directory: Option<&Path>,
|
working_directory: Option<&Path>,
|
||||||
scope: Option<CommandScope>
|
scope: Option<CommandScope>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ensure_dirs().await?;
|
ensure_dirs().await?;
|
||||||
|
|
||||||
@@ -1121,7 +1158,10 @@ pub async fn create_command(
|
|||||||
let (target_scope, target_path) = if scope == Some(CommandScope::Project) {
|
let (target_scope, target_path) = if scope == Some(CommandScope::Project) {
|
||||||
if let Some(wd) = working_directory {
|
if let Some(wd) = working_directory {
|
||||||
ensure_project_command_dir(wd).await?;
|
ensure_project_command_dir(wd).await?;
|
||||||
(CommandScope::Project, get_project_command_path(wd, command_name))
|
(
|
||||||
|
CommandScope::Project,
|
||||||
|
get_project_command_path(wd, command_name),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(CommandScope::User, user_path)
|
(CommandScope::User, user_path)
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1179,12 @@ pub async fn create_command(
|
|||||||
|
|
||||||
// Write .md file
|
// Write .md file
|
||||||
write_md_file(&target_path, &frontmatter, &template).await?;
|
write_md_file(&target_path, &frontmatter, &template).await?;
|
||||||
info!("Created new command: {} (scope: {:?}, path: {})", command_name, target_scope, target_path.display());
|
info!(
|
||||||
|
"Created new command: {} (scope: {:?}, path: {})",
|
||||||
|
command_name,
|
||||||
|
target_scope,
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1194,7 +1239,10 @@ pub async fn update_command(
|
|||||||
let mut md_data = if md_exists {
|
let mut md_data = if md_exists {
|
||||||
Some(parse_md_file(&md_path).await?)
|
Some(parse_md_file(&md_path).await?)
|
||||||
} else if is_builtin_override {
|
} else if is_builtin_override {
|
||||||
Some(MdData { frontmatter: HashMap::new(), body: String::new() })
|
Some(MdData {
|
||||||
|
frontmatter: HashMap::new(),
|
||||||
|
body: String::new(),
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -1230,7 +1278,9 @@ pub async fn update_command(
|
|||||||
md_modified = true;
|
md_modified = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
} else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) {
|
} else if let Some(template_ref) =
|
||||||
|
existing_command.get("template").and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
if is_prompt_file_reference(template_ref) {
|
if is_prompt_file_reference(template_ref) {
|
||||||
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
|
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
|
||||||
write_prompt_file(&template_file_path, &normalized_value).await?;
|
write_prompt_file(&template_file_path, &normalized_value).await?;
|
||||||
@@ -1334,7 +1384,10 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path>
|
|||||||
let project_path = get_project_command_path(wd, command_name);
|
let project_path = get_project_command_path(wd, command_name);
|
||||||
if project_path.exists() {
|
if project_path.exists() {
|
||||||
fs::remove_file(&project_path).await?;
|
fs::remove_file(&project_path).await?;
|
||||||
info!("Deleted project-level command .md file: {}", project_path.display());
|
info!(
|
||||||
|
"Deleted project-level command .md file: {}",
|
||||||
|
project_path.display()
|
||||||
|
);
|
||||||
deleted = true;
|
deleted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1396,10 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path>
|
|||||||
let user_path = get_user_command_path(command_name);
|
let user_path = get_user_command_path(command_name);
|
||||||
if user_path.exists() {
|
if user_path.exists() {
|
||||||
fs::remove_file(&user_path).await?;
|
fs::remove_file(&user_path).await?;
|
||||||
info!("Deleted user-level command .md file: {}", user_path.display());
|
info!(
|
||||||
|
"Deleted user-level command .md file: {}",
|
||||||
|
user_path.display()
|
||||||
|
);
|
||||||
deleted = true;
|
deleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1471,7 +1527,10 @@ fn get_user_skill_path(skill_name: &str) -> PathBuf {
|
|||||||
|
|
||||||
/// Get project-level skill directory (.opencode/skill/)
|
/// Get project-level skill directory (.opencode/skill/)
|
||||||
fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
|
fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
|
||||||
working_directory.join(".opencode").join("skill").join(skill_name)
|
working_directory
|
||||||
|
.join(".opencode")
|
||||||
|
.join("skill")
|
||||||
|
.join(skill_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get project-level skill SKILL.md path
|
/// Get project-level skill SKILL.md path
|
||||||
@@ -1481,7 +1540,10 @@ fn get_project_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf
|
|||||||
|
|
||||||
/// Get Claude-compatible skill directory (.claude/skills/)
|
/// Get Claude-compatible skill directory (.claude/skills/)
|
||||||
fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
|
fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
|
||||||
working_directory.join(".claude").join("skills").join(skill_name)
|
working_directory
|
||||||
|
.join(".claude")
|
||||||
|
.join("skills")
|
||||||
|
.join(skill_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Claude-compatible skill SKILL.md path
|
/// Get Claude-compatible skill SKILL.md path
|
||||||
@@ -1504,24 +1566,39 @@ async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Determine skill scope based on where the SKILL.md file exists
|
/// Determine skill scope based on where the SKILL.md file exists
|
||||||
pub fn get_skill_scope(skill_name: &str, working_directory: Option<&Path>) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
|
pub fn get_skill_scope(
|
||||||
|
skill_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
|
||||||
if let Some(wd) = working_directory {
|
if let Some(wd) = working_directory {
|
||||||
// Check .opencode/skill first
|
// Check .opencode/skill first
|
||||||
let project_path = get_project_skill_path(wd, skill_name);
|
let project_path = get_project_skill_path(wd, skill_name);
|
||||||
if project_path.exists() {
|
if project_path.exists() {
|
||||||
return (Some(SkillScope::Project), Some(project_path), Some(SkillSource::Opencode));
|
return (
|
||||||
|
Some(SkillScope::Project),
|
||||||
|
Some(project_path),
|
||||||
|
Some(SkillSource::Opencode),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check .claude/skills (claude-compat)
|
// Check .claude/skills (claude-compat)
|
||||||
let claude_path = get_claude_skill_path(wd, skill_name);
|
let claude_path = get_claude_skill_path(wd, skill_name);
|
||||||
if claude_path.exists() {
|
if claude_path.exists() {
|
||||||
return (Some(SkillScope::Project), Some(claude_path), Some(SkillSource::Claude));
|
return (
|
||||||
|
Some(SkillScope::Project),
|
||||||
|
Some(claude_path),
|
||||||
|
Some(SkillSource::Claude),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_path = get_user_skill_path(skill_name);
|
let user_path = get_user_skill_path(skill_name);
|
||||||
if user_path.exists() {
|
if user_path.exists() {
|
||||||
return (Some(SkillScope::User), Some(user_path), Some(SkillSource::Opencode));
|
return (
|
||||||
|
Some(SkillScope::User),
|
||||||
|
Some(user_path),
|
||||||
|
Some(SkillSource::Opencode),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(None, None, None)
|
(None, None, None)
|
||||||
@@ -1540,7 +1617,8 @@ fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
|
|||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
walk_dir(&path, relative_base, files);
|
walk_dir(&path, relative_base, files);
|
||||||
} else if file_name != "SKILL.md" {
|
} else if file_name != "SKILL.md" {
|
||||||
let relative_path = path.strip_prefix(relative_base)
|
let relative_path = path
|
||||||
|
.strip_prefix(relative_base)
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|_| file_name.clone());
|
.unwrap_or_else(|_| file_name.clone());
|
||||||
|
|
||||||
@@ -1560,17 +1638,21 @@ fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
|
|||||||
|
|
||||||
/// Discover all skills from all sources
|
/// Discover all skills from all sources
|
||||||
pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> {
|
pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> {
|
||||||
let mut skills: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new();
|
let mut skills: std::collections::HashMap<String, DiscoveredSkill> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
// Helper to add skill if not already found
|
// Helper to add skill if not already found
|
||||||
let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| {
|
let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| {
|
||||||
if !skills.contains_key(&name) {
|
if !skills.contains_key(&name) {
|
||||||
skills.insert(name.clone(), DiscoveredSkill {
|
skills.insert(
|
||||||
name,
|
name.clone(),
|
||||||
path: path.display().to_string(),
|
DiscoveredSkill {
|
||||||
scope,
|
name,
|
||||||
source,
|
path: path.display().to_string(),
|
||||||
});
|
scope,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1628,29 +1710,58 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get information about where skill configuration is stored
|
/// Get information about where skill configuration is stored
|
||||||
pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path>) -> Result<SkillConfigSources> {
|
pub async fn get_skill_sources(
|
||||||
|
skill_name: &str,
|
||||||
|
working_directory: Option<&Path>,
|
||||||
|
) -> Result<SkillConfigSources> {
|
||||||
ensure_skill_dirs().await?;
|
ensure_skill_dirs().await?;
|
||||||
|
|
||||||
// Check all possible locations
|
// Check all possible locations
|
||||||
let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name));
|
let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name));
|
||||||
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
|
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
|
||||||
let project_dir = project_exists.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name))).flatten();
|
let project_dir = project_exists
|
||||||
|
.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name)))
|
||||||
|
.flatten();
|
||||||
|
|
||||||
let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name));
|
let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name));
|
||||||
let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false);
|
let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false);
|
||||||
let claude_dir = claude_exists.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name))).flatten();
|
let claude_dir = claude_exists
|
||||||
|
.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name)))
|
||||||
|
.flatten();
|
||||||
|
|
||||||
let user_path = get_user_skill_path(skill_name);
|
let user_path = get_user_skill_path(skill_name);
|
||||||
let user_exists = user_path.exists();
|
let user_exists = user_path.exists();
|
||||||
let user_dir = if user_exists { Some(get_user_skill_dir(skill_name)) } else { None };
|
let user_dir = if user_exists {
|
||||||
|
Some(get_user_skill_dir(skill_name))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Determine which md file to use (priority: project > claude > user)
|
// Determine which md file to use (priority: project > claude > user)
|
||||||
let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists {
|
let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists {
|
||||||
(project_path.clone(), true, Some(Scope::Project), Some(SkillSource::Opencode), project_dir.clone())
|
(
|
||||||
|
project_path.clone(),
|
||||||
|
true,
|
||||||
|
Some(Scope::Project),
|
||||||
|
Some(SkillSource::Opencode),
|
||||||
|
project_dir.clone(),
|
||||||
|
)
|
||||||
} else if claude_exists {
|
} else if claude_exists {
|
||||||
(claude_path.clone(), true, Some(Scope::Project), Some(SkillSource::Claude), claude_dir.clone())
|
(
|
||||||
|
claude_path.clone(),
|
||||||
|
true,
|
||||||
|
Some(Scope::Project),
|
||||||
|
Some(SkillSource::Claude),
|
||||||
|
claude_dir.clone(),
|
||||||
|
)
|
||||||
} else if user_exists {
|
} else if user_exists {
|
||||||
(Some(user_path.clone()), true, Some(Scope::User), Some(SkillSource::Opencode), user_dir.clone())
|
(
|
||||||
|
Some(user_path.clone()),
|
||||||
|
true,
|
||||||
|
Some(Scope::User),
|
||||||
|
Some(SkillSource::Opencode),
|
||||||
|
user_dir.clone(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(None, false, None, None, None)
|
(None, false, None, None, None)
|
||||||
};
|
};
|
||||||
@@ -1667,8 +1778,16 @@ pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path
|
|||||||
md_fields.extend(md_data.frontmatter.keys().cloned());
|
md_fields.extend(md_data.frontmatter.keys().cloned());
|
||||||
|
|
||||||
// Extract actual content values
|
// Extract actual content values
|
||||||
md_name = md_data.frontmatter.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
|
md_name = md_data
|
||||||
md_description = md_data.frontmatter.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
|
.frontmatter
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
md_description = md_data
|
||||||
|
.frontmatter
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
if !md_data.body.trim().is_empty() {
|
if !md_data.body.trim().is_empty() {
|
||||||
md_fields.push("instructions".to_string());
|
md_fields.push("instructions".to_string());
|
||||||
@@ -1719,7 +1838,11 @@ pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write a supporting file
|
/// Write a supporting file
|
||||||
pub async fn write_skill_supporting_file(skill_dir: &Path, relative_path: &str, content: &str) -> Result<()> {
|
pub async fn write_skill_supporting_file(
|
||||||
|
skill_dir: &Path,
|
||||||
|
relative_path: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<()> {
|
||||||
let full_path = skill_dir.join(relative_path);
|
let full_path = skill_dir.join(relative_path);
|
||||||
if let Some(parent) = full_path.parent() {
|
if let Some(parent) = full_path.parent() {
|
||||||
fs::create_dir_all(parent).await?;
|
fs::create_dir_all(parent).await?;
|
||||||
@@ -1834,7 +1957,12 @@ pub async fn create_skill(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Created new skill: {} (scope: {:?}, path: {})", skill_name, target_scope, target_path.display());
|
info!(
|
||||||
|
"Created new skill: {} (scope: {:?}, path: {})",
|
||||||
|
skill_name,
|
||||||
|
target_scope,
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1846,7 +1974,9 @@ pub async fn update_skill(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (_, existing_path, _) = get_skill_scope(skill_name, working_directory);
|
let (_, existing_path, _) = get_skill_scope(skill_name, working_directory);
|
||||||
let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?;
|
let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?;
|
||||||
let md_dir = md_path.parent().ok_or_else(|| anyhow!("Invalid skill path"))?;
|
let md_dir = md_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow!("Invalid skill path"))?;
|
||||||
|
|
||||||
let mut md_data = parse_md_file(&md_path).await?;
|
let mut md_data = parse_md_file(&md_path).await?;
|
||||||
let mut md_modified = false;
|
let mut md_modified = false;
|
||||||
@@ -1889,7 +2019,11 @@ pub async fn update_skill(
|
|||||||
write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?;
|
write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Updated skill: {} (path: {})", skill_name, md_path.display());
|
info!(
|
||||||
|
"Updated skill: {} (path: {})",
|
||||||
|
skill_name,
|
||||||
|
md_path.display()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1903,7 +2037,10 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) ->
|
|||||||
let project_dir = get_project_skill_dir(wd, skill_name);
|
let project_dir = get_project_skill_dir(wd, skill_name);
|
||||||
if project_dir.exists() {
|
if project_dir.exists() {
|
||||||
fs::remove_dir_all(&project_dir).await?;
|
fs::remove_dir_all(&project_dir).await?;
|
||||||
info!("Deleted project-level skill directory: {}", project_dir.display());
|
info!(
|
||||||
|
"Deleted project-level skill directory: {}",
|
||||||
|
project_dir.display()
|
||||||
|
);
|
||||||
deleted = true;
|
deleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1911,7 +2048,10 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) ->
|
|||||||
let claude_dir = get_claude_skill_dir(wd, skill_name);
|
let claude_dir = get_claude_skill_dir(wd, skill_name);
|
||||||
if claude_dir.exists() {
|
if claude_dir.exists() {
|
||||||
fs::remove_dir_all(&claude_dir).await?;
|
fs::remove_dir_all(&claude_dir).await?;
|
||||||
info!("Deleted claude-compat skill directory: {}", claude_dir.display());
|
info!(
|
||||||
|
"Deleted claude-compat skill directory: {}",
|
||||||
|
claude_dir.display()
|
||||||
|
);
|
||||||
deleted = true;
|
deleted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,14 +268,12 @@ impl OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_process(&self) -> Result<Child> {
|
async fn spawn_process(&self) -> Result<Child> {
|
||||||
let binary = self.binary.as_ref().ok_or_else(|| {
|
let binary = self
|
||||||
anyhow!("Cannot spawn process: OpenCode CLI is not available")
|
.binary
|
||||||
})?;
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?;
|
||||||
|
|
||||||
info!(
|
info!("[desktop:opencode] launching {} {:?}", binary, self.args);
|
||||||
"[desktop:opencode] launching {} {:?}",
|
|
||||||
binary, self.args
|
|
||||||
);
|
|
||||||
|
|
||||||
let working_dir = self.working_dir.read().clone();
|
let working_dir = self.working_dir.read().clone();
|
||||||
let mut cmd = Command::new(binary);
|
let mut cmd = Command::new(binary);
|
||||||
@@ -520,7 +518,10 @@ fn resolve_opencode_binary() -> Option<String> {
|
|||||||
|
|
||||||
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
|
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
|
||||||
if !value.is_empty() && Path::new(&value).exists() {
|
if !value.is_empty() && Path::new(&value).exists() {
|
||||||
info!("[desktop:opencode] using binary from OPENCODE_BINARY env: {}", value);
|
info!(
|
||||||
|
"[desktop:opencode] using binary from OPENCODE_BINARY env: {}",
|
||||||
|
value
|
||||||
|
);
|
||||||
return Some(value);
|
return Some(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,7 +530,10 @@ fn resolve_opencode_binary() -> Option<String> {
|
|||||||
|
|
||||||
if let Some(ref binary) = shell_env.opencode_binary {
|
if let Some(ref binary) = shell_env.opencode_binary {
|
||||||
if Path::new(binary).exists() {
|
if Path::new(binary).exists() {
|
||||||
info!("[desktop:opencode] using binary from shell OPENCODE_BINARY: {}", binary);
|
info!(
|
||||||
|
"[desktop:opencode] using binary from shell OPENCODE_BINARY: {}",
|
||||||
|
binary
|
||||||
|
);
|
||||||
return Some(binary.clone());
|
return Some(binary.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,7 +551,10 @@ fn resolve_opencode_binary() -> Option<String> {
|
|||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
let fallback = home.join(".opencode/bin/opencode");
|
let fallback = home.join(".opencode/bin/opencode");
|
||||||
if fallback.exists() {
|
if fallback.exists() {
|
||||||
info!("[desktop:opencode] found binary in fallback location: {:?}", fallback);
|
info!(
|
||||||
|
"[desktop:opencode] found binary in fallback location: {:?}",
|
||||||
|
fallback
|
||||||
|
);
|
||||||
return Some(fallback.to_string_lossy().to_string());
|
return Some(fallback.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,8 +597,8 @@ struct ShellEnv {
|
|||||||
fn get_user_shell() -> Option<String> {
|
fn get_user_shell() -> Option<String> {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let username = dirs::home_dir()
|
let username =
|
||||||
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
|
dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
|
||||||
|
|
||||||
let output = Command::new("dscl")
|
let output = Command::new("dscl")
|
||||||
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
|
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
|
||||||
@@ -663,7 +670,10 @@ fn detect_shell_env() -> ShellEnv {
|
|||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
warn!("[desktop:opencode] shell env detection failed for {}, stderr: {}", shell, stderr);
|
warn!(
|
||||||
|
"[desktop:opencode] shell env detection failed for {}, stderr: {}",
|
||||||
|
shell, stderr
|
||||||
|
);
|
||||||
return ShellEnv::default();
|
return ShellEnv::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +693,10 @@ fn detect_shell_env() -> ShellEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("[desktop:opencode] parsed path exists: {}", env.path.is_some());
|
info!(
|
||||||
|
"[desktop:opencode] parsed path exists: {}",
|
||||||
|
env.path.is_some()
|
||||||
|
);
|
||||||
env
|
env
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,3 @@ pub fn expand_tilde_path(value: &str) -> PathBuf {
|
|||||||
|
|
||||||
PathBuf::from(trimmed)
|
PathBuf::from(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
use std::{
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
collections::HashMap,
|
|
||||||
sync::Arc,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
@@ -57,7 +53,10 @@ pub fn spawn_session_activity_tracker(
|
|||||||
|
|
||||||
let mut shutdown_rx = runtime.subscribe_shutdown();
|
let mut shutdown_rx = runtime.subscribe_shutdown();
|
||||||
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
|
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
|
||||||
let cooldowns = Arc::new(Mutex::new(HashMap::<String, tauri::async_runtime::JoinHandle<()>>::new()));
|
let cooldowns = Arc::new(Mutex::new(HashMap::<
|
||||||
|
String,
|
||||||
|
tauri::async_runtime::JoinHandle<()>,
|
||||||
|
>::new()));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -112,7 +111,11 @@ async fn run_once(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
let bytes_read = match tokio::time::timeout(Duration::from_secs(2), reader.read_until(b'\n', &mut buf)).await
|
let bytes_read = match tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
reader.read_until(b'\n', &mut buf),
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(n)) => n,
|
Ok(Ok(n)) => n,
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
@@ -155,7 +158,9 @@ async fn run_once(
|
|||||||
data_lines.clear();
|
data_lines.clear();
|
||||||
|
|
||||||
match parse_event_envelope(&raw) {
|
match parse_event_envelope(&raw) {
|
||||||
Ok((event, _directory)) => handle_event(app, event, phases.clone(), cooldowns.clone()).await,
|
Ok((event, _directory)) => {
|
||||||
|
handle_event(app, event, phases.clone(), cooldowns.clone()).await
|
||||||
|
}
|
||||||
Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
|
Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
@@ -214,7 +219,9 @@ async fn connect_activity_sse(
|
|||||||
let working_dir = opencode.get_working_directory();
|
let working_dir = opencode.get_working_directory();
|
||||||
let directory = working_dir.to_string_lossy().to_string();
|
let directory = working_dir.to_string_lossy().to_string();
|
||||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||||
parsed.query_pairs_mut().append_pair("directory", &directory);
|
parsed
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("directory", &directory);
|
||||||
let directory_url = parsed.to_string();
|
let directory_url = parsed.to_string();
|
||||||
|
|
||||||
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?;
|
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?;
|
||||||
@@ -222,7 +229,11 @@ async fn connect_activity_sse(
|
|||||||
Ok((response, SseScope::Directory(working_dir)))
|
Ok((response, SseScope::Directory(working_dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_connect_sse(client: &Client, url: &str, log_prefix: &str) -> Result<reqwest::Response> {
|
async fn try_connect_sse(
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
log_prefix: &str,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
debug!("{log_prefix} Connecting SSE: {url}");
|
debug!("{log_prefix} Connecting SSE: {url}");
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -280,7 +291,14 @@ async fn handle_event(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
if let Some(id) = session_id {
|
if let Some(id) = session_id {
|
||||||
set_phase(app, &id, ActivityPhase::Idle, phases.clone(), cooldowns.clone()).await;
|
set_phase(
|
||||||
|
app,
|
||||||
|
&id,
|
||||||
|
ActivityPhase::Idle,
|
||||||
|
phases.clone(),
|
||||||
|
cooldowns.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"message.updated" => {
|
"message.updated" => {
|
||||||
@@ -326,7 +344,14 @@ async fn handle_event(
|
|||||||
|
|
||||||
// Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
|
// Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
|
||||||
if is_streaming_assistant_part(&event.properties) {
|
if is_streaming_assistant_part(&event.properties) {
|
||||||
set_phase(app, &id, ActivityPhase::Busy, phases.clone(), cooldowns.clone()).await;
|
set_phase(
|
||||||
|
app,
|
||||||
|
&id,
|
||||||
|
ActivityPhase::Busy,
|
||||||
|
phases.clone(),
|
||||||
|
cooldowns.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive cooldown from info.finish === 'stop' when present.
|
// Derive cooldown from info.finish === 'stop' when present.
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::opencode_config;
|
use crate::opencode_config;
|
||||||
|
|
||||||
static SKILL_NAME_RE: Lazy<Regex> = Lazy::new(|| {
|
static SKILL_NAME_RE: Lazy<Regex> =
|
||||||
Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex")
|
Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex"));
|
||||||
});
|
|
||||||
|
|
||||||
static AUTH_ERROR_RE: Lazy<Regex> = Lazy::new(|| {
|
static AUTH_ERROR_RE: Lazy<Regex> = Lazy::new(|| {
|
||||||
Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)")
|
Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)")
|
||||||
@@ -188,7 +187,10 @@ fn list_identities() -> Vec<IdentitySummary> {
|
|||||||
wrapper
|
wrapper
|
||||||
.profiles
|
.profiles
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|p| IdentitySummary { id: p.id, name: p.name })
|
.map(|p| IdentitySummary {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +316,12 @@ fn parse_skill_md_frontmatter(contents: &str) -> (Option<String>, Option<String>
|
|||||||
(name, description, warnings)
|
(name, description, warnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_git(args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Duration) -> Result<(String, String)> {
|
async fn run_git(
|
||||||
|
args: &[String],
|
||||||
|
cwd: &Path,
|
||||||
|
ssh_key: Option<&str>,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
let mut cmd = Command::new("git");
|
let mut cmd = Command::new("git");
|
||||||
|
|
||||||
if let Some(key) = ssh_key {
|
if let Some(key) = ssh_key {
|
||||||
@@ -324,7 +331,8 @@ async fn run_git(args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Du
|
|||||||
"ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
|
"ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
|
||||||
key
|
key
|
||||||
);
|
);
|
||||||
cmd.arg("-c").arg(format!("core.sshCommand={}", ssh_command));
|
cmd.arg("-c")
|
||||||
|
.arg(format!("core.sshCommand={}", ssh_command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +412,10 @@ async fn clone_repo(clone_url: &str, target_dir: &Path, ssh_key: Option<&str>) -
|
|||||||
|
|
||||||
let cwd = std::env::temp_dir();
|
let cwd = std::env::temp_dir();
|
||||||
|
|
||||||
if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60)).await.is_ok() {
|
if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +432,18 @@ async fn scan_repo_items(
|
|||||||
subpath: Option<&str>,
|
subpath: Option<&str>,
|
||||||
default_subpath: Option<&str>,
|
default_subpath: Option<&str>,
|
||||||
ssh_key: Option<&str>,
|
ssh_key: Option<&str>,
|
||||||
) -> Result<(String, Option<String>, Vec<(String, String, Option<String>, Option<String>, Vec<String>, bool)>)> {
|
) -> Result<(
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Vec<(
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Vec<String>,
|
||||||
|
bool,
|
||||||
|
)>,
|
||||||
|
)> {
|
||||||
let parsed = parse_repo_source(source, subpath)?;
|
let parsed = parse_repo_source(source, subpath)?;
|
||||||
let effective_subpath = parsed
|
let effective_subpath = parsed
|
||||||
.effective_subpath
|
.effective_subpath
|
||||||
@@ -435,7 +457,10 @@ async fn scan_repo_items(
|
|||||||
parsed.clone_https.clone()
|
parsed.clone_https.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let temp_base = std::env::temp_dir().join(format!("openchamber-desktop-skills-scan-{}", Uuid::new_v4()));
|
let temp_base = std::env::temp_dir().join(format!(
|
||||||
|
"openchamber-desktop-skills-scan-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
|
||||||
// Clone into temp_base (directory must not exist for git clone target)
|
// Clone into temp_base (directory must not exist for git clone target)
|
||||||
let _ = tokio::fs::remove_dir_all(&temp_base).await;
|
let _ = tokio::fs::remove_dir_all(&temp_base).await;
|
||||||
@@ -482,7 +507,13 @@ async fn scan_repo_items(
|
|||||||
];
|
];
|
||||||
set_args.extend(patterns.clone());
|
set_args.extend(patterns.clone());
|
||||||
|
|
||||||
let sparse_set = run_git(&set_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30)).await;
|
let sparse_set = run_git(
|
||||||
|
&set_args,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
ssh_key,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if sparse_set.is_ok() {
|
if sparse_set.is_ok() {
|
||||||
let checkout = run_git(
|
let checkout = run_git(
|
||||||
&vec![
|
&vec![
|
||||||
@@ -539,7 +570,13 @@ async fn scan_repo_items(
|
|||||||
list_args.push(sp.clone());
|
list_args.push(sp.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let list_out = run_git(&list_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30)).await;
|
let list_out = run_git(
|
||||||
|
&list_args,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
ssh_key,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let stdout = match list_out {
|
let stdout = match list_out {
|
||||||
Ok((out, _)) => out,
|
Ok((out, _)) => out,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -605,7 +642,14 @@ async fn scan_repo_items(
|
|||||||
format!("HEAD:{}", skill_md_repo_path),
|
format!("HEAD:{}", skill_md_repo_path),
|
||||||
];
|
];
|
||||||
|
|
||||||
match run_git(&show_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(15)).await {
|
match run_git(
|
||||||
|
&show_args,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
ssh_key,
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok((out, _)) => out,
|
Ok((out, _)) => out,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
warnings.push("Failed to read SKILL.md".to_string());
|
warnings.push("Failed to read SKILL.md".to_string());
|
||||||
@@ -615,7 +659,8 @@ async fn scan_repo_items(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (frontmatter_name, description, mut fm_warnings) = parse_skill_md_frontmatter(&contents);
|
let (frontmatter_name, description, mut fm_warnings) =
|
||||||
|
parse_skill_md_frontmatter(&contents);
|
||||||
warnings.append(&mut fm_warnings);
|
warnings.append(&mut fm_warnings);
|
||||||
|
|
||||||
let installable = validate_skill_name(&skill_name);
|
let installable = validate_skill_name(&skill_name);
|
||||||
@@ -644,7 +689,8 @@ struct CacheEntry {
|
|||||||
items: Vec<SkillsCatalogItem>,
|
items: Vec<SkillsCatalogItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> =
|
||||||
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String {
|
fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String {
|
||||||
format!(
|
format!(
|
||||||
@@ -656,13 +702,12 @@ fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
|
fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
|
||||||
let settings_path = dirs::home_dir()
|
let settings_path = dirs::home_dir().map(|mut home| {
|
||||||
.map(|mut home| {
|
home.push(".config");
|
||||||
home.push(".config");
|
home.push("openchamber");
|
||||||
home.push("openchamber");
|
home.push("settings.json");
|
||||||
home.push("settings.json");
|
home
|
||||||
home
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let Some(path) = settings_path else {
|
let Some(path) = settings_path else {
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -684,13 +729,31 @@ fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
|
|||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
for entry in arr {
|
for entry in arr {
|
||||||
let Some(obj) = entry.as_object() else { continue };
|
let Some(obj) = entry.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||||
let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim();
|
let label = obj
|
||||||
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.get("label")
|
||||||
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.and_then(|v| v.as_str())
|
||||||
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let source = obj
|
||||||
|
.get("source")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let subpath = obj
|
||||||
|
.get("subpath")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let git_identity_id = obj
|
||||||
|
.get("gitIdentityId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
if id.is_empty() || label.is_empty() || source.is_empty() {
|
if id.is_empty() || label.is_empty() || source.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -706,8 +769,16 @@ fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
|
|||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
description: Some(source.to_string()),
|
description: Some(source.to_string()),
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
default_subpath: if subpath.is_empty() { None } else { Some(subpath.to_string()) },
|
default_subpath: if subpath.is_empty() {
|
||||||
git_identity_id: if git_identity_id.is_empty() { None } else { Some(git_identity_id.to_string()) },
|
None
|
||||||
|
} else {
|
||||||
|
Some(subpath.to_string())
|
||||||
|
},
|
||||||
|
git_identity_id: if git_identity_id.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(git_identity_id.to_string())
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,8 +803,10 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
|
|||||||
let sources = get_curated_sources().await;
|
let sources = get_curated_sources().await;
|
||||||
|
|
||||||
let discovered = opencode_config::discover_skills(Some(working_directory));
|
let discovered = opencode_config::discover_skills(Some(working_directory));
|
||||||
let installed_by_name: HashMap<String, opencode_config::DiscoveredSkill> =
|
let installed_by_name: HashMap<String, opencode_config::DiscoveredSkill> = discovered
|
||||||
discovered.into_iter().map(|s| (s.name.clone(), s)).collect();
|
.into_iter()
|
||||||
|
.map(|s| (s.name.clone(), s))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut items_by_source: HashMap<String, Vec<SkillsCatalogItem>> = HashMap::new();
|
let mut items_by_source: HashMap<String, Vec<SkillsCatalogItem>> = HashMap::new();
|
||||||
|
|
||||||
@@ -752,7 +825,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
|
|||||||
.or(parsed.effective_subpath.as_deref())
|
.or(parsed.effective_subpath.as_deref())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let key = cache_key(&parsed.normalized_repo, Some(effective_subpath), src.git_identity_id.as_deref());
|
let key = cache_key(
|
||||||
|
&parsed.normalized_repo,
|
||||||
|
Some(effective_subpath),
|
||||||
|
src.git_identity_id.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
let maybe_cached = if refresh {
|
let maybe_cached = if refresh {
|
||||||
None
|
None
|
||||||
@@ -773,7 +850,13 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
|
|||||||
items
|
items
|
||||||
} else {
|
} else {
|
||||||
let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref());
|
let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref());
|
||||||
let scan = scan_repo_items(&src.source, None, src.default_subpath.as_deref(), ssh_key.as_deref()).await;
|
let scan = scan_repo_items(
|
||||||
|
&src.source,
|
||||||
|
None,
|
||||||
|
src.default_subpath.as_deref(),
|
||||||
|
ssh_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let (_, _, raw_items) = match scan {
|
let (_, _, raw_items) = match scan {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -804,7 +887,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
|
|||||||
frontmatter_name: fm_name,
|
frontmatter_name: fm_name,
|
||||||
description: desc,
|
description: desc,
|
||||||
installable,
|
installable,
|
||||||
warnings: if warnings.is_empty() { None } else { Some(warnings) },
|
warnings: if warnings.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(warnings)
|
||||||
|
},
|
||||||
installed: SkillsCatalogInstalledBadge {
|
installed: SkillsCatalogInstalledBadge {
|
||||||
is_installed: installed.is_some(),
|
is_installed: installed.is_some(),
|
||||||
scope: installed.map(|s| match s.scope {
|
scope: installed.map(|s| match s.scope {
|
||||||
@@ -865,7 +952,14 @@ pub struct SkillsScanRequest {
|
|||||||
pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
|
pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
|
||||||
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
|
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
|
||||||
|
|
||||||
match scan_repo_items(&req.source, req.subpath.as_deref(), None, ssh_key.as_deref()).await {
|
match scan_repo_items(
|
||||||
|
&req.source,
|
||||||
|
req.subpath.as_deref(),
|
||||||
|
None,
|
||||||
|
ssh_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok((_normalized, effective_subpath, raw_items)) => {
|
Ok((_normalized, effective_subpath, raw_items)) => {
|
||||||
let mut items = vec![];
|
let mut items = vec![];
|
||||||
for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items {
|
for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items {
|
||||||
@@ -886,8 +980,15 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
|
|||||||
frontmatter_name: fm_name,
|
frontmatter_name: fm_name,
|
||||||
description: desc,
|
description: desc,
|
||||||
installable,
|
installable,
|
||||||
warnings: if warnings.is_empty() { None } else { Some(warnings) },
|
warnings: if warnings.is_empty() {
|
||||||
installed: SkillsCatalogInstalledBadge { is_installed: false, scope: None },
|
None
|
||||||
|
} else {
|
||||||
|
Some(warnings)
|
||||||
|
},
|
||||||
|
installed: SkillsCatalogInstalledBadge {
|
||||||
|
is_installed: false,
|
||||||
|
scope: None,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name));
|
items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name));
|
||||||
@@ -903,7 +1004,9 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
|
|||||||
return SkillsRepoScanResponse {
|
return SkillsRepoScanResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
items: None,
|
items: None,
|
||||||
error: Some(auth_required_error("Authentication required to access this repository")),
|
error: Some(auth_required_error(
|
||||||
|
"Authentication required to access this repository",
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,7 +1051,10 @@ fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
if scope == "project" {
|
if scope == "project" {
|
||||||
return Ok(working_directory.join(".opencode").join("skill").join(skill_name));
|
return Ok(working_directory
|
||||||
|
.join(".opencode")
|
||||||
|
.join("skill")
|
||||||
|
.join(skill_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!("Invalid scope"))
|
Err(anyhow!("Invalid scope"))
|
||||||
@@ -1017,7 +1123,10 @@ async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) -> SkillsInstallResponse {
|
pub async fn install_skills(
|
||||||
|
working_directory: &Path,
|
||||||
|
req: SkillsInstallRequest,
|
||||||
|
) -> SkillsInstallResponse {
|
||||||
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
|
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
|
||||||
|
|
||||||
let selections: Vec<String> = req
|
let selections: Vec<String> = req
|
||||||
@@ -1032,7 +1141,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
ok: false,
|
ok: false,
|
||||||
installed: None,
|
installed: None,
|
||||||
skipped: None,
|
skipped: None,
|
||||||
error: Some(simple_error("invalidSource", "No skills selected for installation")),
|
error: Some(simple_error(
|
||||||
|
"invalidSource",
|
||||||
|
"No skills selected for installation",
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,7 +1184,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
let auto = req.conflict_policy.as_deref().unwrap_or("prompt");
|
let auto = req.conflict_policy.as_deref().unwrap_or("prompt");
|
||||||
|
|
||||||
if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" {
|
if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" {
|
||||||
conflicts.push(SkillConflict { skill_name, scope: req.scope.clone() });
|
conflicts.push(SkillConflict {
|
||||||
|
skill_name,
|
||||||
|
scope: req.scope.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1105,7 +1220,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
parsed.clone_https.clone()
|
parsed.clone_https.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let temp_base = std::env::temp_dir().join(format!("openchamber-desktop-skills-install-{}", Uuid::new_v4()));
|
let temp_base = std::env::temp_dir().join(format!(
|
||||||
|
"openchamber-desktop-skills-install-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
let _ = tokio::fs::remove_dir_all(&temp_base).await;
|
let _ = tokio::fs::remove_dir_all(&temp_base).await;
|
||||||
|
|
||||||
let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).await;
|
let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).await;
|
||||||
@@ -1116,7 +1234,9 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
ok: false,
|
ok: false,
|
||||||
installed: None,
|
installed: None,
|
||||||
skipped: None,
|
skipped: None,
|
||||||
error: Some(auth_required_error("Authentication required to access this repository")),
|
error: Some(auth_required_error(
|
||||||
|
"Authentication required to access this repository",
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,7 +1256,13 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
"init".to_string(),
|
"init".to_string(),
|
||||||
"--cone".to_string(),
|
"--cone".to_string(),
|
||||||
];
|
];
|
||||||
let _ = run_git(&init_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(15)).await;
|
let _ = run_git(
|
||||||
|
&init_args,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
ssh_key.as_deref(),
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut set_args = vec![
|
let mut set_args = vec![
|
||||||
"-C".to_string(),
|
"-C".to_string(),
|
||||||
@@ -1148,7 +1274,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
set_args.push(dir.clone());
|
set_args.push(dir.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = run_git(&set_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(30)).await {
|
if let Err(err) = run_git(
|
||||||
|
&set_args,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
ssh_key.as_deref(),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
safe_rm(&temp_base).await;
|
safe_rm(&temp_base).await;
|
||||||
return SkillsInstallResponse {
|
return SkillsInstallResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1166,7 +1299,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
"HEAD".to_string(),
|
"HEAD".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Err(err) = run_git(&checkout_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(60)).await {
|
if let Err(err) = run_git(
|
||||||
|
&checkout_args,
|
||||||
|
&std::env::temp_dir(),
|
||||||
|
ssh_key.as_deref(),
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
safe_rm(&temp_base).await;
|
safe_rm(&temp_base).await;
|
||||||
return SkillsInstallResponse {
|
return SkillsInstallResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1188,21 +1328,30 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
if !validate_skill_name(&skill_name) {
|
if !validate_skill_name(&skill_name) {
|
||||||
skipped.push(SkippedSkill { skill_name, reason: "Invalid skill name (directory basename)".to_string() });
|
skipped.push(SkippedSkill {
|
||||||
|
skill_name,
|
||||||
|
reason: "Invalid skill name (directory basename)".to_string(),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let src_dir = repo_path_to_fs(&temp_base, &skill_dir);
|
let src_dir = repo_path_to_fs(&temp_base, &skill_dir);
|
||||||
let skill_md = src_dir.join("SKILL.md");
|
let skill_md = src_dir.join("SKILL.md");
|
||||||
if !skill_md.exists() {
|
if !skill_md.exists() {
|
||||||
skipped.push(SkippedSkill { skill_name, reason: "SKILL.md not found in selected directory".to_string() });
|
skipped.push(SkippedSkill {
|
||||||
|
skill_name,
|
||||||
|
reason: "SKILL.md not found in selected directory".to_string(),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) {
|
let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
skipped.push(SkippedSkill { skill_name, reason: err.to_string() });
|
skipped.push(SkippedSkill {
|
||||||
|
skill_name,
|
||||||
|
reason: err.to_string(),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1234,7 +1383,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if exists && decision.as_deref() == Some("skip") {
|
if exists && decision.as_deref() == Some("skip") {
|
||||||
skipped.push(SkippedSkill { skill_name, reason: "Already installed (skipped)".to_string() });
|
skipped.push(SkippedSkill {
|
||||||
|
skill_name,
|
||||||
|
reason: "Already installed (skipped)".to_string(),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,11 +1400,17 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
|
|||||||
|
|
||||||
if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await {
|
if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await {
|
||||||
let _ = tokio::fs::remove_dir_all(&target_dir).await;
|
let _ = tokio::fs::remove_dir_all(&target_dir).await;
|
||||||
skipped.push(SkippedSkill { skill_name, reason: err.to_string() });
|
skipped.push(SkippedSkill {
|
||||||
|
skill_name,
|
||||||
|
reason: err.to_string(),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
installed.push(InstalledSkill { skill_name, scope: req.scope.clone() });
|
installed.push(InstalledSkill {
|
||||||
|
skill_name,
|
||||||
|
scope: req.scope.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
safe_rm(&temp_base).await;
|
safe_rm(&temp_base).await;
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
|
|||||||
|
|
||||||
const startListening = async () => {
|
const startListening = async () => {
|
||||||
try {
|
try {
|
||||||
const unlisten = await safeListen<TerminalStreamEvent>(`terminal://${sessionId}`, (event) => {
|
const unlisten = await safeListen<TerminalStreamEvent>(
|
||||||
|
`terminal://${sessionId}`,
|
||||||
|
(event) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,12 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
|
|||||||
if (event.payload?.type === 'exit') {
|
if (event.payload?.type === 'exit') {
|
||||||
stopListening();
|
stopListening();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
// Terminal streams are long-lived; never auto-expire this listener.
|
||||||
|
timeout: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
unlisten();
|
unlisten();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface PendingCallback {
|
|||||||
type: 'invoke' | 'listen';
|
type: 'invoke' | 'listen';
|
||||||
cleanup?: () => void;
|
cleanup?: () => void;
|
||||||
timeout?: NodeJS.Timeout;
|
timeout?: NodeJS.Timeout;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CallbackManagerConfig {
|
interface CallbackManagerConfig {
|
||||||
@@ -51,10 +52,17 @@ class TauriCallbackManager {
|
|||||||
|
|
||||||
this.callbacks.set(callback.id, fullCallback);
|
this.callbacks.set(callback.id, fullCallback);
|
||||||
|
|
||||||
if (callback.type === 'listen' && this.config.listenTimeout > 0) {
|
const timeoutMs =
|
||||||
|
typeof fullCallback.timeoutMs === 'number'
|
||||||
|
? fullCallback.timeoutMs
|
||||||
|
: callback.type === 'listen'
|
||||||
|
? this.config.listenTimeout
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (timeoutMs > 0) {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.cleanupCallback(callback.id, 'timeout');
|
this.cleanupCallback(callback.id, 'timeout');
|
||||||
}, this.config.listenTimeout);
|
}, timeoutMs);
|
||||||
fullCallback.timeout = timeout;
|
fullCallback.timeout = timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +127,9 @@ class TauriCallbackManager {
|
|||||||
const expiredCallbacks: string[] = [];
|
const expiredCallbacks: string[] = [];
|
||||||
|
|
||||||
this.callbacks.forEach((callback, id) => {
|
this.callbacks.forEach((callback, id) => {
|
||||||
|
if (callback.type !== 'invoke') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const age = now - callback.timestamp;
|
const age = now - callback.timestamp;
|
||||||
if (age > this.config.maxCallbackAge) {
|
if (age > this.config.maxCallbackAge) {
|
||||||
expiredCallbacks.push(id);
|
expiredCallbacks.push(id);
|
||||||
@@ -266,6 +277,7 @@ export async function safeListen<T>(
|
|||||||
id: callbackId,
|
id: callbackId,
|
||||||
type: 'listen',
|
type: 'listen',
|
||||||
cleanup: options?.onCancel,
|
cleanup: options?.onCancel,
|
||||||
|
timeoutMs: options?.timeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlisten = await listen<T>(event, (event) => {
|
const unlisten = await listen<T>(event, (event) => {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export default defineConfig({
|
|||||||
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
define: {
|
define: {
|
||||||
'process.env': {},
|
'process.env': {},
|
||||||
'process.platform': JSON.stringify('darwin'),
|
'process.platform': JSON.stringify('darwin'),
|
||||||
|
|||||||
@@ -27,8 +27,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"ghostty-web": "0.3.0",
|
||||||
"@xterm/xterm": "^5.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.0.0",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
|||||||
@@ -1,14 +1,51 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
|
||||||
|
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
import { getTerminalOptions } from '@/lib/terminalTheme';
|
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
|
|
||||||
|
let ghosttyPromise: Promise<Ghostty> | null = null;
|
||||||
|
|
||||||
|
function getGhostty(): Promise<Ghostty> {
|
||||||
|
if (!ghosttyPromise) {
|
||||||
|
ghosttyPromise = Ghostty.load();
|
||||||
|
}
|
||||||
|
return ghosttyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findScrollableViewport(container: HTMLElement): HTMLElement | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [container, ...Array.from(container.querySelectorAll<HTMLElement>('*'))];
|
||||||
|
let fallback: HTMLElement | null = null;
|
||||||
|
|
||||||
|
for (const element of candidates) {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const overflowY = style.overflowY;
|
||||||
|
if (overflowY !== 'auto' && overflowY !== 'scroll') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer an element that is currently scrollable.
|
||||||
|
if (element.scrollHeight - element.clientHeight > 2) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise keep the first overflow container as a fallback so we can
|
||||||
|
// attach touch scroll before scrollback grows.
|
||||||
|
if (!fallback) {
|
||||||
|
fallback = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
type TerminalController = {
|
type TerminalController = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
@@ -34,25 +71,65 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
) => {
|
) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const viewportRef = React.useRef<HTMLElement | null>(null);
|
const viewportRef = React.useRef<HTMLElement | null>(null);
|
||||||
const terminalRef = React.useRef<Terminal | null>(null);
|
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
|
||||||
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
||||||
const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
|
const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
|
||||||
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
|
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
|
||||||
const writeQueueRef = React.useRef<string[]>([]);
|
const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||||
|
const pendingWriteRef = React.useRef('');
|
||||||
|
const writeScheduledRef = React.useRef<number | null>(null);
|
||||||
const isWritingRef = React.useRef(false);
|
const isWritingRef = React.useRef(false);
|
||||||
const processedCountRef = React.useRef(0);
|
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
|
||||||
const firstChunkIdRef = React.useRef<number | null>(null);
|
|
||||||
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
||||||
|
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
||||||
|
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
||||||
|
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
inputHandlerRef.current = onInput;
|
inputHandlerRef.current = onInput;
|
||||||
resizeHandlerRef.current = onResize;
|
resizeHandlerRef.current = onResize;
|
||||||
|
|
||||||
|
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
||||||
|
const input = hiddenInputRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!input || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position the input near the user's tap/cursor so the global keyboard
|
||||||
|
// avoidance logic can decide whether anything is actually obscured.
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const fallbackX = rect.left + rect.width / 2;
|
||||||
|
const fallbackY = rect.top + rect.height - 12;
|
||||||
|
const x = typeof clientX === 'number' ? clientX : fallbackX;
|
||||||
|
const y = typeof clientY === 'number' ? clientY : fallbackY;
|
||||||
|
|
||||||
|
const padding = 8;
|
||||||
|
const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left));
|
||||||
|
const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top));
|
||||||
|
|
||||||
|
input.style.left = `${left}px`;
|
||||||
|
input.style.top = `${top}px`;
|
||||||
|
input.style.bottom = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
input.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
input.focus();
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resetWriteState = React.useCallback(() => {
|
const resetWriteState = React.useCallback(() => {
|
||||||
writeQueueRef.current = [];
|
pendingWriteRef.current = '';
|
||||||
|
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.cancelAnimationFrame(writeScheduledRef.current);
|
||||||
|
}
|
||||||
|
writeScheduledRef.current = null;
|
||||||
isWritingRef.current = false;
|
isWritingRef.current = false;
|
||||||
processedCountRef.current = 0;
|
lastProcessedChunkIdRef.current = null;
|
||||||
firstChunkIdRef.current = null;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fitTerminal = React.useCallback(() => {
|
const fitTerminal = React.useCallback(() => {
|
||||||
@@ -68,61 +145,85 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resizeHandlerRef.current(terminal.cols, terminal.rows);
|
const next = { cols: terminal.cols, rows: terminal.rows };
|
||||||
|
const previous = lastReportedSizeRef.current;
|
||||||
|
if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) {
|
||||||
|
lastReportedSizeRef.current = next;
|
||||||
|
resizeHandlerRef.current(next.cols, next.rows);
|
||||||
|
}
|
||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const flushWriteQueue = React.useCallback(() => {
|
const flushWrites = React.useCallback(() => {
|
||||||
if (isWritingRef.current) {
|
if (isWritingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumeNext = () => {
|
const term = terminalRef.current;
|
||||||
const term = terminalRef.current;
|
if (!term) {
|
||||||
if (!term) {
|
resetWriteState();
|
||||||
resetWriteState();
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = writeQueueRef.current.shift();
|
if (!pendingWriteRef.current) {
|
||||||
if (chunk === undefined) {
|
return;
|
||||||
isWritingRef.current = false;
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isWritingRef.current = true;
|
const chunk = pendingWriteRef.current;
|
||||||
term.write(chunk, () => {
|
pendingWriteRef.current = '';
|
||||||
isWritingRef.current = false;
|
|
||||||
if (writeQueueRef.current.length > 0) {
|
isWritingRef.current = true;
|
||||||
if (typeof window !== 'undefined') {
|
term.write(chunk, () => {
|
||||||
window.setTimeout(consumeNext, 0);
|
isWritingRef.current = false;
|
||||||
} else {
|
if (pendingWriteRef.current) {
|
||||||
consumeNext();
|
if (typeof window !== 'undefined') {
|
||||||
}
|
writeScheduledRef.current = window.requestAnimationFrame(() => {
|
||||||
|
writeScheduledRef.current = null;
|
||||||
|
flushWrites();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
flushWrites();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
consumeNext();
|
|
||||||
}, [resetWriteState]);
|
}, [resetWriteState]);
|
||||||
|
|
||||||
|
const scheduleFlushWrites = React.useCallback(() => {
|
||||||
|
if (writeScheduledRef.current !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
writeScheduledRef.current = window.requestAnimationFrame(() => {
|
||||||
|
writeScheduledRef.current = null;
|
||||||
|
flushWrites();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
flushWrites();
|
||||||
|
}
|
||||||
|
}, [flushWrites]);
|
||||||
|
|
||||||
const enqueueWrite = React.useCallback(
|
const enqueueWrite = React.useCallback(
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeQueueRef.current = [data];
|
pendingWriteRef.current += data;
|
||||||
isWritingRef.current = false;
|
scheduleFlushWrites();
|
||||||
flushWriteQueue();
|
|
||||||
},
|
},
|
||||||
[flushWriteQueue]
|
[scheduleFlushWrites]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setupTouchScroll = React.useCallback(() => {
|
const setupTouchScroll = React.useCallback(() => {
|
||||||
touchScrollCleanupRef.current?.();
|
touchScrollCleanupRef.current?.();
|
||||||
touchScrollCleanupRef.current = null;
|
touchScrollCleanupRef.current = null;
|
||||||
|
|
||||||
|
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
||||||
|
viewportDiscoveryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!enableTouchScroll) {
|
if (!enableTouchScroll) {
|
||||||
|
viewportDiscoveryAttemptsRef.current = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +232,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
// Ghostty scrollback is internal (canvas-based). On touch devices we need
|
||||||
if (!viewport) {
|
// to translate touch deltas into terminal scroll calls.
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewportDiscoveryAttemptsRef.current = 0;
|
||||||
|
|
||||||
const baseScrollMultiplier = 2.2;
|
const baseScrollMultiplier = 2.2;
|
||||||
const maxScrollBoost = 2.8;
|
const maxScrollBoost = 2.8;
|
||||||
const boostDenominator = 25;
|
const boostDenominator = 25;
|
||||||
@@ -149,24 +254,35 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
lastTime: null as number | null,
|
lastTime: null as number | null,
|
||||||
velocity: 0,
|
velocity: 0,
|
||||||
rafId: null as number | null,
|
rafId: null as number | null,
|
||||||
|
startX: null as number | null,
|
||||||
|
startY: null as number | null,
|
||||||
|
didMove: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
|
||||||
const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35));
|
||||||
|
let remainderPx = 0;
|
||||||
const setScrollTop = (nextScrollTop: number) => {
|
|
||||||
const maxScrollTop = getMaxScrollTop();
|
|
||||||
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollByPixels = (deltaPixels: number) => {
|
const scrollByPixels = (deltaPixels: number) => {
|
||||||
if (!deltaPixels) {
|
if (!deltaPixels) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const previous = viewport.scrollTop;
|
|
||||||
setScrollTop(previous + deltaPixels);
|
const before = terminal.getViewportY();
|
||||||
return viewport.scrollTop !== previous;
|
|
||||||
|
const total = remainderPx + deltaPixels;
|
||||||
|
const lines = Math.trunc(total / lineHeightPx);
|
||||||
|
remainderPx = total - lines * lineHeightPx;
|
||||||
|
|
||||||
|
if (lines !== 0) {
|
||||||
|
// Touch delta is in pixels, convert to lines.
|
||||||
|
// Natural mobile scrolling: finger up scrolls down.
|
||||||
|
terminal.scrollLines(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = terminal.getViewportY();
|
||||||
|
return after !== before;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopKinetic = () => {
|
const stopKinetic = () => {
|
||||||
@@ -176,11 +292,18 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
state.rafId = null;
|
state.rafId = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listenerOptions: AddEventListenerOptions = { passive: false, capture: true };
|
const listenerOptions: AddEventListenerOptions = { passive: false, capture: false };
|
||||||
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
|
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
|
||||||
|
|
||||||
if (supportsPointerEvents) {
|
if (supportsPointerEvents) {
|
||||||
const stateWithPointerId = Object.assign(state, { pointerId: null as number | null });
|
const stateWithPointerId = Object.assign(state, {
|
||||||
|
pointerId: null as number | null,
|
||||||
|
startX: null as number | null,
|
||||||
|
startY: null as number | null,
|
||||||
|
moved: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TAP_MOVE_THRESHOLD_PX = 6;
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (event.pointerType !== 'touch') {
|
if (event.pointerType !== 'touch') {
|
||||||
@@ -188,6 +311,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
stopKinetic();
|
stopKinetic();
|
||||||
stateWithPointerId.pointerId = event.pointerId;
|
stateWithPointerId.pointerId = event.pointerId;
|
||||||
|
stateWithPointerId.startX = event.clientX;
|
||||||
|
stateWithPointerId.startY = event.clientY;
|
||||||
|
stateWithPointerId.moved = false;
|
||||||
stateWithPointerId.lastY = event.clientY;
|
stateWithPointerId.lastY = event.clientY;
|
||||||
stateWithPointerId.lastTime = nowMs();
|
stateWithPointerId.lastTime = nowMs();
|
||||||
stateWithPointerId.velocity = 0;
|
stateWithPointerId.velocity = 0;
|
||||||
@@ -201,6 +327,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) {
|
||||||
|
const dx = event.clientX - stateWithPointerId.startX;
|
||||||
|
const dy = event.clientY - stateWithPointerId.startY;
|
||||||
|
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
|
||||||
|
stateWithPointerId.moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stateWithPointerId.lastY === null) {
|
if (stateWithPointerId.lastY === null) {
|
||||||
stateWithPointerId.lastY = event.clientY;
|
stateWithPointerId.lastY = event.clientY;
|
||||||
stateWithPointerId.lastTime = nowMs();
|
stateWithPointerId.lastTime = nowMs();
|
||||||
@@ -230,10 +364,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
stateWithPointerId.velocity = -maxVelocity;
|
stateWithPointerId.velocity = -maxVelocity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.cancelable) {
|
// Only prevent default once we're actually scrolling.
|
||||||
event.preventDefault();
|
if (stateWithPointerId.moved) {
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
event.stopPropagation();
|
|
||||||
scrollByPixels(deltaPixels);
|
scrollByPixels(deltaPixels);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,13 +379,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wasTap = !stateWithPointerId.moved;
|
||||||
|
|
||||||
stateWithPointerId.pointerId = null;
|
stateWithPointerId.pointerId = null;
|
||||||
|
stateWithPointerId.startX = null;
|
||||||
|
stateWithPointerId.startY = null;
|
||||||
|
stateWithPointerId.moved = false;
|
||||||
stateWithPointerId.lastY = null;
|
stateWithPointerId.lastY = null;
|
||||||
stateWithPointerId.lastTime = null;
|
stateWithPointerId.lastTime = null;
|
||||||
try {
|
try {
|
||||||
container.releasePointerCapture(event.pointerId);
|
container.releasePointerCapture(event.pointerId);
|
||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
if (wasTap) {
|
||||||
|
focusHiddenInput(event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,10 +436,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
|
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
|
||||||
|
|
||||||
const previousTouchAction = container.style.touchAction;
|
const previousTouchAction = container.style.touchAction;
|
||||||
container.style.touchAction = 'none';
|
container.style.touchAction = 'manipulation';
|
||||||
|
|
||||||
touchScrollCleanupRef.current = () => {
|
touchScrollCleanupRef.current = () => {
|
||||||
stopKinetic();
|
stopKinetic();
|
||||||
|
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
||||||
|
viewportDiscoveryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
viewportDiscoveryAttemptsRef.current = 0;
|
||||||
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
|
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
|
||||||
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
|
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
|
||||||
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
|
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
|
||||||
@@ -301,6 +455,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAP_MOVE_THRESHOLD_PX = 6;
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
if (event.touches.length !== 1) {
|
if (event.touches.length !== 1) {
|
||||||
return;
|
return;
|
||||||
@@ -309,6 +465,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
state.lastY = event.touches[0].clientY;
|
state.lastY = event.touches[0].clientY;
|
||||||
state.lastTime = nowMs();
|
state.lastTime = nowMs();
|
||||||
state.velocity = 0;
|
state.velocity = 0;
|
||||||
|
state.startX = event.touches[0].clientX;
|
||||||
|
state.startY = event.touches[0].clientY;
|
||||||
|
state.didMove = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
@@ -316,11 +475,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
state.lastY = null;
|
state.lastY = null;
|
||||||
state.lastTime = null;
|
state.lastTime = null;
|
||||||
state.velocity = 0;
|
state.velocity = 0;
|
||||||
|
state.startX = null;
|
||||||
|
state.startY = null;
|
||||||
|
state.didMove = false;
|
||||||
stopKinetic();
|
stopKinetic();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentX = event.touches[0].clientX;
|
||||||
const currentY = event.touches[0].clientY;
|
const currentY = event.touches[0].clientY;
|
||||||
|
|
||||||
|
if (state.startX !== null && state.startY !== null && !state.didMove) {
|
||||||
|
const dx = currentX - state.startX;
|
||||||
|
const dy = currentY - state.startY;
|
||||||
|
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
|
||||||
|
state.didMove = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.lastY === null) {
|
if (state.lastY === null) {
|
||||||
state.lastY = currentY;
|
state.lastY = currentY;
|
||||||
state.lastTime = nowMs();
|
state.lastTime = nowMs();
|
||||||
@@ -350,20 +522,37 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
state.velocity = -maxVelocity;
|
state.velocity = -maxVelocity;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
if (state.didMove) {
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
scrollByPixels(deltaPixels);
|
scrollByPixels(deltaPixels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = (event: TouchEvent) => {
|
||||||
|
const wasTap = !state.didMove;
|
||||||
|
|
||||||
|
|
||||||
state.lastY = null;
|
state.lastY = null;
|
||||||
state.lastTime = null;
|
state.lastTime = null;
|
||||||
|
|
||||||
|
const velocity = state.velocity;
|
||||||
|
state.startX = null;
|
||||||
|
state.startY = null;
|
||||||
|
state.didMove = false;
|
||||||
|
|
||||||
|
if (wasTap) {
|
||||||
|
const point = event.changedTouches?.[0];
|
||||||
|
focusHiddenInput(point?.clientX, point?.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(state.velocity) < minVelocity) {
|
if (Math.abs(velocity) < minVelocity) {
|
||||||
state.velocity = 0;
|
state.velocity = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,77 +583,114 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
|
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
|
||||||
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
|
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
|
||||||
container.addEventListener('touchend', handleTouchEnd, listenerOptions);
|
container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||||
container.addEventListener('touchcancel', handleTouchEnd, listenerOptions);
|
container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||||
|
|
||||||
const previousTouchAction = container.style.touchAction;
|
const previousTouchAction = container.style.touchAction;
|
||||||
container.style.touchAction = 'none';
|
container.style.touchAction = 'manipulation';
|
||||||
|
|
||||||
touchScrollCleanupRef.current = () => {
|
touchScrollCleanupRef.current = () => {
|
||||||
stopKinetic();
|
stopKinetic();
|
||||||
|
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
||||||
|
viewportDiscoveryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
viewportDiscoveryAttemptsRef.current = 0;
|
||||||
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
|
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
|
||||||
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
|
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
|
||||||
container.removeEventListener('touchend', handleTouchEnd, listenerOptions);
|
container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||||
container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions);
|
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||||
container.style.touchAction = previousTouchAction;
|
container.style.touchAction = previousTouchAction;
|
||||||
};
|
};
|
||||||
}, [enableTouchScroll]);
|
}, [enableTouchScroll, focusHiddenInput, fontSize]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
|
let disposed = false;
|
||||||
const fitAddon = new FitAddon();
|
let localTerminal: GhosttyTerminal | null = null;
|
||||||
|
let localResizeObserver: ResizeObserver | null = null;
|
||||||
terminalRef.current = terminal;
|
let localDisposables: Array<{ dispose: () => void }> = [];
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
terminal.loadAddon(fitAddon);
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (container) {
|
if (!container) {
|
||||||
terminal.open(container);
|
|
||||||
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
|
||||||
if (viewport) {
|
|
||||||
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
|
||||||
viewportRef.current = viewport;
|
|
||||||
forceRender();
|
|
||||||
}
|
|
||||||
fitTerminal();
|
|
||||||
terminal.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposables = [
|
|
||||||
terminal.onData((data) => {
|
|
||||||
inputHandlerRef.current(data);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
fitTerminal();
|
|
||||||
});
|
|
||||||
if (container) {
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
touchScrollCleanupRef.current?.();
|
|
||||||
touchScrollCleanupRef.current = null;
|
|
||||||
disposables.forEach((disposable) => disposable.dispose());
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
terminal.dispose();
|
|
||||||
terminalRef.current = null;
|
|
||||||
fitAddonRef.current = null;
|
|
||||||
resetWriteState();
|
|
||||||
};
|
|
||||||
}, [fitTerminal, fontFamily, fontSize, theme, resetWriteState]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
if (!terminal) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = getTerminalOptions(fontFamily, fontSize, theme);
|
|
||||||
Object.assign(terminal.options as Record<string, unknown>, options);
|
container.tabIndex = 0;
|
||||||
fitTerminal();
|
|
||||||
}, [fitTerminal, fontFamily, fontSize, theme]);
|
const initialize = async () => {
|
||||||
|
try {
|
||||||
|
const ghostty = await getGhostty();
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty);
|
||||||
|
|
||||||
|
const terminal = new GhosttyTerminal(options);
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
|
||||||
|
localTerminal = terminal;
|
||||||
|
terminalRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.open(container);
|
||||||
|
bumpTerminalReady();
|
||||||
|
|
||||||
|
const viewport = findScrollableViewport(container);
|
||||||
|
if (viewport) {
|
||||||
|
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
||||||
|
viewportRef.current = viewport;
|
||||||
|
forceRender();
|
||||||
|
} else {
|
||||||
|
viewportRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fitTerminal();
|
||||||
|
setupTouchScroll();
|
||||||
|
terminal.focus();
|
||||||
|
|
||||||
|
localDisposables = [
|
||||||
|
terminal.onData((data: string) => {
|
||||||
|
inputHandlerRef.current(data);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
localResizeObserver = new ResizeObserver(() => {
|
||||||
|
fitTerminal();
|
||||||
|
});
|
||||||
|
localResizeObserver.observe(container);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
fitTerminal();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void initialize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
touchScrollCleanupRef.current?.();
|
||||||
|
touchScrollCleanupRef.current = null;
|
||||||
|
|
||||||
|
localDisposables.forEach((disposable) => disposable.dispose());
|
||||||
|
localResizeObserver?.disconnect();
|
||||||
|
|
||||||
|
localTerminal?.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
viewportRef.current = null;
|
||||||
|
lastReportedSizeRef.current = null;
|
||||||
|
resetWriteState();
|
||||||
|
};
|
||||||
|
}, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
@@ -473,9 +699,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
terminal.reset();
|
terminal.reset();
|
||||||
resetWriteState();
|
resetWriteState();
|
||||||
|
lastReportedSizeRef.current = null;
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
}, [sessionKey, fitTerminal, resetWriteState]);
|
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
@@ -492,7 +719,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
if (processedCountRef.current !== 0) {
|
if (lastProcessedChunkIdRef.current !== null) {
|
||||||
terminal.reset();
|
terminal.reset();
|
||||||
resetWriteState();
|
resetWriteState();
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
@@ -500,31 +727,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentFirstId = chunks[0].id;
|
const lastProcessedId = lastProcessedChunkIdRef.current;
|
||||||
if (firstChunkIdRef.current === null) {
|
let pending: TerminalChunk[];
|
||||||
firstChunkIdRef.current = currentFirstId;
|
|
||||||
|
if (lastProcessedId === null) {
|
||||||
|
pending = chunks;
|
||||||
|
} else {
|
||||||
|
const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId);
|
||||||
|
pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldReset =
|
if (pending.length > 0) {
|
||||||
firstChunkIdRef.current !== currentFirstId || processedCountRef.current > chunks.length;
|
|
||||||
|
|
||||||
if (shouldReset) {
|
|
||||||
terminal.reset();
|
|
||||||
resetWriteState();
|
|
||||||
firstChunkIdRef.current = currentFirstId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processedCountRef.current < chunks.length) {
|
|
||||||
const pending = chunks.slice(processedCountRef.current);
|
|
||||||
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
|
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
|
||||||
processedCountRef.current = chunks.length;
|
|
||||||
}
|
}
|
||||||
}, [chunks, enqueueWrite, fitTerminal, resetWriteState]);
|
|
||||||
|
lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id;
|
||||||
|
}, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
(): TerminalController => ({
|
(): TerminalController => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
|
if (enableTouchScroll) {
|
||||||
|
focusHiddenInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
terminalRef.current?.focus();
|
terminalRef.current?.focus();
|
||||||
},
|
},
|
||||||
clear: () => {
|
clear: () => {
|
||||||
@@ -540,12 +767,69 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
fitTerminal();
|
fitTerminal();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[fitTerminal, resetWriteState]
|
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={cn('relative h-full w-full', className)}>
|
<div
|
||||||
{viewportRef.current ? (
|
ref={containerRef}
|
||||||
|
className={cn('relative h-full w-full', className)}
|
||||||
|
style={{ backgroundColor: theme.background }}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (enableTouchScroll) {
|
||||||
|
focusHiddenInput(event.clientX, event.clientY);
|
||||||
|
} else {
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enableTouchScroll ? (
|
||||||
|
<textarea
|
||||||
|
ref={hiddenInputRef}
|
||||||
|
inputMode="text"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
opacity: 0.001,
|
||||||
|
zIndex: 1,
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onInput={(event) => {
|
||||||
|
const raw = String(event.currentTarget.value || '');
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS often inserts `\n` for Enter; the PTY expects CR.
|
||||||
|
const value = raw.replace(/\r\n|\r|\n/g, '\r');
|
||||||
|
inputHandlerRef.current(value);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
// If there's nothing in the input buffer, emulate DEL.
|
||||||
|
if (!event.currentTarget.value) {
|
||||||
|
inputHandlerRef.current('\x7f');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{viewportRef.current && !enableTouchScroll ? (
|
||||||
<OverlayScrollbar
|
<OverlayScrollbar
|
||||||
containerRef={viewportRef}
|
containerRef={viewportRef}
|
||||||
disableHorizontal
|
disableHorizontal
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'connected': {
|
case 'connected': {
|
||||||
|
if (event.runtime || event.ptyBackend) {
|
||||||
|
console.log(
|
||||||
|
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
setConnecting(directory, false);
|
setConnecting(directory, false);
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export interface TerminalStreamEvent {
|
|||||||
signal?: number | null;
|
signal?: number | null;
|
||||||
attempt?: number;
|
attempt?: number;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
|
|
||||||
|
runtime?: 'node' | 'bun';
|
||||||
|
ptyBackend?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTerminalOptions {
|
export interface CreateTerminalOptions {
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
/**
|
||||||
|
* SerializeAddon for ghostty-web
|
||||||
|
*
|
||||||
|
* Port of xterm.js addon-serialize for ghostty-web terminal.
|
||||||
|
* Enables serialization of terminal contents to restore state after reconnection.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - ANSI color preservation (16-color, 256-color, RGB)
|
||||||
|
* - Text attributes (bold, italic, underline, faint, strikethrough, blink, inverse, invisible, dim)
|
||||||
|
* - Scrollback support with configurable limits
|
||||||
|
* - Round-trip compatibility
|
||||||
|
* - Cursor positioning
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||||
|
|
||||||
|
// Constants for ANSI escape codes
|
||||||
|
const C0 = {
|
||||||
|
ESC: '\u001b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SGR = {
|
||||||
|
RESET: 0,
|
||||||
|
BOLD: 1,
|
||||||
|
DIM: 2,
|
||||||
|
ITALIC: 3,
|
||||||
|
UNDERLINE: 4,
|
||||||
|
SLOW_BLINK: 5,
|
||||||
|
RAPID_BLINK: 6,
|
||||||
|
INVERSE: 7,
|
||||||
|
INVISIBLE: 8,
|
||||||
|
STRIKETHROUGH: 9,
|
||||||
|
NORMAL_INTENSITY: 22,
|
||||||
|
NO_ITALIC: 23,
|
||||||
|
NO_UNDERLINE: 24,
|
||||||
|
NO_BLINK: 25,
|
||||||
|
NO_INVERSE: 27,
|
||||||
|
VISIBLE: 28,
|
||||||
|
NO_STRIKETHROUGH: 29,
|
||||||
|
FG_DEFAULT: 39,
|
||||||
|
BG_DEFAULT: 49,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SerializeOptions {
|
||||||
|
/**
|
||||||
|
* The row range to serialize. When an explicit range is specified, the cursor
|
||||||
|
* will get its final repositioning.
|
||||||
|
*/
|
||||||
|
range?: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of rows in the scrollback buffer to serialize, starting from
|
||||||
|
* the bottom of the scrollback buffer. When not specified, all available
|
||||||
|
* rows in the scrollback buffer will be serialized.
|
||||||
|
*/
|
||||||
|
scrollback?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to exclude the terminal modes from the serialization.
|
||||||
|
* Default: false
|
||||||
|
*/
|
||||||
|
excludeModes?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to exclude the alt buffer from the serialization.
|
||||||
|
* Default: false
|
||||||
|
*/
|
||||||
|
excludeAltBuffer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextSerializeOptions {
|
||||||
|
/**
|
||||||
|
* The number of rows in the scrollback buffer to serialize, starting from
|
||||||
|
* the bottom of the scrollback buffer.
|
||||||
|
*/
|
||||||
|
scrollback?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to trim trailing whitespace from lines.
|
||||||
|
* Default: true
|
||||||
|
*/
|
||||||
|
trimWhitespace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CellState {
|
||||||
|
fg: number | null;
|
||||||
|
bg: number | null;
|
||||||
|
bold: boolean;
|
||||||
|
dim: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
underline: boolean;
|
||||||
|
blink: boolean;
|
||||||
|
inverse: boolean;
|
||||||
|
invisible: boolean;
|
||||||
|
strikethrough: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NULL_CELL_STATE: CellState = {
|
||||||
|
fg: null,
|
||||||
|
bg: null,
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
blink: false,
|
||||||
|
inverse: false,
|
||||||
|
invisible: false,
|
||||||
|
strikethrough: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SerializeAddon for ghostty-web terminal
|
||||||
|
*/
|
||||||
|
export class SerializeAddon {
|
||||||
|
private _terminal: GhosttyTerminal | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate the addon
|
||||||
|
*/
|
||||||
|
activate(terminal: GhosttyTerminal): void {
|
||||||
|
this._terminal = terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose the addon
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
this._terminal = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the terminal buffer to ANSI escape sequences
|
||||||
|
*/
|
||||||
|
serialize(options: SerializeOptions = {}): string {
|
||||||
|
if (!this._terminal) {
|
||||||
|
throw new Error('SerializeAddon not activated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this._terminal.buffer.active;
|
||||||
|
if (!buffer) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
let currentState: CellState = { ...NULL_CELL_STATE };
|
||||||
|
|
||||||
|
// Determine range to serialize
|
||||||
|
const scrollbackLimit = options.scrollback ?? buffer.length;
|
||||||
|
|
||||||
|
let startRow: number;
|
||||||
|
let endRow: number;
|
||||||
|
|
||||||
|
if (options.range) {
|
||||||
|
startRow = options.range.start;
|
||||||
|
endRow = options.range.end;
|
||||||
|
} else {
|
||||||
|
// Serialize scrollback + viewport
|
||||||
|
const totalRows = buffer.length;
|
||||||
|
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
|
||||||
|
startRow = Math.max(0, buffer.baseY - scrollbackRows);
|
||||||
|
endRow = buffer.baseY + buffer.cursorY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
startRow = Math.max(0, startRow);
|
||||||
|
endRow = Math.min(buffer.length - 1, endRow);
|
||||||
|
|
||||||
|
for (let y = startRow; y <= endRow; y++) {
|
||||||
|
const line = buffer.getLine(y);
|
||||||
|
if (!line) {
|
||||||
|
result.push('\r\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lineContent = '';
|
||||||
|
let lastNonSpaceCol = -1;
|
||||||
|
|
||||||
|
// Find the last non-space column
|
||||||
|
for (let x = line.length - 1; x >= 0; x--) {
|
||||||
|
const cell = line.getCell(x);
|
||||||
|
if (cell) {
|
||||||
|
const char = this._getCellChar(cell);
|
||||||
|
if (char !== ' ' && char !== '') {
|
||||||
|
lastNonSpaceCol = x;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize each cell up to the last non-space
|
||||||
|
for (let x = 0; x <= lastNonSpaceCol; x++) {
|
||||||
|
const cell = line.getCell(x);
|
||||||
|
if (!cell) {
|
||||||
|
lineContent += ' ';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cell attributes and generate SGR sequences if needed
|
||||||
|
const newState = this._getCellState(cell);
|
||||||
|
const sgrSequences = this._generateSgrDiff(currentState, newState);
|
||||||
|
if (sgrSequences) {
|
||||||
|
lineContent += sgrSequences;
|
||||||
|
currentState = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get character
|
||||||
|
const char = this._getCellChar(cell);
|
||||||
|
lineContent += char || ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset attributes at end of line if any were set
|
||||||
|
if (this._hasAttributes(currentState)) {
|
||||||
|
lineContent += `${C0.ESC}[${SGR.RESET}m`;
|
||||||
|
currentState = { ...NULL_CELL_STATE };
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(lineContent);
|
||||||
|
|
||||||
|
// Add newline unless it's the last row with cursor
|
||||||
|
if (y < endRow) {
|
||||||
|
result.push('\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position cursor
|
||||||
|
const cursorY = buffer.cursorY;
|
||||||
|
const cursorX = buffer.cursorX;
|
||||||
|
if (cursorY >= 0 && cursorX >= 0) {
|
||||||
|
// Use CUP (Cursor Position) to move cursor to correct position
|
||||||
|
// CUP is 1-based, so add 1 to both coordinates
|
||||||
|
const relativeY = cursorY - (endRow - buffer.baseY);
|
||||||
|
if (relativeY !== 0 || cursorX !== 0) {
|
||||||
|
result.push(`${C0.ESC}[${cursorY + 1};${cursorX + 1}H`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the terminal buffer to plain text (no escape sequences)
|
||||||
|
*/
|
||||||
|
serializeAsText(options: TextSerializeOptions = {}): string {
|
||||||
|
if (!this._terminal) {
|
||||||
|
throw new Error('SerializeAddon not activated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this._terminal.buffer.active;
|
||||||
|
if (!buffer) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimWhitespace = options.trimWhitespace ?? true;
|
||||||
|
const scrollbackLimit = options.scrollback ?? buffer.length;
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
// Determine range
|
||||||
|
const totalRows = buffer.length;
|
||||||
|
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
|
||||||
|
const startRow = Math.max(0, buffer.baseY - scrollbackRows);
|
||||||
|
const endRow = buffer.baseY + buffer.cursorY;
|
||||||
|
|
||||||
|
for (let y = startRow; y <= endRow; y++) {
|
||||||
|
const line = buffer.getLine(y);
|
||||||
|
if (!line) {
|
||||||
|
result.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lineContent = '';
|
||||||
|
for (let x = 0; x < line.length; x++) {
|
||||||
|
const cell = line.getCell(x);
|
||||||
|
if (cell) {
|
||||||
|
const char = this._getCellChar(cell);
|
||||||
|
lineContent += char || ' ';
|
||||||
|
} else {
|
||||||
|
lineContent += ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimWhitespace) {
|
||||||
|
lineContent = lineContent.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(lineContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the character from a cell, handling wide characters and special codepoints
|
||||||
|
*/
|
||||||
|
private _getCellChar(cell: { getChars?: () => string; getCodepoint?: () => number }): string {
|
||||||
|
// Try getChars() first (ghostty-web standard)
|
||||||
|
if (typeof cell.getChars === 'function') {
|
||||||
|
const chars = cell.getChars();
|
||||||
|
if (chars) return chars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try getCodepoint()
|
||||||
|
if (typeof cell.getCodepoint === 'function') {
|
||||||
|
const codepoint = cell.getCodepoint();
|
||||||
|
if (codepoint && codepoint > 0 && codepoint <= 0x10FFFF &&
|
||||||
|
!(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
|
||||||
|
return String.fromCodePoint(codepoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the state of a cell (colors and attributes)
|
||||||
|
*/
|
||||||
|
private _getCellState(cell: {
|
||||||
|
getFgColor?: () => number;
|
||||||
|
getBgColor?: () => number;
|
||||||
|
isBold?: () => boolean | number;
|
||||||
|
isDim?: () => boolean | number;
|
||||||
|
isFaint?: () => boolean | number;
|
||||||
|
isItalic?: () => boolean | number;
|
||||||
|
isUnderline?: () => boolean | number;
|
||||||
|
isBlink?: () => boolean | number;
|
||||||
|
isInverse?: () => boolean | number;
|
||||||
|
isInvisible?: () => boolean | number;
|
||||||
|
isStrikethrough?: () => boolean | number;
|
||||||
|
}): CellState {
|
||||||
|
const state: CellState = { ...NULL_CELL_STATE };
|
||||||
|
|
||||||
|
// Get foreground color
|
||||||
|
if (typeof cell.getFgColor === 'function') {
|
||||||
|
const fg = cell.getFgColor();
|
||||||
|
if (fg !== undefined && fg !== null && fg !== -1) {
|
||||||
|
state.fg = fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get background color
|
||||||
|
if (typeof cell.getBgColor === 'function') {
|
||||||
|
const bg = cell.getBgColor();
|
||||||
|
if (bg !== undefined && bg !== null && bg !== -1) {
|
||||||
|
state.bg = bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attributes
|
||||||
|
if (typeof cell.isBold === 'function') {
|
||||||
|
state.bold = !!cell.isBold();
|
||||||
|
}
|
||||||
|
if (typeof cell.isDim === 'function') {
|
||||||
|
state.dim = !!cell.isDim();
|
||||||
|
} else if (typeof cell.isFaint === 'function') {
|
||||||
|
state.dim = !!cell.isFaint();
|
||||||
|
}
|
||||||
|
if (typeof cell.isItalic === 'function') {
|
||||||
|
state.italic = !!cell.isItalic();
|
||||||
|
}
|
||||||
|
if (typeof cell.isUnderline === 'function') {
|
||||||
|
state.underline = !!cell.isUnderline();
|
||||||
|
}
|
||||||
|
if (typeof cell.isBlink === 'function') {
|
||||||
|
state.blink = !!cell.isBlink();
|
||||||
|
}
|
||||||
|
if (typeof cell.isInverse === 'function') {
|
||||||
|
state.inverse = !!cell.isInverse();
|
||||||
|
}
|
||||||
|
if (typeof cell.isInvisible === 'function') {
|
||||||
|
state.invisible = !!cell.isInvisible();
|
||||||
|
}
|
||||||
|
if (typeof cell.isStrikethrough === 'function') {
|
||||||
|
state.strikethrough = !!cell.isStrikethrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SGR escape sequences for the difference between two cell states
|
||||||
|
*/
|
||||||
|
private _generateSgrDiff(from: CellState, to: CellState): string | null {
|
||||||
|
const codes: number[] = [];
|
||||||
|
|
||||||
|
// Check if we need a full reset
|
||||||
|
const needsReset =
|
||||||
|
(from.bold && !to.bold) ||
|
||||||
|
(from.dim && !to.dim) ||
|
||||||
|
(from.italic && !to.italic) ||
|
||||||
|
(from.underline && !to.underline) ||
|
||||||
|
(from.blink && !to.blink) ||
|
||||||
|
(from.inverse && !to.inverse) ||
|
||||||
|
(from.invisible && !to.invisible) ||
|
||||||
|
(from.strikethrough && !to.strikethrough);
|
||||||
|
|
||||||
|
if (needsReset) {
|
||||||
|
codes.push(SGR.RESET);
|
||||||
|
// After reset, we need to re-apply all 'to' attributes
|
||||||
|
if (to.bold) codes.push(SGR.BOLD);
|
||||||
|
if (to.dim) codes.push(SGR.DIM);
|
||||||
|
if (to.italic) codes.push(SGR.ITALIC);
|
||||||
|
if (to.underline) codes.push(SGR.UNDERLINE);
|
||||||
|
if (to.blink) codes.push(SGR.SLOW_BLINK);
|
||||||
|
if (to.inverse) codes.push(SGR.INVERSE);
|
||||||
|
if (to.invisible) codes.push(SGR.INVISIBLE);
|
||||||
|
if (to.strikethrough) codes.push(SGR.STRIKETHROUGH);
|
||||||
|
|
||||||
|
// Re-apply colors
|
||||||
|
if (to.fg !== null) {
|
||||||
|
this._appendColorCode(codes, to.fg, true);
|
||||||
|
}
|
||||||
|
if (to.bg !== null) {
|
||||||
|
this._appendColorCode(codes, to.bg, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Apply only changed attributes
|
||||||
|
if (!from.bold && to.bold) codes.push(SGR.BOLD);
|
||||||
|
if (!from.dim && to.dim) codes.push(SGR.DIM);
|
||||||
|
if (!from.italic && to.italic) codes.push(SGR.ITALIC);
|
||||||
|
if (!from.underline && to.underline) codes.push(SGR.UNDERLINE);
|
||||||
|
if (!from.blink && to.blink) codes.push(SGR.SLOW_BLINK);
|
||||||
|
if (!from.inverse && to.inverse) codes.push(SGR.INVERSE);
|
||||||
|
if (!from.invisible && to.invisible) codes.push(SGR.INVISIBLE);
|
||||||
|
if (!from.strikethrough && to.strikethrough) codes.push(SGR.STRIKETHROUGH);
|
||||||
|
|
||||||
|
// Handle color changes
|
||||||
|
if (from.fg !== to.fg) {
|
||||||
|
if (to.fg === null) {
|
||||||
|
codes.push(SGR.FG_DEFAULT);
|
||||||
|
} else {
|
||||||
|
this._appendColorCode(codes, to.fg, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (from.bg !== to.bg) {
|
||||||
|
if (to.bg === null) {
|
||||||
|
codes.push(SGR.BG_DEFAULT);
|
||||||
|
} else {
|
||||||
|
this._appendColorCode(codes, to.bg, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${C0.ESC}[${codes.join(';')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append color code to the codes array
|
||||||
|
*/
|
||||||
|
private _appendColorCode(codes: number[], color: number, isForeground: boolean): void {
|
||||||
|
const base = isForeground ? 30 : 40;
|
||||||
|
const extBase = isForeground ? 38 : 48;
|
||||||
|
|
||||||
|
if (color < 8) {
|
||||||
|
// Basic 8 colors
|
||||||
|
codes.push(base + color);
|
||||||
|
} else if (color < 16) {
|
||||||
|
// Bright 8 colors
|
||||||
|
codes.push(base + 60 + (color - 8));
|
||||||
|
} else if (color < 256) {
|
||||||
|
// 256-color palette
|
||||||
|
codes.push(extBase, 5, color);
|
||||||
|
} else {
|
||||||
|
// RGB (24-bit) color encoded as 0xRRGGBB + 0x1000000
|
||||||
|
const rgb = color - 0x1000000;
|
||||||
|
const r = (rgb >> 16) & 0xFF;
|
||||||
|
const g = (rgb >> 8) & 0xFF;
|
||||||
|
const b = rgb & 0xFF;
|
||||||
|
codes.push(extBase, 2, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the state has any attributes set
|
||||||
|
*/
|
||||||
|
private _hasAttributes(state: CellState): boolean {
|
||||||
|
return (
|
||||||
|
state.fg !== null ||
|
||||||
|
state.bg !== null ||
|
||||||
|
state.bold ||
|
||||||
|
state.dim ||
|
||||||
|
state.italic ||
|
||||||
|
state.underline ||
|
||||||
|
state.blink ||
|
||||||
|
state.inverse ||
|
||||||
|
state.invisible ||
|
||||||
|
state.strikethrough
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Ghostty } from 'ghostty-web';
|
||||||
import type { Theme } from '@/types/theme';
|
import type { Theme } from '@/types/theme';
|
||||||
|
|
||||||
export interface TerminalTheme {
|
export interface TerminalTheme {
|
||||||
@@ -79,7 +80,7 @@ export function getTerminalOptions(
|
|||||||
cursorStyle: 'block' as const,
|
cursorStyle: 'block' as const,
|
||||||
theme,
|
theme,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
scrollback: 10000,
|
scrollback: 50_000,
|
||||||
minimumContrastRatio: 1,
|
minimumContrastRatio: 1,
|
||||||
fastScrollModifier: 'shift' as const,
|
fastScrollModifier: 'shift' as const,
|
||||||
fastScrollSensitivity: 5,
|
fastScrollSensitivity: 5,
|
||||||
@@ -89,3 +90,51 @@ export function getTerminalOptions(
|
|||||||
rightClickSelectsWord: true,
|
rightClickSelectsWord: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get terminal options for Ghostty Web terminal
|
||||||
|
*/
|
||||||
|
export function getGhosttyTerminalOptions(
|
||||||
|
fontFamily: string,
|
||||||
|
fontSize: number,
|
||||||
|
theme: TerminalTheme,
|
||||||
|
ghostty: Ghostty
|
||||||
|
) {
|
||||||
|
const powerlineFallbacks =
|
||||||
|
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||||
|
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
fontFamily: augmentedFontFamily,
|
||||||
|
allowTransparency: false,
|
||||||
|
theme: {
|
||||||
|
background: theme.background,
|
||||||
|
foreground: theme.foreground,
|
||||||
|
cursor: theme.cursor,
|
||||||
|
cursorAccent: theme.cursorAccent,
|
||||||
|
selectionBackground: theme.selectionBackground,
|
||||||
|
selectionForeground: theme.selectionForeground,
|
||||||
|
black: theme.black,
|
||||||
|
red: theme.red,
|
||||||
|
green: theme.green,
|
||||||
|
yellow: theme.yellow,
|
||||||
|
blue: theme.blue,
|
||||||
|
magenta: theme.magenta,
|
||||||
|
cyan: theme.cyan,
|
||||||
|
white: theme.white,
|
||||||
|
brightBlack: theme.brightBlack,
|
||||||
|
brightRed: theme.brightRed,
|
||||||
|
brightGreen: theme.brightGreen,
|
||||||
|
brightYellow: theme.brightYellow,
|
||||||
|
brightBlue: theme.brightBlue,
|
||||||
|
brightMagenta: theme.brightMagenta,
|
||||||
|
brightCyan: theme.brightCyan,
|
||||||
|
brightWhite: theme.brightWhite,
|
||||||
|
},
|
||||||
|
scrollback: 50_000,
|
||||||
|
ghostty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface TerminalStore {
|
|||||||
clearAllTerminalSessions: () => void;
|
clearAllTerminalSessions: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL_BUFFER_LIMIT = 256_000;
|
const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||||
|
|
||||||
function normalizeDirectory(dir: string): string {
|
function normalizeDirectory(dir: string): string {
|
||||||
let normalized = dir.trim();
|
let normalized = dir.trim();
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare module 'ghostty-web' {
|
||||||
|
export interface ITerminalOptions {
|
||||||
|
lineHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RendererOptions {
|
||||||
|
lineHeight?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ export default defineConfig({
|
|||||||
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
define: {
|
define: {
|
||||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
'global': 'globalThis',
|
'global': 'globalThis',
|
||||||
|
|||||||
+55
-2
@@ -11,6 +11,35 @@ const __dirname = path.dirname(__filename);
|
|||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3000;
|
||||||
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
||||||
|
|
||||||
|
function getBunBinary() {
|
||||||
|
if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) {
|
||||||
|
return process.env.BUN_BINARY.trim();
|
||||||
|
}
|
||||||
|
if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) {
|
||||||
|
return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun');
|
||||||
|
}
|
||||||
|
return 'bun';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUN_BIN = getBunBinary();
|
||||||
|
|
||||||
|
function isBunRuntime() {
|
||||||
|
return typeof globalThis.Bun !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBunInstalled() {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env });
|
||||||
|
return result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredServerRuntime() {
|
||||||
|
return isBunInstalled() ? 'bun' : 'node';
|
||||||
|
}
|
||||||
|
|
||||||
function generateRandomPassword(length = 16) {
|
function generateRandomPassword(length = 16) {
|
||||||
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||||
let password = '';
|
let password = '';
|
||||||
@@ -384,9 +413,12 @@ const commands = {
|
|||||||
serverArgs.push('--try-cf-tunnel');
|
serverArgs.push('--try-cf-tunnel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preferredRuntime = getPreferredServerRuntime();
|
||||||
|
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
|
||||||
|
|
||||||
if (options.daemon) {
|
if (options.daemon) {
|
||||||
|
|
||||||
const child = spawn(process.execPath, serverArgs, {
|
const child = spawn(runtimeBin, serverArgs, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: {
|
env: {
|
||||||
@@ -430,8 +462,29 @@ const commands = {
|
|||||||
|
|
||||||
writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword });
|
writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword });
|
||||||
|
|
||||||
|
// Prefer bun when installed (much faster PTY). If CLI is running under Node,
|
||||||
|
// run the server in a child process so Node doesn't have to load bun-pty.
|
||||||
|
if (preferredRuntime === 'bun' && !isBunRuntime()) {
|
||||||
|
const child = spawn(runtimeBin, serverArgs, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
OPENCHAMBER_PORT: options.port.toString(),
|
||||||
|
OPENCODE_BINARY: opencodeBinary,
|
||||||
|
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
|
||||||
|
OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
process.exit(typeof code === 'number' ? code : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { startWebUiServer } = await import(serverPath);
|
const { startWebUiServer } = await import(serverPath);
|
||||||
const server = await startWebUiServer({
|
await startWebUiServer({
|
||||||
port: options.port,
|
port: options.port,
|
||||||
attachSignals: true,
|
attachSignals: true,
|
||||||
exitOnShutdown: true,
|
exitOnShutdown: true,
|
||||||
|
|||||||
@@ -37,15 +37,15 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"ghostty-web": "0.3.0",
|
||||||
"@xterm/xterm": "^5.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.0.0",
|
"bun-pty": "^0.4.5",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -3692,23 +3692,39 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ptyLib = null;
|
let ptyProviderPromise = null;
|
||||||
let ptyLoadError = null;
|
const getPtyProvider = async () => {
|
||||||
const getPtyLib = async () => {
|
if (ptyProviderPromise) {
|
||||||
if (ptyLib) return ptyLib;
|
return ptyProviderPromise;
|
||||||
if (ptyLoadError) throw ptyLoadError;
|
|
||||||
|
|
||||||
try {
|
|
||||||
ptyLib = await import('node-pty');
|
|
||||||
console.log('node-pty loaded successfully');
|
|
||||||
return ptyLib;
|
|
||||||
} catch (error) {
|
|
||||||
ptyLoadError = error;
|
|
||||||
console.error('Failed to load node-pty:', error.message);
|
|
||||||
console.error('Terminal functionality will not be available.');
|
|
||||||
console.error('To fix: run "npm rebuild node-pty" or "npm install"');
|
|
||||||
throw new Error('node-pty is not available. Run: npm rebuild node-pty');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ptyProviderPromise = (async () => {
|
||||||
|
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
|
||||||
|
|
||||||
|
if (isBunRuntime) {
|
||||||
|
try {
|
||||||
|
const bunPty = await import('bun-pty');
|
||||||
|
console.log('Using bun-pty for terminal sessions');
|
||||||
|
return { spawn: bunPty.spawn, backend: 'bun-pty' };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('bun-pty unavailable, falling back to node-pty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nodePty = await import('node-pty');
|
||||||
|
console.log('Using node-pty for terminal sessions');
|
||||||
|
return { spawn: nodePty.spawn, backend: 'node-pty' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
|
||||||
|
if (isBunRuntime) {
|
||||||
|
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
|
||||||
|
}
|
||||||
|
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return ptyProviderPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const terminalSessions = new Map();
|
const terminalSessions = new Map();
|
||||||
@@ -3745,7 +3761,6 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'Invalid working directory' });
|
return res.status(400).json({ error: 'Invalid working directory' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pty = await getPtyLib();
|
|
||||||
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
||||||
|
|
||||||
const sessionId = Math.random().toString(36).substring(2, 15) +
|
const sessionId = Math.random().toString(36).substring(2, 15) +
|
||||||
@@ -3754,6 +3769,7 @@ async function main(options = {}) {
|
|||||||
const envPath = buildAugmentedPath();
|
const envPath = buildAugmentedPath();
|
||||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
const resolvedEnv = { ...process.env, PATH: envPath };
|
||||||
|
|
||||||
|
const pty = await getPtyProvider();
|
||||||
const ptyProcess = pty.spawn(shell, [], {
|
const ptyProcess = pty.spawn(shell, [], {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: cols || 80,
|
cols: cols || 80,
|
||||||
@@ -3768,6 +3784,7 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
ptyProcess,
|
ptyProcess,
|
||||||
|
ptyBackend: pty.backend,
|
||||||
cwd,
|
cwd,
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
clients: new Set(),
|
clients: new Set(),
|
||||||
@@ -3801,12 +3818,14 @@ async function main(options = {}) {
|
|||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
res.write('data: {"type":"connected"}\n\n');
|
|
||||||
|
|
||||||
const clientId = Math.random().toString(36).substring(7);
|
const clientId = Math.random().toString(36).substring(7);
|
||||||
session.clients.add(clientId);
|
session.clients.add(clientId);
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
|
|
||||||
|
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
||||||
|
const ptyBackend = session.ptyBackend || 'unknown';
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
|
||||||
|
|
||||||
const heartbeatInterval = setInterval(() => {
|
const heartbeatInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -3871,7 +3890,7 @@ async function main(options = {}) {
|
|||||||
req.on('close', cleanup);
|
req.on('close', cleanup);
|
||||||
req.on('error', cleanup);
|
req.on('error', cleanup);
|
||||||
|
|
||||||
console.log(`Client ${clientId} connected to terminal session ${sessionId}`);
|
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
||||||
@@ -3958,7 +3977,6 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'Invalid working directory' });
|
return res.status(400).json({ error: 'Invalid working directory' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pty = await getPtyLib();
|
|
||||||
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
||||||
|
|
||||||
const newSessionId = Math.random().toString(36).substring(2, 15) +
|
const newSessionId = Math.random().toString(36).substring(2, 15) +
|
||||||
@@ -3967,6 +3985,7 @@ async function main(options = {}) {
|
|||||||
const envPath = buildAugmentedPath();
|
const envPath = buildAugmentedPath();
|
||||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
const resolvedEnv = { ...process.env, PATH: envPath };
|
||||||
|
|
||||||
|
const pty = await getPtyProvider();
|
||||||
const ptyProcess = pty.spawn(shell, [], {
|
const ptyProcess = pty.spawn(shell, [], {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: cols || 80,
|
cols: cols || 80,
|
||||||
@@ -3981,6 +4000,7 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
ptyProcess,
|
ptyProcess,
|
||||||
|
ptyBackend: pty.backend,
|
||||||
cwd,
|
cwd,
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
clients: new Set(),
|
clients: new Set(),
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default defineConfig({
|
|||||||
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
define: {
|
define: {
|
||||||
'process.env': {},
|
'process.env': {},
|
||||||
global: 'globalThis',
|
global: 'globalThis',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
diff --git a/node_modules/ghostty-web/dist/ghostty-web.js b/node_modules/ghostty-web/dist/ghostty-web.js
|
||||||
|
index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644
|
||||||
|
--- a/node_modules/ghostty-web/dist/ghostty-web.js
|
||||||
|
+++ b/node_modules/ghostty-web/dist/ghostty-web.js
|
||||||
|
@@ -1285,1 +1285,1 @@
|
||||||
|
- C === 0 || C < 32 ? B.push(" ") : B.push(String.fromCodePoint(C));
|
||||||
|
+ C === 0 || C < 32 || C > 1114111 || (C >= 55296 && C <= 57343) ? B.push(" ") : B.push(String.fromCodePoint(C));
|
||||||
|
@@ -1328,1 +1328,1 @@
|
||||||
|
- this.ctx = Q, this.fontSize = B.fontSize ?? 15, this.fontFamily = B.fontFamily ?? "monospace", this.cursorStyle = B.cursorStyle ?? "block", this.cursorBlink = B.cursorBlink ?? !1, this.theme = { ...r, ...B.theme }, this.devicePixelRatio = B.devicePixelRatio ?? window.devicePixelRatio ?? 1, this.palette = [
|
||||||
|
+ this.ctx = Q, this.fontSize = B.fontSize ?? 15, this.fontFamily = B.fontFamily ?? "monospace", this.lineHeight = B.lineHeight ?? 1, this.cursorStyle = B.cursorStyle ?? "block", this.cursorBlink = B.cursorBlink ?? !1, this.theme = { ...r, ...B.theme }, this.devicePixelRatio = B.devicePixelRatio ?? window.devicePixelRatio ?? 1, this.palette = [
|
||||||
|
@@ -1353,1 +1353,1 @@
|
||||||
|
- const Q = B.measureText("M"), g = Math.ceil(Q.width), C = Q.actualBoundingBoxAscent || this.fontSize * 0.8, I = Q.actualBoundingBoxDescent || this.fontSize * 0.2, w = Math.ceil(C + I) + 2, o = Math.ceil(C) + 1;
|
||||||
|
+ const Q = B.measureText("M"), g = Math.ceil(Q.width), C = Q.actualBoundingBoxAscent || this.fontSize * 0.8, I = Q.actualBoundingBoxDescent || this.fontSize * 0.2, w0 = Math.ceil(C + I), extra = Math.max(0, Math.round((this.lineHeight - 1) * w0)), w = w0 + 2 + extra, o = Math.ceil(C) + 1;
|
||||||
|
@@ -1484,1 +1484,1 @@
|
||||||
|
- const s = g, F = C + this.metrics.baseline, a = String.fromCodePoint(A.codepoint || 32);
|
||||||
|
+ const s = g, F = C + this.metrics.baseline, a = (A.codepoint === 0 || A.codepoint == null || A.codepoint < 0 || A.codepoint > 1114111 || (A.codepoint >= 55296 && A.codepoint <= 57343)) ? " " : String.fromCodePoint(A.codepoint);
|
||||||
|
@@ -1730,1 +1730,1 @@
|
||||||
|
- if (s && s.codepoint !== 0) {
|
||||||
|
+ if (s && s.codepoint !== 0 && s.codepoint <= 1114111 && !(s.codepoint >= 55296 && s.codepoint <= 57343)) {
|
||||||
|
@@ -1995,1 +1995,1 @@
|
||||||
|
- if (!w || w.codepoint === 0)
|
||||||
|
+ if (!w || w.codepoint === 0 || w.codepoint > 1114111 || (w.codepoint >= 55296 && w.codepoint <= 57343))
|
||||||
|
@@ -2147,4 +2147,5 @@
|
||||||
|
scrollback: A.scrollback ?? 1e3,
|
||||||
|
fontSize: A.fontSize ?? 15,
|
||||||
|
fontFamily: A.fontFamily ?? "monospace",
|
||||||
|
+ lineHeight: A.lineHeight ?? 1,
|
||||||
|
allowTransparency: A.allowTransparency ?? !1,
|
||||||
|
@@ -2267,3 +2267,4 @@
|
||||||
|
fontSize: this.options.fontSize,
|
||||||
|
fontFamily: this.options.fontFamily,
|
||||||
|
+ lineHeight: this.options.lineHeight,
|
||||||
|
cursorStyle: this.options.cursorStyle,
|
||||||
@@ -17,6 +17,9 @@ export default defineConfig({
|
|||||||
'@opencode-ai/sdk': path.resolve(__dirname, './node_modules/@opencode-ai/sdk/dist/client.js'),
|
'@opencode-ai/sdk': path.resolve(__dirname, './node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
define: {
|
define: {
|
||||||
'process.env': {},
|
'process.env': {},
|
||||||
global: 'globalThis',
|
global: 'globalThis',
|
||||||
|
|||||||
Reference in New Issue
Block a user