Compare commits
71 Commits
jderehag/d
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359e89971f | ||
|
|
7f833747b0 | ||
|
|
ab3f228d85 | ||
|
|
3382736f05 | ||
|
|
fd5941fb36 | ||
|
|
9b76521a90 | ||
|
|
ea92c0609d | ||
|
|
612e50808a | ||
|
|
2c24402742 | ||
|
|
d7c4bf1e45 | ||
|
|
5bfb09c73b | ||
|
|
fd499d95e6 | ||
|
|
204b2e020b | ||
|
|
d34e0163e3 | ||
|
|
a93252621a | ||
|
|
8ce7a9b4ee | ||
|
|
63ffb86ea7 | ||
|
|
bd9a8d9788 | ||
|
|
d291c2f074 | ||
|
|
16c2eeca3e | ||
|
|
d9d281af8c | ||
|
|
56a6364f99 | ||
|
|
ba20dd6f2f | ||
|
|
0d96a9f9ff | ||
|
|
ee9da95044 | ||
|
|
0511d92cbf | ||
|
|
e666ac333c | ||
|
|
8495dcd021 | ||
|
|
01ab2f2794 | ||
|
|
b59e85abda | ||
|
|
4eded9e204 | ||
|
|
90164aa507 | ||
|
|
f87c83cadd | ||
|
|
01300a81de | ||
|
|
d143faf8eb | ||
|
|
8c29741830 | ||
|
|
d360089b80 | ||
|
|
4279b25ff4 | ||
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 | ||
|
|
dcbe3475ed | ||
|
|
338a88fb5a | ||
|
|
7eb1551e4b | ||
|
|
0414f924e6 | ||
|
|
9456871271 | ||
|
|
5b4edef785 | ||
|
|
6b81d0d703 | ||
|
|
4097637169 | ||
|
|
9bd66e7297 | ||
|
|
883b0724e0 | ||
|
|
e0bb867948 | ||
|
|
ca28f503b7 | ||
|
|
c83028abc2 | ||
|
|
60406ca8fb | ||
|
|
e878c3c83b | ||
|
|
bdd3fe8899 | ||
|
|
3cfaf689e7 | ||
|
|
b41da03e8a | ||
|
|
ef14b9acb6 | ||
|
|
6f73adaef6 | ||
|
|
e2ff758003 | ||
|
|
748a99c9c4 | ||
|
|
db2d764cce |
29
AGENTS.md
29
AGENTS.md
@@ -15,6 +15,35 @@
|
|||||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||||
|
|
||||||
|
## Multi-Language Support (i18n)
|
||||||
|
|
||||||
|
The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings.
|
||||||
|
|
||||||
|
- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code.
|
||||||
|
- Implementation: `packages/ui/src/lib/i18n/index.tsx`
|
||||||
|
- **Where messages live:** `packages/ui/src/lib/i18n/messages/<locale>/` as TypeScript objects (`"flat.dot.keys": "string"`).
|
||||||
|
- Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time.
|
||||||
|
- Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts`
|
||||||
|
- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file.
|
||||||
|
- Missing translations fall back to English (and finally to the key), so gaps can be easy to miss.
|
||||||
|
- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`.
|
||||||
|
- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code.
|
||||||
|
- **Adding a new language:** add a new `messages/<locale>/` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`.
|
||||||
|
- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`).
|
||||||
|
- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply.
|
||||||
|
|
||||||
|
## File Length Guidelines (Highlight Only)
|
||||||
|
|
||||||
|
We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits.
|
||||||
|
|
||||||
|
- Source files: warn after ~500 lines; target limit ~800 lines
|
||||||
|
- Test files: highlight after ~1000 lines
|
||||||
|
|
||||||
|
Behavior for agents:
|
||||||
|
- Do not refactor solely to satisfy these thresholds.
|
||||||
|
- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count.
|
||||||
|
- When creating new files, aim to stay under the thresholds unless there's a clear reason.
|
||||||
|
|
||||||
## Tooling Preferences
|
## Tooling Preferences
|
||||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||||
- Use the `write` tool only when creating new files from scratch.
|
- Use the `write` tool only when creating new files from scratch.
|
||||||
|
|||||||
61
package-lock.json
generated
61
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -3305,6 +3305,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.3",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
@@ -3453,6 +3462,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-forge": {
|
||||||
|
"version": "1.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||||
|
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/plist": {
|
"node_modules/@types/plist": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7989,6 +8008,12 @@
|
|||||||
"obliterator": "^2.0.1"
|
"obliterator": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/monaco-editor": {
|
||||||
|
"version": "0.52.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||||
|
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -8059,6 +8084,15 @@
|
|||||||
"url": "https://opencollective.com/node-fetch"
|
"url": "https://opencollective.com/node-fetch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||||
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -10184,6 +10218,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/tauri-plugin-keepawake-api": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -11928,7 +11970,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -11963,7 +12005,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -11972,6 +12014,7 @@
|
|||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
@@ -11981,6 +12024,7 @@
|
|||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/yauzl": "^2.10.0",
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
@@ -12001,7 +12045,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12009,7 +12053,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
@@ -12019,16 +12063,19 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0",
|
||||||
|
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.9.2",
|
"minServerVersion": "0.10.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
|
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
|
||||||
|
|
||||||
const uiRoot = resolve(__dirname, "../ui")
|
const uiRoot = resolve(__dirname, "../ui")
|
||||||
const uiSrc = resolve(uiRoot, "src")
|
const uiSrc = resolve(uiRoot, "src")
|
||||||
@@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
|||||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||||
|
|
||||||
|
function prepareMonacoPublicAssets() {
|
||||||
|
return {
|
||||||
|
name: "prepare-monaco-public-assets",
|
||||||
|
configureServer(server: any) {
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: uiRendererRoot,
|
||||||
|
warn: (msg: string) => server.config.logger.warn(msg),
|
||||||
|
sourceRoots: [
|
||||||
|
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||||
|
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
buildStart(this: any) {
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: uiRendererRoot,
|
||||||
|
warn: (msg: string) => this.warn(msg),
|
||||||
|
sourceRoots: [
|
||||||
|
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||||
|
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
@@ -40,7 +67,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
root: uiRendererRoot,
|
root: uiRendererRoot,
|
||||||
plugins: [solid()],
|
plugins: [solid(), prepareMonacoPublicAssets()],
|
||||||
css: {
|
css: {
|
||||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
|
let wakeLockId: number | null = null
|
||||||
|
|
||||||
interface DialogOpenRequest {
|
interface DialogOpenRequest {
|
||||||
mode: "directory" | "file"
|
mode: "directory" | "file"
|
||||||
title?: string
|
title?: string
|
||||||
@@ -62,4 +64,50 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
|
|
||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
|
const next = Boolean(enabled)
|
||||||
|
if (next) {
|
||||||
|
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
|
||||||
|
return { enabled: true }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
|
||||||
|
} catch {
|
||||||
|
wakeLockId = null
|
||||||
|
return { enabled: false }
|
||||||
|
}
|
||||||
|
return { enabled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wakeLockId !== null) {
|
||||||
|
try {
|
||||||
|
if (powerSaveBlocker.isStarted(wakeLockId)) {
|
||||||
|
powerSaveBlocker.stop(wakeLockId)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wakeLockId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { enabled: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"notifications:show",
|
||||||
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
return { ok: false, reason: "unsupported" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
|
||||||
|
const body = typeof payload?.body === "string" ? payload.body : ""
|
||||||
|
try {
|
||||||
|
const notification = new Notification({ title, body })
|
||||||
|
notification.show()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,7 +399,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
|
|
||||||
async function startCli() {
|
async function startCli() {
|
||||||
try {
|
try {
|
||||||
const devMode = process.env.NODE_ENV === "development"
|
// In desktop dev workflows we always want the CLI to run in dev mode so it:
|
||||||
|
// - uses plain HTTP
|
||||||
|
// - proxies UI requests to the renderer dev server
|
||||||
|
// Monaco's AMD assets are served from that dev server.
|
||||||
|
const devMode = !app.isPackaged
|
||||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||||
await cliManager.start({ dev: devMode })
|
await cliManager.start({ dev: devMode })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -473,6 +477,14 @@ if (isMac) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// Required for Windows notifications / taskbar grouping.
|
||||||
|
// Keep in sync with desktop app identifier.
|
||||||
|
try {
|
||||||
|
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
startCli()
|
startCli()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
|
|||||||
@@ -347,39 +347,28 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
console.info(`[cli][${stream}] ${trimmed}`)
|
console.info(`[cli][${stream}] ${trimmed}`)
|
||||||
this.emit("log", { stream, message: trimmed })
|
this.emit("log", { stream, message: trimmed })
|
||||||
|
|
||||||
const port = this.extractPort(trimmed)
|
const localUrl = this.extractLocalUrl(trimmed)
|
||||||
if (port && this.status.state === "starting") {
|
if (localUrl && this.status.state === "starting") {
|
||||||
const url = `http://127.0.0.1:${port}`
|
let port: number | undefined
|
||||||
console.info(`[cli] ready on ${url}`)
|
try {
|
||||||
this.updateStatus({ state: "ready", port, url })
|
port = Number(new URL(localUrl).port) || undefined
|
||||||
|
} catch {
|
||||||
|
port = undefined
|
||||||
|
}
|
||||||
|
console.info(`[cli] ready on ${localUrl}`)
|
||||||
|
this.updateStatus({ state: "ready", port, url: localUrl })
|
||||||
this.emit("ready", this.status)
|
this.emit("ready", this.status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractPort(line: string): number | null {
|
private extractLocalUrl(line: string): string | null {
|
||||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
||||||
if (readyMatch) {
|
if (!match) {
|
||||||
return parseInt(readyMatch[1], 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.toLowerCase().includes("http server listening")) {
|
|
||||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
|
||||||
if (httpMatch) {
|
|
||||||
return parseInt(httpMatch[1], 10)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line)
|
|
||||||
if (typeof parsed.port === "number") {
|
|
||||||
return parsed.port
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
return match[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
private updateStatus(patch: Partial<CliStatus>) {
|
private updateStatus(patch: Partial<CliStatus>) {
|
||||||
this.status = { ...this.status, ...patch }
|
this.status = { ...this.status, ...patch }
|
||||||
@@ -387,10 +376,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
const args = ["serve", "--host", host, "--generate-token"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
|
args.push("--https", "false", "--http", "true")
|
||||||
|
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||||
|
// by forcing an ephemeral port in dev.
|
||||||
|
args.push("--http-port", "0")
|
||||||
|
} else {
|
||||||
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
|
args.push("--https", "true", "--http", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dev) {
|
||||||
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||||
|
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.42"
|
"@opencode-ai/plugin": "1.1.53"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import http from "http"
|
||||||
|
import https from "https"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
|
||||||
export type PluginEvent = {
|
export type PluginEvent = {
|
||||||
type: string
|
type: string
|
||||||
properties?: Record<string, unknown>
|
properties?: Record<string, unknown>
|
||||||
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||||
|
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||||
const authorization = buildInstanceAuthorizationHeader()
|
const authorization = buildInstanceAuthorizationHeader()
|
||||||
|
|
||||||
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
|||||||
const hasBody = init?.body !== undefined
|
const hasBody = init?.body !== undefined
|
||||||
const headers = buildHeaders(init?.headers, hasBody)
|
const headers = buildHeaders(init?.headers, hasBody)
|
||||||
|
|
||||||
return fetch(url, {
|
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||||
...init,
|
// Use a single request implementation that tolerates custom/self-signed certs
|
||||||
headers,
|
// without disabling TLS verification for the whole Node process.
|
||||||
})
|
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function nodeFetch(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit & { headers?: Record<string, string> },
|
||||||
|
tls: { rejectUnauthorized: boolean },
|
||||||
|
): Promise<Response> {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const isHttps = parsed.protocol === "https:"
|
||||||
|
const requestFn = isHttps ? https.request : http.request
|
||||||
|
|
||||||
|
const method = (init.method ?? "GET").toUpperCase()
|
||||||
|
const headers = init.headers ?? {}
|
||||||
|
const body = init.body
|
||||||
|
|
||||||
|
return await new Promise<Response>((resolve, reject) => {
|
||||||
|
const req = requestFn(
|
||||||
|
{
|
||||||
|
protocol: parsed.protocol,
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port ? Number(parsed.port) : undefined,
|
||||||
|
path: `${parsed.pathname}${parsed.search}`,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const responseHeaders = new Headers()
|
||||||
|
for (const [key, value] of Object.entries(res.headers)) {
|
||||||
|
if (value === undefined) continue
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
responseHeaders.set(key, value.join(", "))
|
||||||
|
} else {
|
||||||
|
responseHeaders.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Node stream -> Web ReadableStream for Response.
|
||||||
|
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||||
|
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const signal = init.signal
|
||||||
|
const abort = () => {
|
||||||
|
const err = new Error("Request aborted")
|
||||||
|
;(err as any).name = "AbortError"
|
||||||
|
req.destroy(err)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abort, { once: true })
|
||||||
|
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.once("error", reject)
|
||||||
|
|
||||||
|
if (body === undefined || body === null) {
|
||||||
|
req.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === "string") {
|
||||||
|
req.end(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof Uint8Array) {
|
||||||
|
req.end(Buffer.from(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
req.end(Buffer.from(new Uint8Array(body)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for less common BodyInit types.
|
||||||
|
req.end(String(body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
function requireEnv(key: string): string {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On startup, CodeNomad prints two URLs:
|
||||||
|
|
||||||
|
- `Local Connection URL : ...` (used by desktop shells)
|
||||||
|
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||||
|
|
||||||
### Install Globally
|
### Install Globally
|
||||||
Or install it globally to use the `codenomad` command:
|
Or install it globally to use the `codenomad` command:
|
||||||
|
|
||||||
@@ -44,7 +49,14 @@ You can configure the server using flags or environment variables:
|
|||||||
|
|
||||||
| Flag | Env Variable | Description |
|
| Flag | Env Variable | Description |
|
||||||
|------|--------------|-------------|
|
|------|--------------|-------------|
|
||||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
|
||||||
|
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
|
||||||
|
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
|
||||||
|
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
|
||||||
|
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
|
||||||
|
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
|
||||||
|
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||||
|
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||||
@@ -56,6 +68,42 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||||
|
|
||||||
|
### HTTP vs HTTPS
|
||||||
|
|
||||||
|
- Default: `--https=true --http=false` (HTTPS only).
|
||||||
|
- To run plain HTTP only (useful for development):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
codenomad --https=false --http=true
|
||||||
|
```
|
||||||
|
|
||||||
|
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
codenomad --https=true --http=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Access Binding Rules
|
||||||
|
|
||||||
|
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
|
||||||
|
- HTTP listens on `127.0.0.1` only.
|
||||||
|
- HTTPS listens on `--host` (LAN/all interfaces).
|
||||||
|
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
|
||||||
|
- Both HTTP and HTTPS listen on `127.0.0.1`.
|
||||||
|
|
||||||
|
### Self-Signed Certificates
|
||||||
|
|
||||||
|
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
|
||||||
|
|
||||||
|
- `~/.config/codenomad/tls/ca-cert.pem`
|
||||||
|
- `~/.config/codenomad/tls/server-cert.pem`
|
||||||
|
|
||||||
|
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||||
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||||
@@ -71,8 +119,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
|||||||
|
|
||||||
> **TLS requirement**
|
> **TLS requirement**
|
||||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||||
> If you host CodeNomad on a remote machine, serve it behind a reverse proxy (e.g. Caddy, nginx) with a valid TLS certificate.
|
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||||
> Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
|
||||||
|
|
||||||
### Data Storage
|
### Data Storage
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"build:ui": "npm run build --prefix ../ui",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -31,12 +31,14 @@
|
|||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/yauzl": "^2.10.0",
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
@@ -50,6 +50,38 @@ export interface WorkspaceDeleteResponse {
|
|||||||
status: WorkspaceStatus
|
status: WorkspaceStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorktreeKind = "root" | "worktree"
|
||||||
|
|
||||||
|
export interface WorktreeDescriptor {
|
||||||
|
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
|
||||||
|
slug: string
|
||||||
|
/** Absolute directory path on the server host. */
|
||||||
|
directory: string
|
||||||
|
kind: WorktreeKind
|
||||||
|
/** Optional VCS branch name when available. */
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeListResponse {
|
||||||
|
worktrees: WorktreeDescriptor[]
|
||||||
|
/** True when the workspace folder resolves to a Git repository. */
|
||||||
|
isGitRepo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeCreateRequest {
|
||||||
|
slug: string
|
||||||
|
/** Optional branch name (defaults to slug). */
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeMap {
|
||||||
|
version: 1
|
||||||
|
/** Default worktree to use for new sessions and as fallback. */
|
||||||
|
defaultWorktreeSlug: string
|
||||||
|
/** Mapping of *parent* session IDs to a worktree slug. */
|
||||||
|
parentSessionWorktreeSlug: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
@@ -204,7 +236,8 @@ export interface NetworkAddress {
|
|||||||
ip: string
|
ip: string
|
||||||
family: "ipv4" | "ipv6"
|
family: "ipv4" | "ipv6"
|
||||||
scope: "external" | "internal" | "loopback"
|
scope: "external" | "internal" | "loopback"
|
||||||
url: string
|
/** Remote URL using the server's remote protocol/port for this IP. */
|
||||||
|
remoteUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LatestReleaseInfo {
|
export interface LatestReleaseInfo {
|
||||||
@@ -230,16 +263,20 @@ export interface SupportMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerMeta {
|
export interface ServerMeta {
|
||||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
|
||||||
httpBaseUrl: string
|
localUrl: string
|
||||||
|
/** URL remote clients should use (prefers HTTPS when enabled). */
|
||||||
|
remoteUrl?: string
|
||||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||||
eventsUrl: string
|
eventsUrl: string
|
||||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||||
host: string
|
host: string
|
||||||
/** Listening mode derived from host binding. */
|
/** Listening mode derived from host binding. */
|
||||||
listeningMode: "local" | "all"
|
listeningMode: "local" | "all"
|
||||||
/** Actual port in use after binding. */
|
/** Actual local port in use after binding. */
|
||||||
port: number
|
localPort: number
|
||||||
|
/** Actual remote port in use after binding (when remoteUrl is set). */
|
||||||
|
remotePort?: number
|
||||||
/** Display label for the host (e.g., hostname or friendly name). */
|
/** Display label for the host (e.g., hostname or friendly name). */
|
||||||
hostLabel: string
|
hostLabel: string
|
||||||
/** Absolute path of the filesystem root exposed to clients. */
|
/** Absolute path of the filesystem root exposed to clients. */
|
||||||
|
|||||||
@@ -119,10 +119,18 @@ export class AuthManager {
|
|||||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
|
||||||
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
|
||||||
|
}
|
||||||
|
|
||||||
clearSessionCookie(reply: FastifyReply) {
|
clearSessionCookie(reply: FastifyReply) {
|
||||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
|
||||||
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
|
||||||
|
}
|
||||||
|
|
||||||
private requireAuthStore(): AuthStore {
|
private requireAuthStore(): AuthStore {
|
||||||
if (!this.authStore) {
|
if (!this.authStore) {
|
||||||
throw new Error("Auth store is unavailable")
|
throw new Error("Auth store is unavailable")
|
||||||
@@ -143,8 +151,11 @@ function resolvePath(filePath: string) {
|
|||||||
return path.resolve(filePath)
|
return path.resolve(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
|
||||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||||
|
if (options?.secure) {
|
||||||
|
parts.push("Secure")
|
||||||
|
}
|
||||||
if (options?.maxAgeSeconds !== undefined) {
|
if (options?.maxAgeSeconds !== undefined) {
|
||||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const PreferencesSchema = z.object({
|
|||||||
showThinkingBlocks: z.boolean().default(false),
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showTimelineTools: z.boolean().default(true),
|
showTimelineTools: z.boolean().default(true),
|
||||||
|
promptSubmitOnEnter: z.boolean().default(false),
|
||||||
lastUsedBinary: z.string().optional(),
|
lastUsedBinary: z.string().optional(),
|
||||||
locale: z.string().optional(),
|
locale: z.string().optional(),
|
||||||
environmentVariables: z.record(z.string()).default({}),
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
@@ -24,6 +25,12 @@ const PreferencesSchema = z.object({
|
|||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
|
|
||||||
|
// OS notifications
|
||||||
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||||
|
notifyOnNeedsInput: z.boolean().default(true),
|
||||||
|
notifyOnIdle: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { createLogger } from "./logger"
|
|||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -28,8 +30,15 @@ const __dirname = path.dirname(__filename)
|
|||||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
port: number
|
|
||||||
host: string
|
host: string
|
||||||
|
https: boolean
|
||||||
|
http: boolean
|
||||||
|
httpsPort: number
|
||||||
|
httpPort: number
|
||||||
|
tlsKeyPath?: string
|
||||||
|
tlsCertPath?: string
|
||||||
|
tlsCaPath?: string
|
||||||
|
tlsSANs?: string
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configPath: string
|
configPath: string
|
||||||
unrestrictedRoot: boolean
|
unrestrictedRoot: boolean
|
||||||
@@ -47,9 +56,10 @@ interface CliOptions {
|
|||||||
dangerouslySkipAuth: boolean
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
|
||||||
const DEFAULT_HOST = "127.0.0.1"
|
const DEFAULT_HOST = "127.0.0.1"
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
const DEFAULT_HTTPS_PORT = 9898
|
||||||
|
const DEFAULT_HTTP_PORT = 9899
|
||||||
|
|
||||||
function parseCliOptions(argv: string[]): CliOptions {
|
function parseCliOptions(argv: string[]): CliOptions {
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
@@ -57,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.description("CodeNomad CLI server")
|
.description("CodeNomad CLI server")
|
||||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
|
||||||
|
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
|
||||||
|
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
|
||||||
|
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
|
||||||
|
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
|
||||||
|
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
|
||||||
|
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||||
|
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||||
)
|
)
|
||||||
@@ -97,7 +114,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
program.parse(argv, { from: "user" })
|
program.parse(argv, { from: "user" })
|
||||||
const parsed = program.opts<{
|
const parsed = program.opts<{
|
||||||
host: string
|
host: string
|
||||||
port: number
|
https?: string
|
||||||
|
http?: string
|
||||||
|
httpsPort: number
|
||||||
|
httpPort: number
|
||||||
|
tlsKey?: string
|
||||||
|
tlsCert?: string
|
||||||
|
tlsCa?: string
|
||||||
|
tlsSANs?: string
|
||||||
workspaceRoot?: string
|
workspaceRoot?: string
|
||||||
root?: string
|
root?: string
|
||||||
unrestrictedRoot?: boolean
|
unrestrictedRoot?: boolean
|
||||||
@@ -128,9 +152,23 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||||
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||||
|
|
||||||
|
const httpsEnabled = parseBooleanEnv(parsed.https)
|
||||||
|
const httpEnabled = parseBooleanEnv(parsed.http)
|
||||||
|
|
||||||
|
if (!httpsEnabled && !httpEnabled) {
|
||||||
|
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: parsed.port,
|
|
||||||
host: normalizedHost,
|
host: normalizedHost,
|
||||||
|
https: httpsEnabled,
|
||||||
|
http: httpEnabled,
|
||||||
|
httpsPort: parsed.httpsPort,
|
||||||
|
httpPort: parsed.httpPort,
|
||||||
|
tlsKeyPath: parsed.tlsKey,
|
||||||
|
tlsCertPath: parsed.tlsCert,
|
||||||
|
tlsCaPath: parsed.tlsCa,
|
||||||
|
tlsSANs: parsed.tlsSANs,
|
||||||
rootDir: resolvedRoot,
|
rootDir: resolvedRoot,
|
||||||
configPath: parsed.config,
|
configPath: parsed.config,
|
||||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||||
@@ -172,6 +210,13 @@ function resolveHost(input: string | undefined): string {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePath(filePath: string) {
|
||||||
|
if (filePath.startsWith("~/")) {
|
||||||
|
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
function programHasArg(argv: string[], flag: string): boolean {
|
function programHasArg(argv: string[], flag: string): boolean {
|
||||||
return argv.includes(flag)
|
return argv.includes(flag)
|
||||||
}
|
}
|
||||||
@@ -200,12 +245,20 @@ async function main() {
|
|||||||
|
|
||||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
|
const configDir = path.dirname(resolvePath(options.configPath))
|
||||||
|
|
||||||
|
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||||
|
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||||
|
}
|
||||||
|
|
||||||
const serverMeta: ServerMeta = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
localUrl: "http://localhost:0",
|
||||||
|
remoteUrl: undefined,
|
||||||
eventsUrl: `/api/events`,
|
eventsUrl: `/api/events`,
|
||||||
host: options.host,
|
host: options.host,
|
||||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||||
port: options.port,
|
localPort: 0,
|
||||||
|
remotePort: undefined,
|
||||||
hostLabel: options.host,
|
hostLabel: options.host,
|
||||||
workspaceRoot: options.rootDir,
|
workspaceRoot: options.rootDir,
|
||||||
addresses: [],
|
addresses: [],
|
||||||
@@ -229,6 +282,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tlsResolution = resolveHttpsOptions({
|
||||||
|
enabled: options.https,
|
||||||
|
configDir,
|
||||||
|
host: options.host,
|
||||||
|
tlsKeyPath: options.tlsKeyPath,
|
||||||
|
tlsCertPath: options.tlsCertPath,
|
||||||
|
tlsCaPath: options.tlsCaPath,
|
||||||
|
tlsSANs: options.tlsSANs,
|
||||||
|
logger: logger.child({ component: "tls" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
@@ -237,7 +303,8 @@ async function main() {
|
|||||||
binaryRegistry,
|
binaryRegistry,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
|
nodeExtraCaCertsPath,
|
||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore()
|
const instanceStore = new InstanceStore()
|
||||||
@@ -277,9 +344,33 @@ async function main() {
|
|||||||
minServerVersion: uiResolution.minServerVersion,
|
minServerVersion: uiResolution.minServerVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createHttpServer({
|
if (uiResolution.uiDevServerUrl && options.https) {
|
||||||
host: options.host,
|
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||||
port: options.port,
|
}
|
||||||
|
|
||||||
|
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
|
||||||
|
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||||
|
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||||
|
|
||||||
|
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
|
||||||
|
const httpBindPort = httpPortExplicit ? options.httpPort : 0
|
||||||
|
|
||||||
|
// Listener binding rules:
|
||||||
|
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
|
||||||
|
// - Remote access disabled: both listen on loopback.
|
||||||
|
// - HTTP-only mode: respect --host (used for dev/testing).
|
||||||
|
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
|
||||||
|
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
|
||||||
|
|
||||||
|
const servers: Array<ReturnType<typeof createHttpServer>> = []
|
||||||
|
|
||||||
|
const httpServer = options.http
|
||||||
|
? createHttpServer({
|
||||||
|
bindHost: httpBindHost,
|
||||||
|
bindPort: httpBindPort,
|
||||||
|
defaultPort: options.httpPort,
|
||||||
|
protocol: "http",
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
configStore,
|
||||||
binaryRegistry,
|
binaryRegistry,
|
||||||
@@ -292,13 +383,86 @@ async function main() {
|
|||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
})
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const startInfo = await server.start()
|
const httpsServer = options.https
|
||||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
? createHttpServer({
|
||||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
bindHost: httpsBindHost,
|
||||||
|
bindPort: httpsBindPort,
|
||||||
|
defaultPort: options.httpsPort,
|
||||||
|
protocol: "https",
|
||||||
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
|
workspaceManager,
|
||||||
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
|
fileSystemBrowser,
|
||||||
|
eventBus,
|
||||||
|
serverMeta,
|
||||||
|
instanceStore,
|
||||||
|
authManager,
|
||||||
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
|
uiDevServerUrl: undefined,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (httpServer) servers.push(httpServer)
|
||||||
|
if (httpsServer) servers.push(httpsServer)
|
||||||
|
|
||||||
|
const [httpStart, httpsStart] = await Promise.all([
|
||||||
|
httpServer ? httpServer.start() : Promise.resolve(null),
|
||||||
|
httpsServer ? httpsServer.start() : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
|
||||||
|
const localStart = httpStart ?? httpsStart
|
||||||
|
if (!localStart) {
|
||||||
|
throw new Error("No listeners started")
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteStart = httpsStart ?? httpStart
|
||||||
|
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||||
|
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||||
|
|
||||||
|
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||||
|
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||||
|
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||||
|
// which can lead clients to talk to the wrong process.
|
||||||
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
|
let remoteUrl: string | undefined
|
||||||
|
if (remoteStart) {
|
||||||
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
let remoteHost = options.host
|
||||||
|
if (wantsAll) {
|
||||||
|
if (options.host === "0.0.0.0") {
|
||||||
|
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
|
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remoteHost = "localhost"
|
||||||
|
}
|
||||||
|
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
serverMeta.localUrl = localUrl
|
||||||
|
serverMeta.localPort = localStart.port
|
||||||
|
serverMeta.remoteUrl = remoteUrl
|
||||||
|
serverMeta.remotePort = remoteStart?.port
|
||||||
|
serverMeta.host = options.host
|
||||||
|
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||||
|
|
||||||
|
if (serverMeta.remotePort && remoteUrl) {
|
||||||
|
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||||
|
} else {
|
||||||
|
serverMeta.addresses = []
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||||
|
if (serverMeta.remoteUrl) {
|
||||||
|
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.launch) {
|
if (options.launch) {
|
||||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
let shuttingDown = false
|
let shuttingDown = false
|
||||||
@@ -328,8 +492,8 @@ async function main() {
|
|||||||
|
|
||||||
const shutdownHttp = (async () => {
|
const shutdownHttp = (async () => {
|
||||||
try {
|
try {
|
||||||
await server.stop()
|
await Promise.allSettled(servers.map((srv) => srv.stop()))
|
||||||
logger.info("HTTP server stopped")
|
logger.info("HTTP server(s) stopped")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import path from "path"
|
|||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
|
|||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
@@ -28,8 +30,12 @@ import { registerAuthRoutes } from "./routes/auth"
|
|||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
host: string
|
bindHost: string
|
||||||
port: number
|
bindPort: number
|
||||||
|
/** When bindPort is 0, try this first. */
|
||||||
|
defaultPort: number
|
||||||
|
protocol: "http" | "https"
|
||||||
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
configStore: ConfigStore
|
configStore: ConfigStore
|
||||||
binaryRegistry: BinaryRegistry
|
binaryRegistry: BinaryRegistry
|
||||||
@@ -49,10 +55,15 @@ interface HttpServerStartResult {
|
|||||||
displayHost: string
|
displayHost: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HTTP_PORT = 9898
|
|
||||||
|
|
||||||
export function createHttpServer(deps: HttpServerDeps) {
|
export function createHttpServer(deps: HttpServerDeps) {
|
||||||
const app = Fastify({ logger: false })
|
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
||||||
|
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
||||||
|
const app = Fastify(
|
||||||
|
({
|
||||||
|
logger: false,
|
||||||
|
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
|
||||||
|
} as unknown) as any,
|
||||||
|
) as unknown as FastifyInstance
|
||||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||||
const apiLogger = deps.logger.child({ component: "http" })
|
const apiLogger = deps.logger.child({ component: "http" })
|
||||||
const sseLogger = deps.logger.child({ component: "sse" })
|
const sseLogger = deps.logger.child({ component: "sse" })
|
||||||
@@ -95,6 +106,27 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
|
const getSelfOrigins = (): Set<string> => {
|
||||||
|
const origins = new Set<string>()
|
||||||
|
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) continue
|
||||||
|
try {
|
||||||
|
origins.add(new URL(candidate).origin)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const addr of deps.serverMeta.addresses ?? []) {
|
||||||
|
try {
|
||||||
|
origins.add(new URL(addr.remoteUrl).origin)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins
|
||||||
|
}
|
||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: (origin, cb) => {
|
origin: (origin, cb) => {
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
@@ -102,14 +134,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let selfOrigin: string | null = null
|
const selfOrigins = getSelfOrigins()
|
||||||
try {
|
if (selfOrigins.has(origin)) {
|
||||||
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
|
||||||
} catch {
|
|
||||||
selfOrigin = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selfOrigin && origin === selfOrigin) {
|
|
||||||
cb(null, true)
|
cb(null, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +146,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||||
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
|
||||||
cb(null, true)
|
cb(null, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,6 +248,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
instanceStore: deps.instanceStore,
|
instanceStore: deps.instanceStore,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
@@ -242,12 +269,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
instance: app,
|
instance: app,
|
||||||
start: async (): Promise<HttpServerStartResult> => {
|
start: async (): Promise<HttpServerStartResult> => {
|
||||||
const attemptListen = async (requestedPort: number) => {
|
const attemptListen = async (requestedPort: number) => {
|
||||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
|
||||||
return { addressInfo, requestedPort }
|
return { addressInfo, requestedPort }
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoPortRequested = deps.port === 0
|
const autoPortRequested = deps.bindPort === 0
|
||||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
||||||
|
|
||||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||||
if (!autoPortRequested) return false
|
if (!autoPortRequested) return false
|
||||||
@@ -283,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
|
||||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
|
||||||
|
|
||||||
deps.serverMeta.httpBaseUrl = serverUrl
|
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
|
||||||
deps.serverMeta.host = deps.host
|
|
||||||
deps.serverMeta.port = actualPort
|
|
||||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
|
||||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
|
||||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
|
||||||
|
|
||||||
return { port: actualPort, url: serverUrl, displayHost }
|
return { port: actualPort, url: serverUrl, displayHost }
|
||||||
},
|
},
|
||||||
@@ -312,31 +334,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||||
|
|
||||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
const proxyBaseHandler = async (
|
||||||
await proxyWorkspaceRequest({
|
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||||
request,
|
|
||||||
reply,
|
|
||||||
workspaceManager: deps.workspaceManager,
|
|
||||||
pathSuffix: "",
|
|
||||||
logger: deps.logger,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyWildcardHandler = async (
|
|
||||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) => {
|
) => {
|
||||||
await proxyWorkspaceRequest({
|
await proxyWorkspaceRequest({
|
||||||
request,
|
request,
|
||||||
reply,
|
reply,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
|
worktreeSlug: request.params.slug,
|
||||||
|
pathSuffix: "",
|
||||||
|
logger: deps.logger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyWildcardHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxyWorkspaceRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
workspaceManager: deps.workspaceManager,
|
||||||
|
worktreeSlug: request.params.slug,
|
||||||
pathSuffix: request.params["*"] ?? "",
|
pathSuffix: request.params["*"] ?? "",
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,12 +374,75 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
worktreeSlug: string
|
||||||
pathSuffix?: string
|
pathSuffix?: string
|
||||||
}) {
|
}) {
|
||||||
const { request, reply, workspaceManager, logger } = args
|
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
||||||
const workspaceId = (request.params as { id: string }).id
|
const workspaceId = (request.params as { id: string }).id
|
||||||
const workspace = workspaceManager.get(workspaceId)
|
const workspace = workspaceManager.get(workspaceId)
|
||||||
|
|
||||||
|
const bodyToJson = (body: unknown): unknown => {
|
||||||
|
if (body == null) return null
|
||||||
|
|
||||||
|
const anyBody = body as any
|
||||||
|
if (anyBody && typeof anyBody.pipe === "function") {
|
||||||
|
// Don't consume streams (would break proxying).
|
||||||
|
// Best-effort: if the stream already has buffered chunks, parse those.
|
||||||
|
try {
|
||||||
|
const buffered = anyBody?._readableState?.buffer
|
||||||
|
if (Array.isArray(buffered) && buffered.length > 0) {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for (const entry of buffered) {
|
||||||
|
if (!entry) continue
|
||||||
|
if (Buffer.isBuffer(entry)) {
|
||||||
|
chunks.push(entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const data = (entry as any).data
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
chunks.push(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
const text = Buffer.concat(chunks).toString("utf-8")
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { __raw: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
return { __stream: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeParse = (input: string): unknown => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(input)
|
||||||
|
} catch {
|
||||||
|
return { __raw: input }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
return maybeParse(body.toString("utf-8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === "string") {
|
||||||
|
return maybeParse(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === "object") {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
reply.code(404).send({ error: "Workspace not found" })
|
reply.code(404).send({ error: "Workspace not found" })
|
||||||
return
|
return
|
||||||
@@ -364,6 +454,23 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidWorktreeSlug(worktreeSlug)) {
|
||||||
|
reply.code(400).send({ error: "Invalid worktree slug" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = await resolveWorktreeDirectory({
|
||||||
|
workspaceId,
|
||||||
|
workspacePath: workspace.path,
|
||||||
|
worktreeSlug,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404).send({ error: "Worktree not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
@@ -381,15 +488,42 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
headers.authorization = instanceAuthHeader
|
headers.authorization = instanceAuthHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
|
|
||||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||||
const directory = workspace.path
|
|
||||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||||
|
|
||||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||||
|
|
||||||
|
if (logger.isLevelEnabled("trace")) {
|
||||||
|
const outgoing: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||||
|
outgoing[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redact sensitive headers.
|
||||||
|
for (const key of Object.keys(outgoing)) {
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
||||||
|
outgoing[key] = "<redacted>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
method: request.method,
|
||||||
|
targetUrl,
|
||||||
|
worktreeSlug,
|
||||||
|
directory,
|
||||||
|
contentType: request.headers["content-type"],
|
||||||
|
body: bodyToJson(request.body),
|
||||||
|
headers: outgoing,
|
||||||
|
},
|
||||||
|
"Proxy -> OpenCode request",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return headers
|
return headers
|
||||||
},
|
},
|
||||||
onError: (proxyReply, { error }) => {
|
onError: (proxyReply, { error }) => {
|
||||||
@@ -409,6 +543,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorktreeCacheEntry = {
|
||||||
|
expiresAt: number
|
||||||
|
repoRoot: string
|
||||||
|
worktrees: Array<{ slug: string; directory: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKTREE_CACHE_TTL_MS = 2000
|
||||||
|
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||||
|
|
||||||
|
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
||||||
|
const cached = worktreeCache.get(params.workspaceId)
|
||||||
|
const now = Date.now()
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||||
|
const entry: WorktreeCacheEntry = {
|
||||||
|
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||||
|
repoRoot,
|
||||||
|
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
||||||
|
}
|
||||||
|
worktreeCache.set(params.workspaceId, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorktreeDirectory(params: {
|
||||||
|
workspaceId: string
|
||||||
|
workspacePath: string
|
||||||
|
worktreeSlug: string
|
||||||
|
logger: Logger
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const { worktreeSlug } = params
|
||||||
|
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||||
|
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
||||||
|
if (match) {
|
||||||
|
return match.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the slug is new (e.g., created moments ago), refresh once.
|
||||||
|
worktreeCache.delete(params.workspaceId)
|
||||||
|
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||||
|
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
app.log.warn("UI static directory not provided; API endpoints only")
|
||||||
|
|||||||
75
packages/server/src/server/network-addresses.ts
Normal file
75
packages/server/src/server/network-addresses.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import os from "os"
|
||||||
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
|
export function resolveNetworkAddresses(args: {
|
||||||
|
host: string
|
||||||
|
protocol: "http" | "https"
|
||||||
|
port: number
|
||||||
|
}): NetworkAddress[] {
|
||||||
|
const { host, protocol, port } = args
|
||||||
|
const interfaces = os.networkInterfaces()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const results: NetworkAddress[] = []
|
||||||
|
|
||||||
|
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||||
|
if (!ip || ip === "0.0.0.0") return
|
||||||
|
const key = `ipv4-${ip}`
|
||||||
|
if (seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeFamily = (value: string | number) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const lowered = value.toLowerCase()
|
||||||
|
if (lowered === "ipv4") {
|
||||||
|
return "ipv4" as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value === 4) return "ipv4" as const
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === "0.0.0.0") {
|
||||||
|
// Enumerate system interfaces (IPv4 only)
|
||||||
|
for (const entries of Object.values(interfaces)) {
|
||||||
|
if (!entries) continue
|
||||||
|
for (const entry of entries) {
|
||||||
|
const family = normalizeFamily(entry.family)
|
||||||
|
if (!family) continue
|
||||||
|
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||||
|
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||||
|
addAddress(entry.address, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include loopback address
|
||||||
|
addAddress("127.0.0.1", "loopback")
|
||||||
|
|
||||||
|
// Include explicitly configured host if it was IPv4
|
||||||
|
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||||
|
const isLoopback = host.startsWith("127.")
|
||||||
|
addAddress(host, isLoopback ? "loopback" : "external")
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||||
|
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
|
if (scopeDelta !== 0) return scopeDelta
|
||||||
|
return a.ip.localeCompare(b.ip)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
|
if (!value) return false
|
||||||
|
const parts = value.split(".")
|
||||||
|
if (parts.length !== 4) return false
|
||||||
|
return parts.every((part) => {
|
||||||
|
if (part.length === 0 || part.length > 3) return false
|
||||||
|
if (!/^[0-9]+$/.test(part)) return false
|
||||||
|
const num = Number(part)
|
||||||
|
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = deps.authManager.createSession(body.username)
|
const session = deps.authManager.createSession(body.username)
|
||||||
deps.authManager.setSessionCookie(reply, session.id)
|
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||||
reply.send({ ok: true })
|
reply.send({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,12 +112,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
|
|
||||||
const username = deps.authManager.getStatus().username
|
const username = deps.authManager.getStatus().username
|
||||||
const session = deps.authManager.createSession(username)
|
const session = deps.authManager.createSession(username)
|
||||||
deps.authManager.setSessionCookie(reply, session.id)
|
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||||
reply.send({ ok: true })
|
reply.send({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_request, reply) => {
|
app.post("/api/auth/logout", async (request, reply) => {
|
||||||
deps.authManager.clearSessionCookie(reply)
|
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
|
||||||
reply.send({ ok: true })
|
reply.send({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -139,6 +139,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSecureRequest(request: any) {
|
||||||
|
if (request.protocol === "https") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
function escapeHtml(value: string) {
|
||||||
return value.replace(/[&<>"]/g, (char) => {
|
return value.replace(/[&<>"]/g, (char) => {
|
||||||
switch (char) {
|
switch (char) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import os from "os"
|
import { ServerMeta } from "../../api-types"
|
||||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
import { resolveNetworkAddresses } from "../network-addresses"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const port = resolvePort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
const remote = resolveRemote(meta)
|
||||||
|
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
port,
|
localPort,
|
||||||
|
remotePort: remote?.port,
|
||||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
addresses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePort(meta: ServerMeta): number {
|
function resolveLocalPort(meta: ServerMeta): number {
|
||||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
|
||||||
return meta.port
|
return meta.localPort
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(meta.httpBaseUrl)
|
const parsed = new URL(meta.localUrl)
|
||||||
const port = Number(parsed.port)
|
const port = Number(parsed.port)
|
||||||
return Number.isInteger(port) && port > 0 ? port : 0
|
return Number.isInteger(port) && port > 0 ? port : 0
|
||||||
} catch {
|
} catch {
|
||||||
@@ -35,74 +37,22 @@ function resolvePort(meta: ServerMeta): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
|
||||||
|
if (!meta.remoteUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(meta.remoteUrl)
|
||||||
|
const protocol = parsed.protocol === "https:" ? "https" : "http"
|
||||||
|
const port = Number(parsed.port)
|
||||||
|
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isLoopbackHost(host: string): boolean {
|
function isLoopbackHost(host: string): boolean {
|
||||||
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
// NetworkAddress shape is resolved in ../network-addresses
|
||||||
const interfaces = os.networkInterfaces()
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const results: NetworkAddress[] = []
|
|
||||||
|
|
||||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
|
||||||
if (!ip || ip === "0.0.0.0") return
|
|
||||||
const key = `ipv4-${ip}`
|
|
||||||
if (seen.has(key)) return
|
|
||||||
seen.add(key)
|
|
||||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeFamily = (value: string | number) => {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const lowered = value.toLowerCase()
|
|
||||||
if (lowered === "ipv4") {
|
|
||||||
return "ipv4" as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (value === 4) return "ipv4" as const
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (host === "0.0.0.0") {
|
|
||||||
// Enumerate system interfaces (IPv4 only)
|
|
||||||
for (const entries of Object.values(interfaces)) {
|
|
||||||
if (!entries) continue
|
|
||||||
for (const entry of entries) {
|
|
||||||
const family = normalizeFamily(entry.family)
|
|
||||||
if (!family) continue
|
|
||||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
|
||||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
|
||||||
addAddress(entry.address, scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always include loopback address
|
|
||||||
addAddress("127.0.0.1", "loopback")
|
|
||||||
|
|
||||||
// Include explicitly configured host if it was IPv4
|
|
||||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
|
||||||
const isLoopback = host.startsWith("127.")
|
|
||||||
addAddress(host, isLoopback ? "loopback" : "external")
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
|
||||||
|
|
||||||
return results.sort((a, b) => {
|
|
||||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
|
||||||
if (scopeDelta !== 0) return scopeDelta
|
|
||||||
return a.ip.localeCompare(b.ip)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIPv4Address(value: string | undefined): value is string {
|
|
||||||
if (!value) return false
|
|
||||||
const parts = value.split(".")
|
|
||||||
if (parts.length !== 4) return false
|
|
||||||
return parts.every((part) => {
|
|
||||||
if (part.length === 0 || part.length > 3) return false
|
|
||||||
if (!/^[0-9]+$/.test(part)) return false
|
|
||||||
const num = Number(part)
|
|
||||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
195
packages/server/src/server/routes/worktrees.ts
Normal file
195
packages/server/src/server/routes/worktrees.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
import {
|
||||||
|
resolveRepoRoot,
|
||||||
|
listWorktrees,
|
||||||
|
isValidWorktreeSlug,
|
||||||
|
createManagedWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
} from "../../workspaces/git-worktrees"
|
||||||
|
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
||||||
|
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorktreeMapSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
defaultWorktreeSlug: z.string().min(1).default("root"),
|
||||||
|
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeCreateSchema = z.object({
|
||||||
|
slug: z.string().trim().min(1),
|
||||||
|
branch: z.string().trim().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||||
|
const response: WorktreeListResponse = { worktrees, isGitRepo }
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = WorktreeCreateSchema.parse(request.body ?? {})
|
||||||
|
const slug = body.slug
|
||||||
|
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree slug" }
|
||||||
|
}
|
||||||
|
if (body.branch) {
|
||||||
|
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree branch" }
|
||||||
|
}
|
||||||
|
if (body.branch !== slug) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Branch must match slug" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Workspace is not a Git repository" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
|
||||||
|
|
||||||
|
const created = await createManagedWorktree({
|
||||||
|
repoRoot,
|
||||||
|
workspaceFolder: workspace.path,
|
||||||
|
slug,
|
||||||
|
logger: request.log,
|
||||||
|
})
|
||||||
|
|
||||||
|
reply.code(201)
|
||||||
|
return created
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
|
||||||
|
"/api/workspaces/:id/worktrees/:slug",
|
||||||
|
async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = (request.params.slug ?? "").trim()
|
||||||
|
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree slug" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Workspace is not a Git repository" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||||
|
const match = worktrees.find((wt) => wt.slug === slug)
|
||||||
|
if (!match || match.kind === "root") {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Worktree not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
|
||||||
|
|
||||||
|
// Best-effort: prune any mappings that point at the deleted worktree.
|
||||||
|
const current = await readWorktreeMap(workspace.path, request.log)
|
||||||
|
let changed = false
|
||||||
|
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||||
|
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
|
||||||
|
if (mapped === slug) {
|
||||||
|
delete nextMapping[sessionId]
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
|
||||||
|
if (nextDefault !== current.defaultWorktreeSlug) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await writeWorktreeMap(
|
||||||
|
workspace.path,
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug: nextDefault,
|
||||||
|
parentSessionWorktreeSlug: nextMapping,
|
||||||
|
},
|
||||||
|
request.log,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, reply)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
return await readWorktreeMap(workspace.path, request.log)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
|
||||||
|
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid defaultWorktreeSlug" }
|
||||||
|
}
|
||||||
|
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
|
||||||
|
if (!isValidWorktreeSlug(slug)) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: "Invalid worktree slug in mapping" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeWorktreeMap(workspace.path, parsed, request.log)
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown, reply: FastifyReply) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||||
|
}
|
||||||
283
packages/server/src/server/tls.ts
Normal file
283
packages/server/src/server/tls.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { createRequire } from "module"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
type Forge = typeof import("node-forge")
|
||||||
|
|
||||||
|
function loadForge(): Forge {
|
||||||
|
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
|
||||||
|
return require("node-forge") as Forge
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedHttpsOptions {
|
||||||
|
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
|
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
|
||||||
|
caCertPath?: string
|
||||||
|
mode: "provided" | "generated"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveHttpsOptionsArgs {
|
||||||
|
enabled: boolean
|
||||||
|
configDir: string
|
||||||
|
host: string
|
||||||
|
tlsKeyPath?: string
|
||||||
|
tlsCertPath?: string
|
||||||
|
tlsCaPath?: string
|
||||||
|
tlsSANs?: string
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEAF_VALIDITY_DAYS = 30
|
||||||
|
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
|
||||||
|
|
||||||
|
const CA_VALIDITY_DAYS = 365
|
||||||
|
|
||||||
|
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
|
||||||
|
if (!args.enabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
|
||||||
|
if (hasProvided) {
|
||||||
|
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
|
||||||
|
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
|
||||||
|
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
|
||||||
|
return {
|
||||||
|
httpsOptions: { key, cert, ca },
|
||||||
|
caCertPath: args.tlsCaPath,
|
||||||
|
mode: "provided",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureGeneratedTls(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
|
||||||
|
const tlsDir = path.join(args.configDir, "tls")
|
||||||
|
const caKeyPath = path.join(tlsDir, "ca-key.pem")
|
||||||
|
const caCertPath = path.join(tlsDir, "ca-cert.pem")
|
||||||
|
const keyPath = path.join(tlsDir, "server-key.pem")
|
||||||
|
const certPath = path.join(tlsDir, "server-cert.pem")
|
||||||
|
|
||||||
|
fs.mkdirSync(tlsDir, { recursive: true })
|
||||||
|
|
||||||
|
const shouldRotateLeaf = () => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(certPath)) return true
|
||||||
|
const pem = fs.readFileSync(certPath, "utf-8")
|
||||||
|
const x509 = new crypto.X509Certificate(pem)
|
||||||
|
const validToMs = Date.parse(x509.validTo)
|
||||||
|
if (!Number.isFinite(validToMs)) return true
|
||||||
|
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
return Date.now() >= rotateAt
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRotateCa = () => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(caCertPath)) return true
|
||||||
|
const pem = fs.readFileSync(caCertPath, "utf-8")
|
||||||
|
const x509 = new crypto.X509Certificate(pem)
|
||||||
|
const validToMs = Date.parse(x509.validTo)
|
||||||
|
if (!Number.isFinite(validToMs)) return true
|
||||||
|
// CA rotates only when expired.
|
||||||
|
return Date.now() >= validToMs
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
|
||||||
|
const { caKeyPem, caCertPem } = generateCaCertificate()
|
||||||
|
writePemFile(caKeyPath, caKeyPem, 0o600)
|
||||||
|
writePemFile(caCertPath, caCertPem, 0o644)
|
||||||
|
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
|
||||||
|
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
|
||||||
|
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
|
||||||
|
|
||||||
|
const { keyPem, certPem } = generateServerCertificate({
|
||||||
|
host: args.host,
|
||||||
|
tlsSANs: args.tlsSANs,
|
||||||
|
caKeyPem,
|
||||||
|
caCertPem,
|
||||||
|
})
|
||||||
|
|
||||||
|
writePemFile(keyPath, keyPem, 0o600)
|
||||||
|
writePemFile(certPath, certPem, 0o644)
|
||||||
|
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = fs.readFileSync(keyPath, "utf-8")
|
||||||
|
const cert = fs.readFileSync(certPath, "utf-8")
|
||||||
|
const ca = fs.readFileSync(caCertPath, "utf-8")
|
||||||
|
|
||||||
|
// Present the CA as part of the chain.
|
||||||
|
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpsOptions: {
|
||||||
|
key,
|
||||||
|
cert: chainedCert,
|
||||||
|
},
|
||||||
|
caCertPath,
|
||||||
|
mode: "generated",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePemFile(filePath: string, content: string, mode: number) {
|
||||||
|
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, mode)
|
||||||
|
} catch {
|
||||||
|
// best effort on platforms that ignore chmod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
|
||||||
|
const forge = loadForge()
|
||||||
|
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||||
|
const cert = forge.pki.createCertificate()
|
||||||
|
cert.publicKey = keys.publicKey
|
||||||
|
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const notBefore = new Date(now.getTime() - 60_000)
|
||||||
|
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||||
|
cert.validity.notBefore = notBefore
|
||||||
|
cert.validity.notAfter = notAfter
|
||||||
|
|
||||||
|
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
|
||||||
|
cert.setSubject(attrs)
|
||||||
|
cert.setIssuer(attrs)
|
||||||
|
|
||||||
|
cert.setExtensions([
|
||||||
|
{ name: "basicConstraints", cA: true },
|
||||||
|
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
|
||||||
|
{ name: "subjectKeyIdentifier" },
|
||||||
|
])
|
||||||
|
|
||||||
|
cert.sign(keys.privateKey, forge.md.sha256.create())
|
||||||
|
|
||||||
|
return {
|
||||||
|
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||||
|
caCertPem: forge.pki.certificateToPem(cert),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateServerCertificate(args: {
|
||||||
|
host: string
|
||||||
|
tlsSANs?: string
|
||||||
|
caKeyPem: string
|
||||||
|
caCertPem: string
|
||||||
|
}): { keyPem: string; certPem: string } {
|
||||||
|
const forge = loadForge()
|
||||||
|
|
||||||
|
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
|
||||||
|
const caCert = forge.pki.certificateFromPem(args.caCertPem)
|
||||||
|
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||||
|
const cert = forge.pki.createCertificate()
|
||||||
|
cert.publicKey = keys.publicKey
|
||||||
|
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const notBefore = new Date(now.getTime() - 60_000)
|
||||||
|
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||||
|
cert.validity.notBefore = notBefore
|
||||||
|
cert.validity.notAfter = notAfter
|
||||||
|
|
||||||
|
const commonName = pickCommonName(args.host)
|
||||||
|
cert.setSubject([{ name: "commonName", value: commonName }])
|
||||||
|
cert.setIssuer(caCert.subject.attributes)
|
||||||
|
|
||||||
|
const san = buildSubjectAltNames(args.host, args.tlsSANs)
|
||||||
|
|
||||||
|
cert.setExtensions([
|
||||||
|
{ name: "basicConstraints", cA: false },
|
||||||
|
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
||||||
|
{ name: "extKeyUsage", serverAuth: true },
|
||||||
|
{ name: "subjectAltName", altNames: san },
|
||||||
|
{ name: "subjectKeyIdentifier" },
|
||||||
|
])
|
||||||
|
|
||||||
|
cert.sign(caKey, forge.md.sha256.create())
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||||
|
certPem: forge.pki.certificateToPem(cert),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCommonName(host: string): string {
|
||||||
|
if (!host || host === "0.0.0.0") {
|
||||||
|
return "localhost"
|
||||||
|
}
|
||||||
|
if (host === "127.0.0.1") {
|
||||||
|
return "localhost"
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
|
||||||
|
const dns = new Set<string>()
|
||||||
|
const ips = new Set<string>()
|
||||||
|
|
||||||
|
dns.add("localhost")
|
||||||
|
ips.add("127.0.0.1")
|
||||||
|
|
||||||
|
if (host && host !== "0.0.0.0") {
|
||||||
|
if (isIPv4(host)) {
|
||||||
|
ips.add(host)
|
||||||
|
} else {
|
||||||
|
dns.add(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of splitList(tlsSANs)) {
|
||||||
|
if (isIPv4(token)) {
|
||||||
|
ips.add(token)
|
||||||
|
} else if (token) {
|
||||||
|
dns.add(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
|
||||||
|
|
||||||
|
// 2 = DNS, 7 = IP
|
||||||
|
for (const name of Array.from(dns)) {
|
||||||
|
altNames.push({ type: 2, value: name })
|
||||||
|
}
|
||||||
|
for (const ip of Array.from(ips)) {
|
||||||
|
altNames.push({ type: 7, ip })
|
||||||
|
}
|
||||||
|
|
||||||
|
return altNames
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(input: string | undefined): string[] {
|
||||||
|
if (!input) return []
|
||||||
|
return input
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4(value: string): boolean {
|
||||||
|
const parts = value.split(".")
|
||||||
|
if (parts.length !== 4) return false
|
||||||
|
return parts.every((part) => {
|
||||||
|
if (!/^[0-9]+$/.test(part)) return false
|
||||||
|
const num = Number(part)
|
||||||
|
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||||
|
})
|
||||||
|
}
|
||||||
241
packages/server/src/workspaces/git-worktrees.ts
Normal file
241
packages/server/src/workspaces/git-worktrees.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { spawn } from "child_process"
|
||||||
|
import type { WorktreeDescriptor } from "../api-types"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
|
||||||
|
export interface LogLike {
|
||||||
|
debug?: (obj: any, msg?: string) => void
|
||||||
|
warn?: (obj: any, msg?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||||
|
let stdout = ""
|
||||||
|
let stderr = ""
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString()
|
||||||
|
})
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
child.once("error", (error) => {
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
})
|
||||||
|
child.once("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ ok: true, stdout })
|
||||||
|
} else {
|
||||||
|
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||||
|
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||||
|
if (!result.ok) {
|
||||||
|
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||||
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
|
}
|
||||||
|
const repoRoot = result.stdout.trim()
|
||||||
|
if (!repoRoot) {
|
||||||
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
|
}
|
||||||
|
return { repoRoot, isGitRepo: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
||||||
|
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||||
|
const lines = output.split(/\r?\n/)
|
||||||
|
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current.worktree) {
|
||||||
|
records.push({ worktree: current.worktree, branch: current.branch })
|
||||||
|
}
|
||||||
|
current = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const [key, ...rest] = trimmed.split(" ")
|
||||||
|
const value = rest.join(" ").trim()
|
||||||
|
if (key === "worktree") {
|
||||||
|
current.worktree = value
|
||||||
|
} else if (key === "branch") {
|
||||||
|
// branch is like refs/heads/foo
|
||||||
|
current.branch = value.replace(/^refs\/heads\//, "")
|
||||||
|
} else if (key === "HEAD") {
|
||||||
|
current.head = value
|
||||||
|
} else if (key === "detached") {
|
||||||
|
current.detached = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorktrees(params: {
|
||||||
|
repoRoot: string
|
||||||
|
workspaceFolder: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<WorktreeDescriptor[]> {
|
||||||
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
|
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||||
|
|
||||||
|
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||||
|
if (!result.ok) {
|
||||||
|
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||||
|
return [rootDescriptor]
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = parseWorktreePorcelain(result.stdout)
|
||||||
|
|
||||||
|
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
|
const seen = new Set<string>(["root"])
|
||||||
|
|
||||||
|
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
||||||
|
const branch = (record.branch ?? "").trim()
|
||||||
|
if (branch) {
|
||||||
|
return branch
|
||||||
|
}
|
||||||
|
const head = (record.head ?? "").trim()
|
||||||
|
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
|
||||||
|
return `detached-${head.slice(0, 7)}`
|
||||||
|
}
|
||||||
|
// Fallback: stable-ish identifier derived from directory basename.
|
||||||
|
const base = path.basename(record.worktree || "")
|
||||||
|
return base ? `worktree-${base}` : "worktree"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const abs = record.worktree
|
||||||
|
if (!abs || typeof abs !== "string") continue
|
||||||
|
|
||||||
|
// Skip the root record (we always expose it as slug="root").
|
||||||
|
if (path.resolve(abs) === path.resolve(repoRoot)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = normalizeSlug(record)
|
||||||
|
if (!slug || slug === "root") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (seen.has(slug)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(slug)
|
||||||
|
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
return worktrees
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidWorktreeSlug(slug: string): boolean {
|
||||||
|
if (!slug) return false
|
||||||
|
const trimmed = slug.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (trimmed.length > 200) return false
|
||||||
|
// Disallow control characters; allow branch-like slugs including '/'.
|
||||||
|
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManagedWorktree(params: {
|
||||||
|
repoRoot: string
|
||||||
|
workspaceFolder: string
|
||||||
|
slug: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||||
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
|
const branch = params.slug.trim()
|
||||||
|
|
||||||
|
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
|
||||||
|
throw new Error("Invalid worktree slug")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeDirName = (input: string): string => {
|
||||||
|
const normalized = input
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/]+/g, "-")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
return normalized || "worktree"
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
|
||||||
|
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
|
||||||
|
await fsp.mkdir(worktreesDir, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(targetDir)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
throw new Error("Worktree directory already exists")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code !== "ENOENT") {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
|
||||||
|
|
||||||
|
// Prefer creating a new branch from HEAD.
|
||||||
|
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
|
||||||
|
if (first.ok) {
|
||||||
|
return { slug: branch, directory: targetDir, branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
|
||||||
|
if (message.includes("already exists")) {
|
||||||
|
// If the branch already exists, add worktree for that branch.
|
||||||
|
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
|
||||||
|
if (second.ok) {
|
||||||
|
return { slug: branch, directory: targetDir, branch }
|
||||||
|
}
|
||||||
|
throw second.error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw first.error
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeWorktree(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
directory: string
|
||||||
|
force?: boolean
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<void> {
|
||||||
|
const { workspaceFolder, logger } = params
|
||||||
|
const directory = (params.directory ?? "").trim()
|
||||||
|
if (!directory) {
|
||||||
|
throw new Error("Invalid worktree directory")
|
||||||
|
}
|
||||||
|
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
|
||||||
|
|
||||||
|
const args = ["worktree", "remove"]
|
||||||
|
if (params.force) {
|
||||||
|
args.push("--force")
|
||||||
|
}
|
||||||
|
args.push(directory)
|
||||||
|
|
||||||
|
const result = await runGit(args, workspaceFolder)
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort cleanup of stale metadata.
|
||||||
|
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ export class InstanceEventBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
const url = `http://${INSTANCE_HOST}:${port}/global/event`
|
||||||
|
|
||||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
@@ -165,8 +165,32 @@ export class InstanceEventBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
const parsed = JSON.parse(payload) as any
|
||||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCode SSE payload shapes vary across versions.
|
||||||
|
// Common variants:
|
||||||
|
// - { type, properties, ... }
|
||||||
|
// - { payload: { type, properties, ... }, directory: "/abs/path" }
|
||||||
|
// - { payload: { type, properties, ... } }
|
||||||
|
const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed
|
||||||
|
|
||||||
|
const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null
|
||||||
|
|
||||||
|
// Attach directory when available (don't overwrite if already present).
|
||||||
|
if (event && !(event as any).directory && typeof (parsed as any).directory === "string") {
|
||||||
|
;(event as any).directory = (parsed as any).directory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || typeof (event as any).type !== "string") {
|
||||||
|
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
|
||||||
if (this.options.logger.isLevelEnabled("trace")) {
|
if (this.options.logger.isLevelEnabled("trace")) {
|
||||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface WorkspaceManagerOptions {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
|
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
||||||
|
nodeExtraCaCertsPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||||
@@ -91,7 +93,7 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||||
|
|
||||||
const proxyPath = `/workspaces/${id}/instance`
|
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
||||||
|
|
||||||
|
|
||||||
const descriptor: WorkspaceRecord = {
|
const descriptor: WorkspaceRecord = {
|
||||||
@@ -132,6 +134,7 @@ export class WorkspaceManager {
|
|||||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||||
CODENOMAD_INSTANCE_ID: id,
|
CODENOMAD_INSTANCE_ID: id,
|
||||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||||
|
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,12 +116,26 @@ export class WorkspaceRuntime {
|
|||||||
folder: options.folder,
|
folder: options.folder,
|
||||||
binary: options.binaryPath,
|
binary: options.binaryPath,
|
||||||
spawnCommand: spec.command,
|
spawnCommand: spec.command,
|
||||||
spawnArgs: spec.args,
|
|
||||||
commandLine,
|
commandLine,
|
||||||
env: redactEnvironment(env),
|
|
||||||
},
|
},
|
||||||
"Launching OpenCode process",
|
"Launching OpenCode process",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
spawnArgs: spec.args,
|
||||||
|
},
|
||||||
|
"OpenCode spawn args",
|
||||||
|
)
|
||||||
|
|
||||||
|
this.logger.trace(
|
||||||
|
{
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
env: redactEnvironment(env),
|
||||||
|
},
|
||||||
|
"OpenCode spawn environment",
|
||||||
|
)
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
|
|||||||
129
packages/server/src/workspaces/worktree-map.ts
Normal file
129
packages/server/src/workspaces/worktree-map.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import type { WorktreeMap } from "../api-types"
|
||||||
|
import { resolveRepoRoot } from "./git-worktrees"
|
||||||
|
import type { LogLike } from "./git-worktrees"
|
||||||
|
|
||||||
|
const DEFAULT_MAP: WorktreeMap = {
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug: "root",
|
||||||
|
parentSessionWorktreeSlug: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMapPath(repoRoot: string): string {
|
||||||
|
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGitExcludePath(repoRoot: string): string {
|
||||||
|
return path.join(repoRoot, ".git", "info", "exclude")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
|
||||||
|
const excludePath = getGitExcludePath(repoRoot)
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
".codenomad/worktrees/",
|
||||||
|
".codenomad/worktreeMap.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
let existing = ""
|
||||||
|
try {
|
||||||
|
existing = await fsp.readFile(excludePath, "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code !== "ENOENT") {
|
||||||
|
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
|
||||||
|
const missing = entries.filter((e) => !lines.has(e))
|
||||||
|
if (missing.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
|
||||||
|
const suffix = missing.map((e) => `${e}\n`).join("")
|
||||||
|
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ensureGitExclude(repoRoot, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||||
|
const filePath = getMapPath(repoRoot)
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(filePath, "utf-8")
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
const version = (parsed as any).version
|
||||||
|
if (version !== 1) {
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
|
||||||
|
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
|
||||||
|
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug,
|
||||||
|
parentSessionWorktreeSlug: { ...mapping },
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
if (isGitRepo) {
|
||||||
|
// Best-effort ignore setup on first use.
|
||||||
|
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||||
|
}
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
|
||||||
|
return DEFAULT_MAP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
|
||||||
|
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||||
|
const filePath = getMapPath(repoRoot)
|
||||||
|
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
|
||||||
|
// Ensure ignore rules are present (local-only).
|
||||||
|
if (isGitRepo) {
|
||||||
|
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: WorktreeMap = {
|
||||||
|
version: 1,
|
||||||
|
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
|
||||||
|
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write atomically.
|
||||||
|
const tmpPath = `${filePath}.${process.pid}.tmp`
|
||||||
|
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
|
||||||
|
await fsp.rename(tmpPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worktreeMapExists(repoRoot: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(getMapPath(repoRoot))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
836
packages/tauri-app/Cargo.lock
generated
836
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { execSync } = require("child_process")
|
const { execSync } = require("child_process")
|
||||||
|
const { pathToFileURL } = require("url")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..")
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..")
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
|
|||||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||||
|
|
||||||
|
async function ensureMonacoAssets() {
|
||||||
|
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||||
|
const helperUrl = pathToFileURL(helperPath).href
|
||||||
|
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||||
|
warn: (msg) => console.warn(`[dev-prep] ${msg}`),
|
||||||
|
sourceRoots: [
|
||||||
|
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUiBuild() {
|
function ensureUiBuild() {
|
||||||
const loadingHtml = path.join(uiDist, "loading.html")
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
if (fs.existsSync(loadingHtml)) {
|
if (fs.existsSync(loadingHtml)) {
|
||||||
@@ -42,5 +57,11 @@ function copyUiLoadingAssets() {
|
|||||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
await ensureMonacoAssets()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("[dev-prep] failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { execSync } = require("child_process")
|
const { execSync } = require("child_process")
|
||||||
|
const { pathToFileURL } = require("url")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..")
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..")
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
@@ -37,6 +38,20 @@ const braceExpansionPath = path.join(
|
|||||||
|
|
||||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||||
|
|
||||||
|
async function ensureMonacoAssets() {
|
||||||
|
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||||
|
const helperUrl = pathToFileURL(helperPath).href
|
||||||
|
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||||
|
warn: (msg) => console.warn(`[prebuild] ${msg}`),
|
||||||
|
sourceRoots: [
|
||||||
|
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureServerBuild() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist")
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public")
|
const publicPath = path.join(serverRoot, "public")
|
||||||
@@ -223,8 +238,10 @@ function copyUiLoadingAssets() {
|
|||||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
ensureServerDevDependencies()
|
ensureServerDevDependencies()
|
||||||
ensureUiDevDependencies()
|
ensureUiDevDependencies()
|
||||||
|
await ensureMonacoAssets()
|
||||||
ensureRollupPlatformBinary()
|
ensureRollupPlatformBinary()
|
||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
@@ -232,3 +249,7 @@ ensureUiBuild()
|
|||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("[prebuild] failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,3 +22,5 @@ tauri-plugin-dialog = "2"
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
tauri-plugin-keepawake = "0.1.1"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-show",
|
||||||
"core:webview:allow-set-webview-zoom"
|
"core:webview:allow-set-webview-zoom"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2378,6 +2378,234 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-batch",
|
||||||
|
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-cancel",
|
||||||
|
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-check-permissions",
|
||||||
|
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-create-channel",
|
||||||
|
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-delete-channel",
|
||||||
|
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-active",
|
||||||
|
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-pending",
|
||||||
|
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-is-permission-granted",
|
||||||
|
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-list-channels",
|
||||||
|
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-notify",
|
||||||
|
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-permission-state",
|
||||||
|
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-action-types",
|
||||||
|
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-listener",
|
||||||
|
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-remove-active",
|
||||||
|
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-request-permission",
|
||||||
|
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-show",
|
||||||
|
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-batch",
|
||||||
|
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-cancel",
|
||||||
|
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-check-permissions",
|
||||||
|
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-create-channel",
|
||||||
|
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-delete-channel",
|
||||||
|
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-active",
|
||||||
|
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-pending",
|
||||||
|
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-is-permission-granted",
|
||||||
|
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-list-channels",
|
||||||
|
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-notify",
|
||||||
|
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-permission-state",
|
||||||
|
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-action-types",
|
||||||
|
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-listener",
|
||||||
|
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-remove-active",
|
||||||
|
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-request-permission",
|
||||||
|
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-show",
|
||||||
|
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2378,6 +2378,234 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-batch",
|
||||||
|
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-cancel",
|
||||||
|
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-check-permissions",
|
||||||
|
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-create-channel",
|
||||||
|
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-delete-channel",
|
||||||
|
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-active",
|
||||||
|
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-pending",
|
||||||
|
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-is-permission-granted",
|
||||||
|
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-list-channels",
|
||||||
|
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-notify",
|
||||||
|
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-permission-state",
|
||||||
|
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-action-types",
|
||||||
|
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-listener",
|
||||||
|
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-remove-active",
|
||||||
|
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-request-permission",
|
||||||
|
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-show",
|
||||||
|
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-batch",
|
||||||
|
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-cancel",
|
||||||
|
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-check-permissions",
|
||||||
|
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-create-channel",
|
||||||
|
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-delete-channel",
|
||||||
|
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-active",
|
||||||
|
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-pending",
|
||||||
|
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-is-permission-granted",
|
||||||
|
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-list-channels",
|
||||||
|
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-notify",
|
||||||
|
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-permission-state",
|
||||||
|
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-action-types",
|
||||||
|
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-listener",
|
||||||
|
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-remove-active",
|
||||||
|
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-request-permission",
|
||||||
|
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-show",
|
||||||
|
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ impl CliProcessManager {
|
|||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
@@ -559,12 +559,12 @@ impl CliProcessManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(port) = port_regex
|
if let Some(url) = local_url_regex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.map(|m| m.as_str().to_string())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,13 +574,13 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
|
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,12 +597,15 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
port: u16,
|
base_url: String,
|
||||||
) {
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
let base_url = format!("http://127.0.0.1:{port}");
|
let port = Url::parse(&base_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.port_or_known_default())
|
||||||
|
.map(|p| p as u16);
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
locked.port = Some(port);
|
locked.port = port;
|
||||||
locked.url = Some(base_url.clone());
|
locked.url = Some(base_url.clone());
|
||||||
locked.state = CliState::Ready;
|
locked.state = CliState::Ready;
|
||||||
locked.error = None;
|
locked.error = None;
|
||||||
@@ -611,6 +614,12 @@ impl CliProcessManager {
|
|||||||
let token = bootstrap_token.lock().take();
|
let token = bootstrap_token.lock().take();
|
||||||
|
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
|
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
||||||
|
// skip the exchange and let the user authenticate normally.
|
||||||
|
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
||||||
|
if scheme.as_deref() != Some("http") {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
} else {
|
||||||
match exchange_bootstrap_token(&base_url, &token) {
|
match exchange_bootstrap_token(&base_url, &token) {
|
||||||
Ok(Some(session_id)) => {
|
Ok(Some(session_id)) => {
|
||||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||||
@@ -629,6 +638,7 @@ impl CliProcessManager {
|
|||||||
navigate_main(app, &format!("{base_url}/login"));
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navigate_main(app, &base_url);
|
navigate_main(app, &base_url);
|
||||||
}
|
}
|
||||||
@@ -709,19 +719,24 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
|
||||||
"serve".to_string(),
|
|
||||||
"--host".to_string(),
|
|
||||||
host.to_string(),
|
|
||||||
"--port".to_string(),
|
|
||||||
"0".to_string(),
|
|
||||||
"--generate-token".to_string(),
|
|
||||||
];
|
|
||||||
if dev {
|
if dev {
|
||||||
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
|
args.push("--https".to_string());
|
||||||
|
args.push("false".to_string());
|
||||||
|
args.push("--http".to_string());
|
||||||
|
args.push("true".to_string());
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
args.push("http://localhost:3000".to_string());
|
args.push("http://localhost:3000".to_string());
|
||||||
args.push("--log-level".to_string());
|
args.push("--log-level".to_string());
|
||||||
args.push("debug".to_string());
|
args.push("debug".to_string());
|
||||||
|
} else {
|
||||||
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
|
args.push("--https".to_string());
|
||||||
|
args.push("true".to_string());
|
||||||
|
args.push("--http".to_string());
|
||||||
|
args.push("true".to_string());
|
||||||
}
|
}
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,8 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_keepawake::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
|
|||||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.vite/
|
.vite/
|
||||||
src/renderer/public/logo.png
|
src/renderer/public/logo.png
|
||||||
|
src/renderer/public/monaco/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.9.5",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -19,15 +19,18 @@
|
|||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0",
|
||||||
|
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type CopyMonacoPublicAssetsParams = {
|
||||||
|
uiRendererRoot: string
|
||||||
|
warn?: (message: string) => void
|
||||||
|
sourceRoots?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void
|
||||||
97
packages/ui/scripts/monaco-public-assets.js
Normal file
97
packages/ui/scripts/monaco-public-assets.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy Monaco's AMD `min/vs` assets into the UI renderer public folder.
|
||||||
|
*
|
||||||
|
* Monaco is loaded at runtime via `/monaco/vs/loader.js`. These assets are gitignored
|
||||||
|
* and generated on demand in dev/build so the repo stays clean.
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.uiRendererRoot Absolute path to `packages/ui/src/renderer`.
|
||||||
|
* @param {(message: string) => void} [params.warn] Warning logger.
|
||||||
|
* @param {string[]} [params.sourceRoots] Optional override list of `.../monaco-editor/min/vs` roots.
|
||||||
|
*/
|
||||||
|
export function copyMonacoPublicAssets(params) {
|
||||||
|
const uiRendererRoot = params?.uiRendererRoot
|
||||||
|
if (!uiRendererRoot) {
|
||||||
|
throw new Error("copyMonacoPublicAssets: uiRendererRoot is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = params?.warn ?? ((message) => console.warn(message))
|
||||||
|
const publicDir = resolve(uiRendererRoot, "public")
|
||||||
|
const destRoot = resolve(publicDir, "monaco/vs")
|
||||||
|
|
||||||
|
const candidates =
|
||||||
|
params?.sourceRoots?.length > 0
|
||||||
|
? params.sourceRoots
|
||||||
|
: [
|
||||||
|
// Workspace root hoisted deps.
|
||||||
|
resolve(process.cwd(), "node_modules/monaco-editor/min/vs"),
|
||||||
|
// UI package local deps (covers non-hoisted installs).
|
||||||
|
resolve(process.cwd(), "packages/ui/node_modules/monaco-editor/min/vs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js")))
|
||||||
|
if (!sourceRoot) {
|
||||||
|
warn("Monaco source directory not found; skipping copy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRecursive = (src, dest) => {
|
||||||
|
const stat = fs.statSync(src)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true })
|
||||||
|
for (const entry of fs.readdirSync(src)) {
|
||||||
|
copyRecursive(resolve(src, entry), resolve(dest, entry))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the working tree clean; these assets are generated.
|
||||||
|
try {
|
||||||
|
fs.rmSync(destRoot, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
fs.mkdirSync(destRoot, { recursive: true })
|
||||||
|
|
||||||
|
// Copy core Monaco runtime.
|
||||||
|
for (const dir of ["base", "editor", "platform"]) {
|
||||||
|
const src = resolve(sourceRoot, dir)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loader.js is required.
|
||||||
|
copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js"))
|
||||||
|
|
||||||
|
// Copy baseline rich language packages + workers.
|
||||||
|
for (const lang of ["typescript", "html", "json", "css"]) {
|
||||||
|
const src = resolve(sourceRoot, "language", lang)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, "language", lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy baseline basic tokenizers.
|
||||||
|
for (const lang of ["python", "markdown", "cpp", "kotlin"]) {
|
||||||
|
const src = resolve(sourceRoot, "basic-languages", lang)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, "basic-languages", lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy monaco.contribution.js entrypoints (needed by some loads).
|
||||||
|
const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
|
||||||
|
if (fs.existsSync(monacoContribution)) {
|
||||||
|
copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js"))
|
||||||
|
}
|
||||||
|
const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js")
|
||||||
|
if (fs.existsSync(underscoreContribution)) {
|
||||||
|
copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { getLogger } from "./lib/logger"
|
|||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
@@ -60,6 +63,7 @@ const App: Component = () => {
|
|||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -90,6 +94,26 @@ const App: Component = () => {
|
|||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shouldHoldWakeLock = createMemo(() => {
|
||||||
|
const map = instances()
|
||||||
|
for (const id of map.keys()) {
|
||||||
|
const status = getInstanceSessionIndicatorStatus(id)
|
||||||
|
if (status !== "idle") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const hold = shouldHoldWakeLock()
|
||||||
|
void setWakeLockDesired(hold)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
void setWakeLockDesired(false)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
instances()
|
instances()
|
||||||
hasInstances()
|
hasInstances()
|
||||||
@@ -271,6 +295,7 @@ const App: Component = () => {
|
|||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -330,7 +355,7 @@ const App: Component = () => {
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
@@ -338,17 +363,19 @@ const App: Component = () => {
|
|||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={launchErrorMessage()}>
|
<Show when={launchErrorMessage()}>
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
|
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Show when={launchError()?.missingBinary}>
|
<Show when={launchError()?.missingBinary}>
|
||||||
|
|||||||
116
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
116
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { loadMonaco } from "../../lib/monaco/setup"
|
||||||
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
|
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||||
|
import { useTheme } from "../../lib/theme"
|
||||||
|
|
||||||
|
interface MonacoDiffViewerProps {
|
||||||
|
scopeKey: string
|
||||||
|
path: string
|
||||||
|
before: string
|
||||||
|
after: string
|
||||||
|
viewMode?: "split" | "unified"
|
||||||
|
contextMode?: "expanded" | "collapsed"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
let host: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
let diffEditor: any = null
|
||||||
|
let monaco: any = null
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
|
||||||
|
const disposeEditor = () => {
|
||||||
|
try {
|
||||||
|
diffEditor?.setModel(null as any)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
diffEditor?.dispose()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
diffEditor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
monaco = await loadMonaco()
|
||||||
|
if (cancelled) return
|
||||||
|
if (!host || !monaco) return
|
||||||
|
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
diffEditor = monaco.editor.createDiffEditor(host, {
|
||||||
|
readOnly: true,
|
||||||
|
automaticLayout: true,
|
||||||
|
renderSideBySide: true,
|
||||||
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
renderMarginRevertIcon: false,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
fontSize: 13,
|
||||||
|
wordWrap: "off",
|
||||||
|
glyphMargin: false,
|
||||||
|
folding: false,
|
||||||
|
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||||
|
lineNumbersMinChars: 4,
|
||||||
|
lineDecorationsWidth: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
setReady(false)
|
||||||
|
disposeEditor()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
|
|
||||||
|
diffEditor.updateOptions({
|
||||||
|
renderSideBySide: viewMode === "split",
|
||||||
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
hideUnchangedRegions:
|
||||||
|
contextMode === "collapsed"
|
||||||
|
? { enabled: true }
|
||||||
|
: { enabled: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||||
|
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||||
|
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||||
|
|
||||||
|
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
||||||
|
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
||||||
|
diffEditor.setModel({ original, modified })
|
||||||
|
|
||||||
|
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||||
|
try {
|
||||||
|
monaco.editor.setModelLanguage(original, languageId)
|
||||||
|
monaco.editor.setModelLanguage(modified, languageId)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div class="monaco-viewer" ref={host} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { loadMonaco } from "../../lib/monaco/setup"
|
||||||
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
|
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||||
|
import { useTheme } from "../../lib/theme"
|
||||||
|
|
||||||
|
interface MonacoFileViewerProps {
|
||||||
|
scopeKey: string
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
let host: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
let editor: any = null
|
||||||
|
let monaco: any = null
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
|
||||||
|
const disposeEditor = () => {
|
||||||
|
try {
|
||||||
|
editor?.setModel(null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
editor?.dispose()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
editor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
monaco = await loadMonaco()
|
||||||
|
if (cancelled) return
|
||||||
|
if (!host || !monaco) return
|
||||||
|
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
editor = monaco.editor.create(host, {
|
||||||
|
value: "",
|
||||||
|
language: "plaintext",
|
||||||
|
readOnly: true,
|
||||||
|
automaticLayout: true,
|
||||||
|
lineNumbers: "on",
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: "off",
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
fontSize: 13,
|
||||||
|
})
|
||||||
|
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
setReady(false)
|
||||||
|
disposeEditor()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !editor) return
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !editor) return
|
||||||
|
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||||
|
const cacheKey = `${props.scopeKey}:file:${props.path}`
|
||||||
|
const model = getOrCreateTextModel({ monaco, cacheKey, value: props.content, languageId })
|
||||||
|
editor.setModel(model)
|
||||||
|
|
||||||
|
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||||
|
try {
|
||||||
|
monaco.editor.setModelLanguage(model, languageId)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div class="monaco-viewer" ref={host} />
|
||||||
|
}
|
||||||
@@ -254,12 +254,63 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
function getDisplayPath(path: string): string {
|
function getDisplayPath(path: string): string {
|
||||||
|
if (!path) return path
|
||||||
|
|
||||||
|
// macOS: /Users/<name>/...
|
||||||
if (path.startsWith("/Users/")) {
|
if (path.startsWith("/Users/")) {
|
||||||
return path.replace(/^\/Users\/[^/]+/, "~")
|
return path.replace(/^\/Users\/[^/]+/, "~")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux: /home/<name>/...
|
||||||
|
if (path.startsWith("/home/")) {
|
||||||
|
return path.replace(/^\/home\/[^/]+/, "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: C:\Users\<name>\... (and the forward-slash variant)
|
||||||
|
if (/^[A-Za-z]:\\Users\\/.test(path)) {
|
||||||
|
return path.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~")
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z]:\/Users\//.test(path)) {
|
||||||
|
return path.replace(/^[A-Za-z]:\/Users\/[^/]+/, "~")
|
||||||
|
}
|
||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeWindowsPath(value: string): boolean {
|
||||||
|
if (!value) return false
|
||||||
|
// Drive letter (C:\...) or UNC (\\server\share\...)
|
||||||
|
return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFolderPath(rawPath: string): { baseName: string; dirName: string } {
|
||||||
|
if (!rawPath) return { baseName: "", dirName: "" }
|
||||||
|
|
||||||
|
const isWindows = looksLikeWindowsPath(rawPath)
|
||||||
|
const trimmed = rawPath.replace(/[\\/]+$/, "")
|
||||||
|
|
||||||
|
// Root edge-cases ("/", "C:\\", "\\\\server\\share\\")
|
||||||
|
if (!trimmed) {
|
||||||
|
return { baseName: rawPath, dirName: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWindows && /^[A-Za-z]:$/.test(trimmed)) {
|
||||||
|
return { baseName: `${trimmed}\\`, dirName: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSlash = trimmed.lastIndexOf("/")
|
||||||
|
const lastBackslash = isWindows ? trimmed.lastIndexOf("\\") : -1
|
||||||
|
const lastSep = Math.max(lastSlash, lastBackslash)
|
||||||
|
|
||||||
|
if (lastSep < 0) {
|
||||||
|
return { baseName: trimmed, dirName: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = trimmed.slice(lastSep + 1) || trimmed
|
||||||
|
const dirName = trimmed.slice(0, lastSep)
|
||||||
|
return { baseName, dirName }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -441,14 +492,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
<span class="text-sm font-medium truncate text-primary">
|
<span class="text-sm font-medium truncate text-primary">
|
||||||
{folder.path.split("/").pop()}
|
{splitFolderPath(folder.path).baseName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||||
|
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||||
{getDisplayPath(folder.path)}
|
{getDisplayPath(folder.path)}
|
||||||
</div>
|
</span>
|
||||||
<div class="text-xs mt-1 pl-6 text-muted">
|
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||||
{formatRelativeTime(folder.lastAccessed)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||||
|
|||||||
@@ -11,20 +11,11 @@ interface InstanceTabProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
|
function getPathBasename(path: string): string {
|
||||||
const name = path.split("/").pop() || path
|
// Instance folders can be POSIX-like (/Users/...) on macOS/Linux or Windows-like (C:\Users\...).
|
||||||
|
// Normalize by trimming trailing separators and then splitting on both '/' and '\\'.
|
||||||
const duplicates = instances.filter((i) => {
|
const normalized = path.replace(/[\\/]+$/, "")
|
||||||
const iName = i.folder.split("/").pop() || i.folder
|
return normalized.split(/[\\/]/).pop() || path
|
||||||
return iName === name
|
|
||||||
})
|
|
||||||
|
|
||||||
if (duplicates.length > 1) {
|
|
||||||
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
|
|
||||||
return `~/${name} (${index + 1})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `~/${name}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||||
@@ -58,7 +49,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
||||||
<span class="tab-label">
|
<span class="tab-label">
|
||||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
{getPathBasename(props.instance.folder)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
|
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||||
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -18,6 +22,21 @@ interface InstanceTabsProps {
|
|||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { preferences } = useConfig()
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||||
|
|
||||||
|
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||||
|
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||||
|
const notificationIcon = createMemo(() => {
|
||||||
|
if (!notificationsSupported()) return BellOff
|
||||||
|
return notificationsEnabled() ? Bell : BellOff
|
||||||
|
})
|
||||||
|
|
||||||
|
const notificationTitle = createMemo(() => {
|
||||||
|
if (!notificationsSupported()) return "Notifications unsupported"
|
||||||
|
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tab-bar tab-bar-instance">
|
<div class="tab-bar tab-bar-instance">
|
||||||
<div class="tab-container" role="tablist">
|
<div class="tab-container" role="tablist">
|
||||||
@@ -54,6 +73,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<ThemeModeToggle class="new-tab-button" />
|
<ThemeModeToggle class="new-tab-button" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||||
|
onClick={() => setNotificationsOpen(true)}
|
||||||
|
title={notificationTitle()}
|
||||||
|
aria-label={notificationTitle()}
|
||||||
|
>
|
||||||
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button tab-remote-button"
|
class="new-tab-button tab-remote-button"
|
||||||
@@ -67,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
182
packages/ui/src/components/instance/shell/SessionSidebar.tsx
Normal file
182
packages/ui/src/components/instance/shell/SessionSidebar.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Show, type Accessor, type Component } from "solid-js"
|
||||||
|
import type { SessionThread } from "../../../stores/session-state"
|
||||||
|
import type { Session } from "../../../types/session"
|
||||||
|
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||||
|
import type { DrawerViewState } from "./types"
|
||||||
|
|
||||||
|
import { Search, SquarePlus } from "lucide-solid"
|
||||||
|
import IconButton from "@suid/material/IconButton"
|
||||||
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
|
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||||
|
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||||
|
|
||||||
|
import SessionList from "../../session-list"
|
||||||
|
import KeyboardHint from "../../keyboard-hint"
|
||||||
|
import Kbd from "../../kbd"
|
||||||
|
import WorktreeSelector from "../../worktree-selector"
|
||||||
|
import AgentSelector from "../../agent-selector"
|
||||||
|
import ModelSelector from "../../model-selector"
|
||||||
|
import ThinkingSelector from "../../thinking-selector"
|
||||||
|
import { getLogger } from "../../../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
interface SessionSidebarProps {
|
||||||
|
t: (key: string) => string
|
||||||
|
instanceId: string
|
||||||
|
threads: Accessor<SessionThread[]>
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSession: Accessor<Session | null>
|
||||||
|
|
||||||
|
showSearch: Accessor<boolean>
|
||||||
|
onToggleSearch: () => void
|
||||||
|
|
||||||
|
keyboardShortcuts: Accessor<KeyboardShortcut[]>
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
drawerState: Accessor<DrawerViewState>
|
||||||
|
leftPinned: Accessor<boolean>
|
||||||
|
|
||||||
|
onSelectSession: (sessionId: string) => void
|
||||||
|
onNewSession: () => Promise<void> | void
|
||||||
|
onSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||||
|
onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
|
onPinLeftDrawer: () => void
|
||||||
|
onUnpinLeftDrawer: () => void
|
||||||
|
onCloseLeftDrawer: () => void
|
||||||
|
|
||||||
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||||
|
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||||
|
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||||
|
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2 text-primary">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||||
|
title={props.t("sessionList.actions.newSession.title")}
|
||||||
|
onClick={() => {
|
||||||
|
const result = props.onNewSession()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquarePlus class="w-4 h-4 opacity-70" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||||
|
title={props.t("sessionList.filter.ariaLabel")}
|
||||||
|
aria-pressed={props.showSearch()}
|
||||||
|
onClick={props.onToggleSearch}
|
||||||
|
sx={{
|
||||||
|
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||||
|
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "var(--surface-hover)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||||
|
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||||
|
onClick={() => props.onSelectSession("info")}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Show when={!props.isPhoneLayout()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||||
|
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||||
|
>
|
||||||
|
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.drawerState() === "floating-open"}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||||
|
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||||
|
onClick={props.onCloseLeftDrawer}
|
||||||
|
>
|
||||||
|
<MenuOpenIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-sidebar-shortcuts">
|
||||||
|
<Show when={props.keyboardShortcuts().length}>
|
||||||
|
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||||
|
<SessionList
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
threads={props.threads()}
|
||||||
|
activeSessionId={props.activeSessionId()}
|
||||||
|
onSelect={props.onSelectSession}
|
||||||
|
onNew={() => {
|
||||||
|
const result = props.onNewSession()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
enableFilterBar={props.showSearch()}
|
||||||
|
showHeader={false}
|
||||||
|
showFooter={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="session-sidebar-separator" />
|
||||||
|
<Show when={props.activeSession()}>
|
||||||
|
{(activeSession) => (
|
||||||
|
<>
|
||||||
|
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||||
|
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||||
|
|
||||||
|
<AgentSelector
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={activeSession().id}
|
||||||
|
currentAgent={activeSession().agent}
|
||||||
|
onAgentChange={(agent) => props.onSidebarAgentChange(activeSession().id, agent)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={activeSession().id}
|
||||||
|
currentModel={activeSession().model}
|
||||||
|
onModelChange={(model) => props.onSidebarModelChange(activeSession().id, model)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||||
|
|
||||||
|
<div class="session-sidebar-selector-hints" aria-hidden="true">
|
||||||
|
<Kbd shortcut="cmd+shift+a" />
|
||||||
|
<Kbd shortcut="cmd+shift+m" />
|
||||||
|
<Kbd shortcut="cmd+shift+t" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SessionSidebar
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
import {
|
||||||
|
Show,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
type Accessor,
|
||||||
|
type Component,
|
||||||
|
} from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
|
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import IconButton from "@suid/material/IconButton"
|
||||||
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
|
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||||
|
|
||||||
|
import type { Instance } from "../../../../types/instance"
|
||||||
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
|
import type { Session } from "../../../../types/session"
|
||||||
|
import type { DrawerViewState } from "../types"
|
||||||
|
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
|
||||||
|
|
||||||
|
import ChangesTab from "./tabs/ChangesTab"
|
||||||
|
import FilesTab from "./tabs/FilesTab"
|
||||||
|
import GitChangesTab from "./tabs/GitChangesTab"
|
||||||
|
import StatusTab from "./tabs/StatusTab"
|
||||||
|
|
||||||
|
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||||
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
|
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||||
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
|
import {
|
||||||
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
|
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||||
|
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||||
|
readStoredBool,
|
||||||
|
readStoredEnum,
|
||||||
|
readStoredPanelWidth,
|
||||||
|
readStoredRightPanelTab,
|
||||||
|
} from "../storage"
|
||||||
|
|
||||||
|
interface RightPanelProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
instance: Instance
|
||||||
|
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSession: Accessor<Session | null>
|
||||||
|
activeSessionDiffs: Accessor<any[] | undefined>
|
||||||
|
|
||||||
|
latestTodoState: Accessor<ToolState | null>
|
||||||
|
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||||
|
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||||
|
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
rightDrawerWidth: Accessor<number>
|
||||||
|
rightDrawerWidthInitialized: Accessor<boolean>
|
||||||
|
rightDrawerState: Accessor<DrawerViewState>
|
||||||
|
rightPinned: Accessor<boolean>
|
||||||
|
onCloseRightDrawer: () => void
|
||||||
|
onPinRightDrawer: () => void
|
||||||
|
onUnpinRightDrawer: () => void
|
||||||
|
|
||||||
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||||
|
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||||
|
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||||
|
"plan",
|
||||||
|
"background-processes",
|
||||||
|
"mcp",
|
||||||
|
"lsp",
|
||||||
|
"plugins",
|
||||||
|
])
|
||||||
|
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const [browserPath, setBrowserPath] = createSignal(".")
|
||||||
|
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
|
||||||
|
const [browserLoading, setBrowserLoading] = createSignal(false)
|
||||||
|
const [browserError, setBrowserError] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||||
|
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||||
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||||
|
)
|
||||||
|
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||||
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||||
|
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||||
|
const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
|
||||||
|
const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null)
|
||||||
|
const [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
|
||||||
|
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
|
||||||
|
|
||||||
|
const [filesListOpen, setFilesListOpen] = createSignal(true)
|
||||||
|
const [filesListTouched, setFilesListTouched] = createSignal(false)
|
||||||
|
const [changesListOpen, setChangesListOpen] = createSignal(true)
|
||||||
|
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||||
|
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||||
|
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||||
|
|
||||||
|
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
||||||
|
|
||||||
|
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
|
||||||
|
const layout = listLayoutKey()
|
||||||
|
if (tab === "changes") {
|
||||||
|
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
if (tab === "git-changes") {
|
||||||
|
return layout === "phone"
|
||||||
|
? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY
|
||||||
|
: RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||||
|
const layout = listLayoutKey()
|
||||||
|
layout
|
||||||
|
|
||||||
|
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
|
||||||
|
if (filesPersisted !== null) {
|
||||||
|
setFilesListOpen(filesPersisted)
|
||||||
|
setFilesListTouched(true)
|
||||||
|
} else {
|
||||||
|
setFilesListOpen(true)
|
||||||
|
setFilesListTouched(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
|
||||||
|
if (changesPersisted !== null) {
|
||||||
|
setChangesListOpen(changesPersisted)
|
||||||
|
setChangesListTouched(true)
|
||||||
|
} else {
|
||||||
|
setChangesListOpen(true)
|
||||||
|
setChangesListTouched(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
|
||||||
|
if (gitPersisted !== null) {
|
||||||
|
setGitChangesListOpen(gitPersisted)
|
||||||
|
setGitChangesListTouched(true)
|
||||||
|
} else {
|
||||||
|
setGitChangesListOpen(true)
|
||||||
|
setGitChangesListTouched(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Default behavior: when nothing is selected, keep the file list open.
|
||||||
|
// Once the user explicitly toggles it, we stop auto-opening.
|
||||||
|
if (rightPanelTab() !== "files") return
|
||||||
|
if (filesListTouched()) return
|
||||||
|
if (!browserSelectedPath()) {
|
||||||
|
setFilesListOpen(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
||||||
|
})
|
||||||
|
|
||||||
|
const clampSplitWidth = (value: number) => {
|
||||||
|
const min = 200
|
||||||
|
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||||
|
const max = Math.min(560, maxByDrawer)
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (splitWidthsInitialized()) return
|
||||||
|
if (!props.rightDrawerWidthInitialized()) return
|
||||||
|
setSplitWidthsInitialized(true)
|
||||||
|
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||||
|
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
|
||||||
|
setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const key =
|
||||||
|
mode === "changes"
|
||||||
|
? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY
|
||||||
|
: mode === "git-changes"
|
||||||
|
? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY
|
||||||
|
: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
|
||||||
|
window.localStorage.setItem(key, String(width))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSplitResize() {
|
||||||
|
setActiveSplitResize(null)
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
splitPointerDrag.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMouseMove(event: MouseEvent) {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (!mode) return
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.clientX - splitResizeStartX()
|
||||||
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
|
else setFilesSplitWidth(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMouseUp() {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (mode) {
|
||||||
|
const width =
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||||
|
persistSplitWidth(mode, width)
|
||||||
|
}
|
||||||
|
stopSplitResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTouchMove(event: TouchEvent) {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (!mode) return
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = touch.clientX - splitResizeStartX()
|
||||||
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
|
else setFilesSplitWidth(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTouchEnd() {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (mode) {
|
||||||
|
const width =
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||||
|
persistSplitWidth(mode, width)
|
||||||
|
}
|
||||||
|
stopSplitResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPointerDrag = useGlobalPointerDrag({
|
||||||
|
onMouseMove: splitMouseMove,
|
||||||
|
onMouseUp: splitMouseUp,
|
||||||
|
onTouchMove: splitTouchMove,
|
||||||
|
onTouchEnd: splitTouchEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
setActiveSplitResize(mode)
|
||||||
|
setSplitResizeStartX(clientX)
|
||||||
|
setSplitResizeStartWidth(
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
|
||||||
|
)
|
||||||
|
splitPointerDrag.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
startSplitResize(mode, event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
startSplitResize(mode, touch.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stopSplitResize()
|
||||||
|
})
|
||||||
|
|
||||||
|
const worktreeSlugForViewer = createMemo(() => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
if (sessionId && sessionId !== "info") {
|
||||||
|
return getWorktreeSlugForSession(props.instanceId, sessionId)
|
||||||
|
}
|
||||||
|
return getDefaultWorktreeSlug(props.instanceId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||||
|
|
||||||
|
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
||||||
|
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||||
|
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||||
|
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const gitMostChangedPath = createMemo<string | null>(() => {
|
||||||
|
const entries = gitStatusEntries()
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) return null
|
||||||
|
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
const best = candidates.reduce((currentBest, item) => {
|
||||||
|
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
||||||
|
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return currentBest
|
||||||
|
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
||||||
|
}, candidates[0])
|
||||||
|
return typeof best?.path === "string" ? best.path : null
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Reset tab state when worktree context changes.
|
||||||
|
worktreeSlugForViewer()
|
||||||
|
setBrowserPath(".")
|
||||||
|
setBrowserEntries(null)
|
||||||
|
setBrowserError(null)
|
||||||
|
setBrowserSelectedPath(null)
|
||||||
|
setBrowserSelectedContent(null)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
|
||||||
|
setGitStatusEntries(null)
|
||||||
|
setGitStatusError(null)
|
||||||
|
setGitStatusLoading(false)
|
||||||
|
setGitSelectedPath(null)
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
setGitSelectedError(null)
|
||||||
|
setGitSelectedBefore(null)
|
||||||
|
setGitSelectedAfter(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadGitStatus = async (force = false) => {
|
||||||
|
if (!force && gitStatusEntries() !== null) return
|
||||||
|
setGitStatusLoading(true)
|
||||||
|
setGitStatusError(null)
|
||||||
|
try {
|
||||||
|
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
|
||||||
|
setGitStatusEntries(Array.isArray(list) ? list : [])
|
||||||
|
} catch (error) {
|
||||||
|
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
|
||||||
|
setGitStatusEntries([])
|
||||||
|
} finally {
|
||||||
|
setGitStatusLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGitFile(path: string) {
|
||||||
|
setGitSelectedPath(path)
|
||||||
|
setGitSelectedLoading(true)
|
||||||
|
setGitSelectedError(null)
|
||||||
|
setGitSelectedBefore(null)
|
||||||
|
setGitSelectedAfter(null)
|
||||||
|
|
||||||
|
const list = gitStatusEntries() || []
|
||||||
|
const entry = list.find((item) => item.path === path) || null
|
||||||
|
if (entry?.status === "deleted") {
|
||||||
|
setGitSelectedError("Deleted file diff is not available yet")
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone: treat file selection as a commit action and close the overlay.
|
||||||
|
if (props.isPhoneLayout()) {
|
||||||
|
setGitChangesListOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||||
|
const type = (content as any)?.type
|
||||||
|
const encoding = (content as any)?.encoding
|
||||||
|
if (type && type !== "text") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
if (encoding === "base64") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
|
||||||
|
if (afterText === null) {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
|
||||||
|
setGitSelectedAfter(afterText)
|
||||||
|
|
||||||
|
if (entry?.status === "added") {
|
||||||
|
setGitSelectedBefore("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffText =
|
||||||
|
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
|
||||||
|
? String((content as any).diff)
|
||||||
|
: (content as any)?.patch
|
||||||
|
? buildUnifiedDiffFromSdkPatch((content as any).patch)
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
|
||||||
|
if (beforeText === null) {
|
||||||
|
throw new Error("Unable to calculate diff for this file")
|
||||||
|
}
|
||||||
|
setGitSelectedBefore(beforeText)
|
||||||
|
} catch (error) {
|
||||||
|
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
|
||||||
|
} finally {
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "git-changes") return
|
||||||
|
const entries = gitStatusEntries()
|
||||||
|
if (entries === null) return
|
||||||
|
if (gitSelectedPath()) return
|
||||||
|
const next = gitMostChangedPath()
|
||||||
|
if (!next) return
|
||||||
|
void openGitFile(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshGitStatus = async () => {
|
||||||
|
await loadGitStatus(true)
|
||||||
|
const selected = gitSelectedPath()
|
||||||
|
if (selected) {
|
||||||
|
void openGitFile(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestDiffFile = createMemo<string | null>(() => {
|
||||||
|
const diffs = props.activeSessionDiffs()
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||||
|
const best = diffs.reduce((currentBest, item) => {
|
||||||
|
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
|
||||||
|
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
|
||||||
|
const bestScore = bestAdd + bestDel
|
||||||
|
|
||||||
|
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||||
|
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||||
|
const score = add + del
|
||||||
|
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return currentBest
|
||||||
|
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
|
||||||
|
}, diffs[0])
|
||||||
|
return typeof (best as any)?.file === "string" ? (best as any).file : null
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const next = bestDiffFile()
|
||||||
|
if (!next) return
|
||||||
|
const diffs = props.activeSessionDiffs()
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) return
|
||||||
|
|
||||||
|
const current = selectedFile()
|
||||||
|
if (current && diffs.some((d) => d.file === current)) return
|
||||||
|
setSelectedFile(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeBrowserPath = (input: string) => {
|
||||||
|
const raw = String(input || ".").trim()
|
||||||
|
if (!raw || raw === "./") return "."
|
||||||
|
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||||
|
return cleaned === "" ? "." : cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
const getParentPath = (path: string): string | null => {
|
||||||
|
const current = normalizeBrowserPath(path)
|
||||||
|
if (current === ".") return null
|
||||||
|
const parts = current.split("/").filter(Boolean)
|
||||||
|
parts.pop()
|
||||||
|
return parts.length ? parts.join("/") : "."
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBrowserEntries = async (path: string) => {
|
||||||
|
const normalized = normalizeBrowserPath(path)
|
||||||
|
setBrowserLoading(true)
|
||||||
|
setBrowserError(null)
|
||||||
|
try {
|
||||||
|
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
|
||||||
|
setBrowserPath(normalized)
|
||||||
|
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
|
||||||
|
setBrowserEntries([])
|
||||||
|
} finally {
|
||||||
|
setBrowserLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBrowserFile = async (path: string) => {
|
||||||
|
setBrowserSelectedPath(path)
|
||||||
|
setBrowserSelectedLoading(true)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
setBrowserSelectedContent(null)
|
||||||
|
|
||||||
|
// Phone: treat file selection as a commit action and close the overlay.
|
||||||
|
if (props.isPhoneLayout()) {
|
||||||
|
setFilesListOpen(false)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||||
|
const type = (content as any)?.type
|
||||||
|
const encoding = (content as any)?.encoding
|
||||||
|
if (type && type !== "text") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
if (encoding === "base64") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
const text = (content as any)?.content
|
||||||
|
if (typeof text !== "string") {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
setBrowserSelectedContent(text)
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
|
} finally {
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "files") return
|
||||||
|
if (browserLoading()) return
|
||||||
|
if (browserEntries() !== null) return
|
||||||
|
void loadBrowserEntries(browserPath())
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "git-changes") return
|
||||||
|
if (gitStatusLoading()) return
|
||||||
|
if (gitStatusEntries() !== null) return
|
||||||
|
void loadGitStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
|
setSelectedFile(file)
|
||||||
|
if (closeList) {
|
||||||
|
setChangesListOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChangesList = () => {
|
||||||
|
setChangesListTouched(true)
|
||||||
|
setChangesListOpen((current) => {
|
||||||
|
const next = !current
|
||||||
|
persistListOpen("changes", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFilesList = () => {
|
||||||
|
setFilesListTouched(true)
|
||||||
|
setFilesListOpen((current) => {
|
||||||
|
const next = !current
|
||||||
|
persistListOpen("files", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGitList = () => {
|
||||||
|
setGitChangesListTouched(true)
|
||||||
|
setGitChangesListOpen((current) => {
|
||||||
|
const next = !current
|
||||||
|
persistListOpen("git-changes", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshFilesTab = async () => {
|
||||||
|
void loadBrowserEntries(browserPath())
|
||||||
|
const selected = browserSelectedPath()
|
||||||
|
if (selected) {
|
||||||
|
// Refresh file content without altering overlay state.
|
||||||
|
setBrowserSelectedLoading(true)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
try {
|
||||||
|
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
|
||||||
|
const type = (content as any)?.type
|
||||||
|
const encoding = (content as any)?.encoding
|
||||||
|
if (type && type !== "text") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
if (encoding === "base64") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
const text = (content as any)?.content
|
||||||
|
if (typeof text !== "string") {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
setBrowserSelectedContent(text)
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
|
} finally {
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserParentPath = createMemo(() => getParentPath(browserPath()))
|
||||||
|
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
|
||||||
|
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
|
||||||
|
|
||||||
|
const openChangesTabFromStatus = (file?: string) => {
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file)
|
||||||
|
}
|
||||||
|
setRightPanelTab("changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||||
|
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
|
||||||
|
setRightPanelExpandedItems(statusSectionIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAccordionChange = (values: string[]) => {
|
||||||
|
setRightPanelExpandedItems(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabClass = (tab: RightPanelTab) =>
|
||||||
|
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full" ref={props.setContentEl}>
|
||||||
|
<div class="right-panel-tab-bar">
|
||||||
|
<div class="tab-container">
|
||||||
|
<div class="tab-strip-shortcuts text-primary">
|
||||||
|
<Show when={props.rightDrawerState() === "floating-open"}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||||
|
title={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||||
|
onClick={props.onCloseRightDrawer}
|
||||||
|
>
|
||||||
|
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.isPhoneLayout()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
|
||||||
|
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
|
||||||
|
>
|
||||||
|
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="tab-scroll">
|
||||||
|
<div class="tab-strip">
|
||||||
|
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("changes")}
|
||||||
|
aria-selected={rightPanelTab() === "changes"}
|
||||||
|
onClick={() => setRightPanelTab("changes")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("git-changes")}
|
||||||
|
aria-selected={rightPanelTab() === "git-changes"}
|
||||||
|
onClick={() => setRightPanelTab("git-changes")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("files")}
|
||||||
|
aria-selected={rightPanelTab() === "files"}
|
||||||
|
onClick={() => setRightPanelTab("files")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("status")}
|
||||||
|
aria-selected={rightPanelTab() === "status"}
|
||||||
|
onClick={() => setRightPanelTab("status")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-strip-spacer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<Show when={rightPanelTab() === "changes"}>
|
||||||
|
<ChangesTab
|
||||||
|
t={props.t}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
activeSessionId={props.activeSessionId}
|
||||||
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
onSelectFile={handleSelectChangesFile}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
diffContextMode={diffContextMode}
|
||||||
|
onViewModeChange={setDiffViewMode}
|
||||||
|
onContextModeChange={setDiffContextMode}
|
||||||
|
listOpen={changesListOpen}
|
||||||
|
onToggleList={toggleChangesList}
|
||||||
|
splitWidth={changesSplitWidth}
|
||||||
|
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||||
|
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||||
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={rightPanelTab() === "git-changes"}>
|
||||||
|
<GitChangesTab
|
||||||
|
t={props.t}
|
||||||
|
activeSessionId={props.activeSessionId}
|
||||||
|
entries={gitStatusEntries}
|
||||||
|
statusLoading={gitStatusLoading}
|
||||||
|
statusError={gitStatusError}
|
||||||
|
selectedPath={gitSelectedPath}
|
||||||
|
selectedLoading={gitSelectedLoading}
|
||||||
|
selectedError={gitSelectedError}
|
||||||
|
selectedBefore={gitSelectedBefore}
|
||||||
|
selectedAfter={gitSelectedAfter}
|
||||||
|
mostChangedPath={gitMostChangedPath}
|
||||||
|
scopeKey={gitScopeKey}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
diffContextMode={diffContextMode}
|
||||||
|
onViewModeChange={setDiffViewMode}
|
||||||
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onOpenFile={(path) => void openGitFile(path)}
|
||||||
|
onRefresh={() => void refreshGitStatus()}
|
||||||
|
listOpen={gitChangesListOpen}
|
||||||
|
onToggleList={toggleGitList}
|
||||||
|
splitWidth={gitChangesSplitWidth}
|
||||||
|
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||||
|
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||||
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={rightPanelTab() === "files"}>
|
||||||
|
<FilesTab
|
||||||
|
t={props.t}
|
||||||
|
browserPath={browserPath}
|
||||||
|
browserEntries={browserEntries}
|
||||||
|
browserLoading={browserLoading}
|
||||||
|
browserError={browserError}
|
||||||
|
browserSelectedPath={browserSelectedPath}
|
||||||
|
browserSelectedContent={browserSelectedContent}
|
||||||
|
browserSelectedLoading={browserSelectedLoading}
|
||||||
|
browserSelectedError={browserSelectedError}
|
||||||
|
parentPath={browserParentPath}
|
||||||
|
scopeKey={browserScopeKey}
|
||||||
|
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||||
|
onOpenFile={(path) => void openBrowserFile(path)}
|
||||||
|
onRefresh={() => void refreshFilesTab()}
|
||||||
|
listOpen={filesListOpen}
|
||||||
|
onToggleList={toggleFilesList}
|
||||||
|
splitWidth={filesSplitWidth}
|
||||||
|
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||||
|
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||||
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={rightPanelTab() === "status"}>
|
||||||
|
<StatusTab
|
||||||
|
t={props.t}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
instance={props.instance}
|
||||||
|
activeSessionId={props.activeSessionId}
|
||||||
|
activeSession={props.activeSession}
|
||||||
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
|
latestTodoState={props.latestTodoState}
|
||||||
|
backgroundProcessList={props.backgroundProcessList}
|
||||||
|
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||||
|
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||||
|
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||||
|
expandedItems={rightPanelExpandedItems}
|
||||||
|
onExpandedItemsChange={handleAccordionChange}
|
||||||
|
onOpenChangesTab={openChangesTabFromStatus}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RightPanel
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
|
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||||
|
|
||||||
|
interface DiffToolbarProps {
|
||||||
|
viewMode: DiffViewMode
|
||||||
|
contextMode: DiffContextMode
|
||||||
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="file-viewer-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
||||||
|
aria-pressed={props.viewMode === "split"}
|
||||||
|
onClick={() => props.onViewModeChange("split")}
|
||||||
|
>
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
||||||
|
aria-pressed={props.viewMode === "unified"}
|
||||||
|
onClick={() => props.onViewModeChange("unified")}
|
||||||
|
>
|
||||||
|
Unified
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
||||||
|
aria-pressed={props.contextMode === "collapsed"}
|
||||||
|
onClick={() => props.onContextModeChange("collapsed")}
|
||||||
|
title="Hide unchanged regions"
|
||||||
|
>
|
||||||
|
Collapsed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
|
||||||
|
aria-pressed={props.contextMode === "expanded"}
|
||||||
|
onClick={() => props.onContextModeChange("expanded")}
|
||||||
|
title="Show full file"
|
||||||
|
>
|
||||||
|
Expanded
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiffToolbar
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Component, JSX } from "solid-js"
|
||||||
|
|
||||||
|
interface OverlayListProps {
|
||||||
|
ariaLabel: string
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverlayList: Component<OverlayListProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
|
||||||
|
<div class="file-list-scroll">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverlayList
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Show, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
import OverlayList from "./OverlayList"
|
||||||
|
|
||||||
|
type SplitFilePanelList = {
|
||||||
|
panel: () => JSX.Element
|
||||||
|
overlay: () => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitFilePanelProps {
|
||||||
|
header: JSX.Element
|
||||||
|
list: SplitFilePanelList
|
||||||
|
viewer: JSX.Element
|
||||||
|
|
||||||
|
listOpen: boolean
|
||||||
|
onToggleList: () => void
|
||||||
|
|
||||||
|
splitWidth: number
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
|
||||||
|
isPhoneLayout: boolean
|
||||||
|
overlayAriaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="files-tab-container">
|
||||||
|
<div class="files-tab-header">
|
||||||
|
<div class="files-tab-header-row">
|
||||||
|
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||||
|
{props.listOpen ? "Hide files" : "Show files"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{props.header}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="files-tab-body">
|
||||||
|
<Show
|
||||||
|
when={!props.isPhoneLayout && props.listOpen}
|
||||||
|
fallback={props.viewer}
|
||||||
|
>
|
||||||
|
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
|
||||||
|
<div class="file-list-panel">
|
||||||
|
<div class="file-list-scroll">{props.list.panel()}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="file-split-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize file list"
|
||||||
|
onMouseDown={props.onResizeMouseDown}
|
||||||
|
onTouchStart={props.onResizeTouchStart}
|
||||||
|
/>
|
||||||
|
{props.viewer}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.isPhoneLayout}>
|
||||||
|
<Show when={props.listOpen}>
|
||||||
|
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SplitFilePanel
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||||
|
|
||||||
|
interface ChangesTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSessionDiffs: Accessor<any[] | undefined>
|
||||||
|
|
||||||
|
selectedFile: Accessor<string | null>
|
||||||
|
onSelectFile: (file: string, closeList: boolean) => void
|
||||||
|
|
||||||
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
|
||||||
|
listOpen: Accessor<boolean>
|
||||||
|
onToggleList: () => void
|
||||||
|
splitWidth: Accessor<number>
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||||
|
const renderContent = (): JSX.Element => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
|
||||||
|
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||||
|
const diffs = hasSession ? props.activeSessionDiffs() : null
|
||||||
|
|
||||||
|
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
|
||||||
|
const totals = sorted.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const mostChanged = sorted.length
|
||||||
|
? sorted.reduce((best, item) => {
|
||||||
|
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||||
|
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
||||||
|
const bestScore = bestAdd + bestDel
|
||||||
|
|
||||||
|
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||||
|
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||||
|
const score = add + del
|
||||||
|
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return best
|
||||||
|
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||||
|
}, sorted[0])
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Auto-select the most-changed file if none selected.
|
||||||
|
const currentSelected = props.selectedFile()
|
||||||
|
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
||||||
|
|
||||||
|
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
|
||||||
|
|
||||||
|
const emptyViewerMessage = () => {
|
||||||
|
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||||
|
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
|
||||||
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderViewer = () => (
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<DiffToolbar
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
onViewModeChange={props.onViewModeChange}
|
||||||
|
onContextModeChange={props.onContextModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(file) => (
|
||||||
|
<MonacoDiffViewer
|
||||||
|
scopeKey={scopeKey}
|
||||||
|
path={String(file().file || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderEmptyList = () => (
|
||||||
|
<div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderListPanel = () => (
|
||||||
|
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.file}>
|
||||||
|
<span class="file-path-text">{item.file}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<span class="file-list-item-additions">+{item.additions}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderListOverlay = () => (
|
||||||
|
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSelectFile(item.file, true)
|
||||||
|
}}
|
||||||
|
title={item.file}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.file}>
|
||||||
|
<span class="file-path-text">{item.file}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<span class="file-list-item-additions">+{item.additions}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitFilePanel
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<span class="files-tab-selected-path" title={headerPath()}>
|
||||||
|
<span class="file-path-text">{headerPath()}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
|
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||||
|
</span>
|
||||||
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
|
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
|
viewer={renderViewer()}
|
||||||
|
listOpen={props.listOpen()}
|
||||||
|
onToggleList={props.onToggleList}
|
||||||
|
splitWidth={props.splitWidth()}
|
||||||
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
|
overlayAriaLabel="Changes"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderContent()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangesTab
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
|
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||||
|
|
||||||
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
|
||||||
|
interface FilesTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
browserPath: Accessor<string>
|
||||||
|
browserEntries: Accessor<FileNode[] | null>
|
||||||
|
browserLoading: Accessor<boolean>
|
||||||
|
browserError: Accessor<string | null>
|
||||||
|
|
||||||
|
browserSelectedPath: Accessor<string | null>
|
||||||
|
browserSelectedContent: Accessor<string | null>
|
||||||
|
browserSelectedLoading: Accessor<boolean>
|
||||||
|
browserSelectedError: Accessor<string | null>
|
||||||
|
|
||||||
|
parentPath: Accessor<string | null>
|
||||||
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
|
onLoadEntries: (path: string) => void
|
||||||
|
onOpenFile: (path: string) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
|
||||||
|
listOpen: Accessor<boolean>
|
||||||
|
onToggleList: () => void
|
||||||
|
splitWidth: Accessor<number>
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||||
|
const renderContent = (): JSX.Element => {
|
||||||
|
const entriesValue = props.browserEntries()
|
||||||
|
const entries = entriesValue || []
|
||||||
|
const sorted = [...entries].sort((a, b) => {
|
||||||
|
const aDir = a.type === "directory" ? 0 : 1
|
||||||
|
const bDir = b.type === "directory" ? 0 : 1
|
||||||
|
if (aDir !== bDir) return aDir - bDir
|
||||||
|
return String(a.name || "").localeCompare(String(b.name || ""))
|
||||||
|
})
|
||||||
|
|
||||||
|
const parent = props.parentPath()
|
||||||
|
|
||||||
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
|
const emptyViewerMessage = () => {
|
||||||
|
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||||
|
return "Select a file to preview"
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderViewer = () => (
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={props.browserSelectedLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.browserSelectedError()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
props.browserSelectedPath() && props.browserSelectedContent() !== null
|
||||||
|
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(payload) => (
|
||||||
|
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(err) => (
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{err()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderList = () => (
|
||||||
|
<>
|
||||||
|
<Show when={parent}>
|
||||||
|
{(p) => (
|
||||||
|
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={p()}>
|
||||||
|
<span class="file-path-text">..</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
|
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.type === "directory") {
|
||||||
|
props.onLoadEntries(item.path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.onOpenFile(item.path)
|
||||||
|
}}
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitFilePanel
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<div class="files-tab-stats">
|
||||||
|
<span class="files-tab-stat">
|
||||||
|
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
|
||||||
|
<span class="file-path-text">{headerDisplayedPath()}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Show when={props.browserLoading()}>
|
||||||
|
<span>Loading…</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
disabled={props.browserLoading()}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
|
onClick={() => props.onRefresh()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
list={{ panel: renderList, overlay: renderList }}
|
||||||
|
viewer={renderViewer()}
|
||||||
|
listOpen={props.listOpen()}
|
||||||
|
onToggleList={props.onToggleList}
|
||||||
|
splitWidth={props.splitWidth()}
|
||||||
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
|
overlayAriaLabel="Files"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderContent()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilesTab
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||||
|
|
||||||
|
interface GitChangesTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
|
||||||
|
entries: Accessor<GitFileStatus[] | null>
|
||||||
|
statusLoading: Accessor<boolean>
|
||||||
|
statusError: Accessor<string | null>
|
||||||
|
|
||||||
|
selectedPath: Accessor<string | null>
|
||||||
|
selectedLoading: Accessor<boolean>
|
||||||
|
selectedError: Accessor<string | null>
|
||||||
|
selectedBefore: Accessor<string | null>
|
||||||
|
selectedAfter: Accessor<string | null>
|
||||||
|
mostChangedPath: Accessor<string | null>
|
||||||
|
|
||||||
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
|
||||||
|
onOpenFile: (path: string) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
|
||||||
|
listOpen: Accessor<boolean>
|
||||||
|
onToggleList: () => void
|
||||||
|
splitWidth: Accessor<number>
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||||
|
const renderContent = (): JSX.Element => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
|
||||||
|
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||||
|
const entries = hasSession ? props.entries() : null
|
||||||
|
|
||||||
|
const sorted = Array.isArray(entries)
|
||||||
|
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const totals = sorted.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||||
|
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
|
||||||
|
|
||||||
|
const emptyViewerMessage = () => {
|
||||||
|
if (!hasSession) return "Select a session to view changes."
|
||||||
|
if (entries === null) return "Loading git changes…"
|
||||||
|
if (nonDeleted.length === 0) return "No git changes yet."
|
||||||
|
return "No file selected."
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPath = props.selectedPath()
|
||||||
|
const fallbackPath = props.mostChangedPath()
|
||||||
|
const selectedEntry =
|
||||||
|
sorted.find((item) => item.path === selectedPath) ||
|
||||||
|
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
|
||||||
|
|
||||||
|
const renderViewer = () => (
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<DiffToolbar
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
onViewModeChange={props.onViewModeChange}
|
||||||
|
onContextModeChange={props.onContextModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={props.selectedLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.selectedError()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
selectedEntry &&
|
||||||
|
props.selectedBefore() !== null &&
|
||||||
|
props.selectedAfter() !== null &&
|
||||||
|
selectedEntry.status !== "deleted"
|
||||||
|
? {
|
||||||
|
path: selectedEntry.path,
|
||||||
|
before: props.selectedBefore() as string,
|
||||||
|
after: props.selectedAfter() as string,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(file) => (
|
||||||
|
<MonacoDiffViewer
|
||||||
|
scopeKey={props.scopeKey()}
|
||||||
|
path={String(file().path || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(err) => (
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{err()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
|
const renderListPanel = () => (
|
||||||
|
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
props.onOpenFile(item.path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.path}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<Show when={item.status === "deleted"}>
|
||||||
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.status !== "deleted"}>
|
||||||
|
<>
|
||||||
|
<span class="file-list-item-additions">+{item.added}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderListOverlay = () => (
|
||||||
|
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => props.onOpenFile(item.path)}
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.path}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<Show when={item.status === "deleted"}>
|
||||||
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.status !== "deleted"}>
|
||||||
|
<>
|
||||||
|
<span class="file-list-item-additions">+{item.added}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitFilePanel
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
||||||
|
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
|
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||||
|
</span>
|
||||||
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
|
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||||
|
</span>
|
||||||
|
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
disabled={!hasSession || props.statusLoading() || entries === null}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
|
onClick={() => props.onRefresh()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
|
viewer={renderViewer()}
|
||||||
|
listOpen={props.listOpen()}
|
||||||
|
onToggleList={props.onToggleList}
|
||||||
|
splitWidth={props.splitWidth()}
|
||||||
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
|
overlayAriaLabel="Git Changes"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderContent()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GitChangesTab
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
|
import { Accordion } from "@kobalte/core"
|
||||||
|
|
||||||
|
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
|
|
||||||
|
import type { Instance } from "../../../../../types/instance"
|
||||||
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||||
|
import type { Session } from "../../../../../types/session"
|
||||||
|
|
||||||
|
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||||
|
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||||
|
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||||
|
|
||||||
|
interface StatusTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
instance: Instance
|
||||||
|
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSession: Accessor<Session | null>
|
||||||
|
activeSessionDiffs: Accessor<any[] | undefined>
|
||||||
|
|
||||||
|
latestTodoState: Accessor<ToolState | null>
|
||||||
|
|
||||||
|
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||||
|
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||||
|
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
|
||||||
|
expandedItems: Accessor<string[]>
|
||||||
|
onExpandedItemsChange: (values: string[]) => void
|
||||||
|
|
||||||
|
onOpenChangesTab: (file?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||||
|
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||||
|
|
||||||
|
const renderStatusSessionChanges = () => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
if (!sessionId || sessionId === "info") {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffs = props.activeSessionDiffs()
|
||||||
|
if (diffs === undefined) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||||
|
const totals = sorted.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3 min-h-0">
|
||||||
|
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
|
||||||
|
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
|
||||||
|
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
|
||||||
|
onClick={() => props.onOpenChangesTab(item.file)}
|
||||||
|
title={props.t("instanceShell.sessionChanges.actions.show")}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div
|
||||||
|
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
||||||
|
title={item.file}
|
||||||
|
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
|
||||||
|
>
|
||||||
|
{item.file}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
|
||||||
|
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
|
||||||
|
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPlanSectionContent = () => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
if (!sessionId || sessionId === "info") {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const todoState = props.latestTodoState()
|
||||||
|
if (!todoState) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBackgroundProcesses = () => {
|
||||||
|
const processes = props.backgroundProcessList()
|
||||||
|
if (processes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<For each={processes}>
|
||||||
|
{(process) => (
|
||||||
|
<div class="status-process-card">
|
||||||
|
<div class="status-process-header">
|
||||||
|
<span class="status-process-title">{process.title}</span>
|
||||||
|
<div class="status-process-meta">
|
||||||
|
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||||
|
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||||
|
<span>
|
||||||
|
{props.t("instanceShell.backgroundProcesses.output", {
|
||||||
|
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-process-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
|
onClick={() => props.onOpenBackgroundOutput(process)}
|
||||||
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
|
title={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
|
>
|
||||||
|
<TerminalSquare class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
|
disabled={process.status !== "running"}
|
||||||
|
onClick={() => props.onStopBackgroundProcess(process.id)}
|
||||||
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
|
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
|
>
|
||||||
|
<XOctagon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
|
onClick={() => props.onTerminateBackgroundProcess(process.id)}
|
||||||
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
|
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSections = [
|
||||||
|
{
|
||||||
|
id: "session-changes",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||||
|
render: renderStatusSessionChanges,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plan",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||||
|
render: renderPlanSectionContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "background-processes",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||||
|
render: renderBackgroundProcesses,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mcp",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||||
|
render: () => (
|
||||||
|
<InstanceServiceStatus
|
||||||
|
initialInstance={props.instance}
|
||||||
|
sections={["mcp"]}
|
||||||
|
showSectionHeadings={false}
|
||||||
|
class="space-y-2"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lsp",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||||
|
render: () => (
|
||||||
|
<InstanceServiceStatus
|
||||||
|
initialInstance={props.instance}
|
||||||
|
sections={["lsp"]}
|
||||||
|
showSectionHeadings={false}
|
||||||
|
class="space-y-2"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plugins",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||||
|
render: () => (
|
||||||
|
<InstanceServiceStatus
|
||||||
|
initialInstance={props.instance}
|
||||||
|
sections={["plugins"]}
|
||||||
|
showSectionHeadings={false}
|
||||||
|
class="space-y-2"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="status-tab-container">
|
||||||
|
<Show when={props.activeSession()}>
|
||||||
|
{(activeSession) => (
|
||||||
|
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Accordion.Root
|
||||||
|
class="right-panel-accordion"
|
||||||
|
collapsible
|
||||||
|
multiple
|
||||||
|
value={props.expandedItems()}
|
||||||
|
onChange={props.onExpandedItemsChange}
|
||||||
|
>
|
||||||
|
<For each={statusSections}>
|
||||||
|
{(section) => (
|
||||||
|
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||||
|
<Accordion.Header>
|
||||||
|
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||||
|
<span>{props.t(section.labelKey)}</span>
|
||||||
|
<ChevronDown
|
||||||
|
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||||
|
/>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Accordion.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusTab
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
||||||
|
|
||||||
|
export type DiffViewMode = "split" | "unified"
|
||||||
|
|
||||||
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
92
packages/ui/src/components/instance/shell/storage.ts
Normal file
92
packages/ui/src/components/instance/shell/storage.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
|
||||||
|
export const MIN_SESSION_SIDEBAR_WIDTH = 220
|
||||||
|
export const MAX_SESSION_SIDEBAR_WIDTH = 400
|
||||||
|
|
||||||
|
export const RIGHT_DRAWER_WIDTH = 260
|
||||||
|
export const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||||
|
export const MAX_RIGHT_DRAWER_WIDTH = 1200
|
||||||
|
|
||||||
|
export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||||
|
export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||||
|
export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
|
||||||
|
export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
|
||||||
|
export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
|
||||||
|
export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1"
|
||||||
|
export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||||
|
|
||||||
|
export const clampWidth = (value: number) =>
|
||||||
|
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||||
|
|
||||||
|
export const clampRightWidth = (value: number) => {
|
||||||
|
const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH
|
||||||
|
const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax)
|
||||||
|
return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY)
|
||||||
|
|
||||||
|
export function readStoredPinState(side: "left" | "right", defaultValue: boolean) {
|
||||||
|
if (typeof window === "undefined") return defaultValue
|
||||||
|
const stored = window.localStorage.getItem(getPinStorageKey(side))
|
||||||
|
if (stored === "true") return true
|
||||||
|
if (stored === "false") return false
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistPinState(side: "left" | "right", value: boolean) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredRightPanelTab(
|
||||||
|
defaultValue: "changes" | "git-changes" | "files" | "status",
|
||||||
|
): "changes" | "git-changes" | "files" | "status" {
|
||||||
|
if (typeof window === "undefined") return defaultValue
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||||
|
if (stored === "status") return "status"
|
||||||
|
if (stored === "changes") return "changes"
|
||||||
|
if (stored === "git-changes") return "git-changes"
|
||||||
|
if (stored === "files") return "files"
|
||||||
|
|
||||||
|
// Migrate from v1 (where the stored values were the internal tab ids).
|
||||||
|
const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||||
|
if (legacy === "status") return "status"
|
||||||
|
if (legacy === "browser") return "files"
|
||||||
|
if (legacy === "files") return "changes"
|
||||||
|
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredPanelWidth(key: string, fallback: number) {
|
||||||
|
if (typeof window === "undefined") return fallback
|
||||||
|
const stored = window.localStorage.getItem(key)
|
||||||
|
if (!stored) return fallback
|
||||||
|
const parsed = Number.parseInt(stored, 10)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredBool(key: string): boolean | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const stored = window.localStorage.getItem(key)
|
||||||
|
if (stored === "true") return true
|
||||||
|
if (stored === "false") return false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const stored = window.localStorage.getItem(key)
|
||||||
|
if (!stored) return null
|
||||||
|
return (allowed as readonly string[]).includes(stored) ? (stored as T) : null
|
||||||
|
}
|
||||||
3
packages/ui/src/components/instance/shell/types.ts
Normal file
3
packages/ui/src/components/instance/shell/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type LayoutMode = "desktop" | "tablet" | "phone"
|
||||||
|
|
||||||
|
export type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
|
||||||
260
packages/ui/src/components/instance/shell/useDrawerChrome.ts
Normal file
260
packages/ui/src/components/instance/shell/useDrawerChrome.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import {
|
||||||
|
batch,
|
||||||
|
createComponent,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
type Accessor,
|
||||||
|
type JSX,
|
||||||
|
type Setter,
|
||||||
|
} from "solid-js"
|
||||||
|
import MenuIcon from "@suid/icons-material/Menu"
|
||||||
|
|
||||||
|
import type { TranslateParams } from "../../../lib/i18n"
|
||||||
|
|
||||||
|
import type { DrawerViewState, LayoutMode } from "./types"
|
||||||
|
import { persistPinState, readStoredPinState } from "./storage"
|
||||||
|
|
||||||
|
export interface UseDrawerChromeOptions {
|
||||||
|
t: (key: string, params?: TranslateParams) => string
|
||||||
|
layoutMode: Accessor<LayoutMode>
|
||||||
|
leftPinningSupported: Accessor<boolean>
|
||||||
|
rightPinningSupported: Accessor<boolean>
|
||||||
|
leftDrawerContentEl: Accessor<HTMLElement | null>
|
||||||
|
rightDrawerContentEl: Accessor<HTMLElement | null>
|
||||||
|
leftToggleButtonEl: Accessor<HTMLElement | null>
|
||||||
|
rightToggleButtonEl: Accessor<HTMLElement | null>
|
||||||
|
measureDrawerHost?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawerChromeApi {
|
||||||
|
leftPinned: Accessor<boolean>
|
||||||
|
leftOpen: Accessor<boolean>
|
||||||
|
rightPinned: Accessor<boolean>
|
||||||
|
rightOpen: Accessor<boolean>
|
||||||
|
setLeftOpen: Setter<boolean>
|
||||||
|
setRightOpen: Setter<boolean>
|
||||||
|
leftDrawerState: Accessor<DrawerViewState>
|
||||||
|
rightDrawerState: Accessor<DrawerViewState>
|
||||||
|
pinLeft: () => void
|
||||||
|
unpinLeft: () => void
|
||||||
|
pinRight: () => void
|
||||||
|
unpinRight: () => void
|
||||||
|
closeLeft: () => void
|
||||||
|
closeRight: () => void
|
||||||
|
leftAppBarButtonLabel: Accessor<string>
|
||||||
|
rightAppBarButtonLabel: Accessor<string>
|
||||||
|
leftAppBarButtonIcon: Accessor<JSX.Element>
|
||||||
|
rightAppBarButtonIcon: Accessor<JSX.Element>
|
||||||
|
handleLeftAppBarButtonClick: () => void
|
||||||
|
handleRightAppBarButtonClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi {
|
||||||
|
const [leftPinned, setLeftPinned] = createSignal(true)
|
||||||
|
const [leftOpen, setLeftOpen] = createSignal(true)
|
||||||
|
const [rightPinned, setRightPinned] = createSignal(true)
|
||||||
|
const [rightOpen, setRightOpen] = createSignal(true)
|
||||||
|
|
||||||
|
const measureDrawerHost = () => options.measureDrawerHost?.()
|
||||||
|
|
||||||
|
const focusTarget = (element: HTMLElement | null) => {
|
||||||
|
if (!element) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const blurIfInside = (element: HTMLElement | null) => {
|
||||||
|
if (typeof document === "undefined" || !element) return
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (active && element.contains(active)) {
|
||||||
|
active.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
|
||||||
|
if (side === "left" && !options.leftPinningSupported()) return
|
||||||
|
if (side === "right" && !options.rightPinningSupported()) return
|
||||||
|
persistPinState(side, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
switch (options.layoutMode()) {
|
||||||
|
case "desktop": {
|
||||||
|
const leftSaved = readStoredPinState("left", true)
|
||||||
|
const rightSaved = readStoredPinState("right", true)
|
||||||
|
setLeftPinned(leftSaved)
|
||||||
|
setLeftOpen(leftSaved)
|
||||||
|
setRightPinned(rightSaved)
|
||||||
|
setRightOpen(rightSaved)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "tablet": {
|
||||||
|
setLeftPinned(true)
|
||||||
|
setLeftOpen(true)
|
||||||
|
setRightPinned(false)
|
||||||
|
setRightOpen(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
setLeftPinned(false)
|
||||||
|
setLeftOpen(false)
|
||||||
|
setRightPinned(false)
|
||||||
|
setRightOpen(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const leftDrawerState = createMemo<DrawerViewState>(() => {
|
||||||
|
if (leftPinned()) return "pinned"
|
||||||
|
return leftOpen() ? "floating-open" : "floating-closed"
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightDrawerState = createMemo<DrawerViewState>(() => {
|
||||||
|
if (rightPinned()) return "pinned"
|
||||||
|
return rightOpen() ? "floating-open" : "floating-closed"
|
||||||
|
})
|
||||||
|
|
||||||
|
const leftAppBarButtonLabel = () => {
|
||||||
|
const state = leftDrawerState()
|
||||||
|
if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned")
|
||||||
|
return options.t("instanceShell.leftDrawer.toggle.open")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightAppBarButtonLabel = () => {
|
||||||
|
const state = rightDrawerState()
|
||||||
|
if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned")
|
||||||
|
return options.t("instanceShell.rightDrawer.toggle.open")
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftAppBarButtonIcon = () => {
|
||||||
|
return createComponent(MenuIcon, { fontSize: "small" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightAppBarButtonIcon = () => {
|
||||||
|
return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinLeft = () => {
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setLeftPinned(true)
|
||||||
|
setLeftOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("left", true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpinLeft = () => {
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setLeftPinned(false)
|
||||||
|
setLeftOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("left", false)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinRight = () => {
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setRightPinned(true)
|
||||||
|
setRightOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("right", true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpinRight = () => {
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setRightPinned(false)
|
||||||
|
setRightOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("right", false)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeftAppBarButtonClick = () => {
|
||||||
|
const state = leftDrawerState()
|
||||||
|
if (state !== "floating-closed") return
|
||||||
|
setLeftOpen(true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRightAppBarButtonClick = () => {
|
||||||
|
const state = rightDrawerState()
|
||||||
|
if (state !== "floating-closed") return
|
||||||
|
setRightOpen(true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeLeft = () => {
|
||||||
|
if (leftDrawerState() === "pinned") return
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
setLeftOpen(false)
|
||||||
|
focusTarget(options.leftToggleButtonEl())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRight = () => {
|
||||||
|
if (rightDrawerState() === "pinned") return
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
setRightOpen(false)
|
||||||
|
focusTarget(options.rightToggleButtonEl())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeFloatingDrawersIfAny = () => {
|
||||||
|
let handled = false
|
||||||
|
if (!leftPinned() && leftOpen()) {
|
||||||
|
setLeftOpen(false)
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
focusTarget(options.leftToggleButtonEl())
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
if (!rightPinned() && rightOpen()) {
|
||||||
|
setRightOpen(false)
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
focusTarget(options.rightToggleButtonEl())
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return
|
||||||
|
if (!closeFloatingDrawersIfAny()) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleEscape, true)
|
||||||
|
onCleanup(() => window.removeEventListener("keydown", handleEscape, true))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPinned,
|
||||||
|
leftOpen,
|
||||||
|
rightPinned,
|
||||||
|
rightOpen,
|
||||||
|
setLeftOpen,
|
||||||
|
setRightOpen,
|
||||||
|
leftDrawerState,
|
||||||
|
rightDrawerState,
|
||||||
|
pinLeft,
|
||||||
|
unpinLeft,
|
||||||
|
pinRight,
|
||||||
|
unpinRight,
|
||||||
|
closeLeft,
|
||||||
|
closeRight,
|
||||||
|
leftAppBarButtonLabel,
|
||||||
|
rightAppBarButtonLabel,
|
||||||
|
leftAppBarButtonIcon,
|
||||||
|
rightAppBarButtonIcon,
|
||||||
|
handleLeftAppBarButtonClick,
|
||||||
|
handleRightAppBarButtonClick,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
|
|
||||||
|
type DrawerHostMeasure = {
|
||||||
|
setDrawerHost: (element: HTMLElement) => void
|
||||||
|
drawerContainer: () => HTMLElement | undefined
|
||||||
|
measureDrawerHost: () => void
|
||||||
|
floatingTopPx: () => string
|
||||||
|
floatingHeight: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawerHostMeasure(tabBarOffset: Accessor<number>): DrawerHostMeasure {
|
||||||
|
const [drawerHost, setDrawerHost] = createSignal<HTMLElement | null>(null)
|
||||||
|
const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0)
|
||||||
|
const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0)
|
||||||
|
|
||||||
|
const storeDrawerHost = (element: HTMLElement) => {
|
||||||
|
setDrawerHost(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureDrawerHost = () => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const host = drawerHost()
|
||||||
|
if (!host) return
|
||||||
|
const rect = host.getBoundingClientRect()
|
||||||
|
setFloatingDrawerTop(rect.top)
|
||||||
|
setFloatingDrawerHeight(Math.max(0, rect.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
tabBarOffset()
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
requestAnimationFrame(() => measureDrawerHost())
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerContainer = () => {
|
||||||
|
const host = drawerHost()
|
||||||
|
if (host) return host
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
return document.body
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackDrawerTop = () => tabBarOffset()
|
||||||
|
const floatingTop = () => {
|
||||||
|
const measured = floatingDrawerTop()
|
||||||
|
if (measured > 0) return measured
|
||||||
|
return fallbackDrawerTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingTopPx = () => `${floatingTop()}px`
|
||||||
|
const floatingHeight = () => {
|
||||||
|
const measured = floatingDrawerHeight()
|
||||||
|
if (measured > 0) return `${measured}px`
|
||||||
|
return `calc(100% - ${floatingTop()}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setDrawerHost: storeDrawerHost,
|
||||||
|
drawerContainer,
|
||||||
|
measureDrawerHost,
|
||||||
|
floatingTopPx,
|
||||||
|
floatingHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
113
packages/ui/src/components/instance/shell/useDrawerResize.ts
Normal file
113
packages/ui/src/components/instance/shell/useDrawerResize.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js"
|
||||||
|
|
||||||
|
import { useGlobalPointerDrag } from "./useGlobalPointerDrag"
|
||||||
|
|
||||||
|
type DrawerResizeSide = "left" | "right"
|
||||||
|
|
||||||
|
type DrawerResizeOptions = {
|
||||||
|
sessionSidebarWidth: Accessor<number>
|
||||||
|
rightDrawerWidth: Accessor<number>
|
||||||
|
setSessionSidebarWidth: Setter<number>
|
||||||
|
setRightDrawerWidth: Setter<number>
|
||||||
|
clampLeft: (width: number) => number
|
||||||
|
clampRight: (width: number) => number
|
||||||
|
measureDrawerHost: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawerResizeApi = {
|
||||||
|
handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void
|
||||||
|
handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
||||||
|
const [activeResizeSide, setActiveResizeSide] = createSignal<DrawerResizeSide | null>(null)
|
||||||
|
const [resizeStartX, setResizeStartX] = createSignal(0)
|
||||||
|
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
||||||
|
|
||||||
|
const scheduleDrawerMeasure = () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
options.measureDrawerHost()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => options.measureDrawerHost())
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDrawerWidth = (side: DrawerResizeSide, width: number) => {
|
||||||
|
if (side === "left") {
|
||||||
|
options.setSessionSidebarWidth(width)
|
||||||
|
} else {
|
||||||
|
options.setRightDrawerWidth(width)
|
||||||
|
}
|
||||||
|
scheduleDrawerMeasure()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerPointerMove = (clientX: number) => {
|
||||||
|
const side = activeResizeSide()
|
||||||
|
if (!side) return
|
||||||
|
const startWidth = resizeStartWidth()
|
||||||
|
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||||
|
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||||
|
const nextWidth = clamp(startWidth + delta)
|
||||||
|
applyDrawerWidth(side, nextWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerMouseMove(event: MouseEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleDrawerPointerMove(event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerMouseUp() {
|
||||||
|
stopDrawerResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerTouchMove(event: TouchEvent) {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
handleDrawerPointerMove(touch.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerTouchEnd() {
|
||||||
|
stopDrawerResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawerPointerDrag = useGlobalPointerDrag({
|
||||||
|
onMouseMove: drawerMouseMove,
|
||||||
|
onMouseUp: drawerMouseUp,
|
||||||
|
onTouchMove: drawerTouchMove,
|
||||||
|
onTouchEnd: drawerTouchEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
function stopDrawerResize() {
|
||||||
|
setActiveResizeSide(null)
|
||||||
|
drawerPointerDrag.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrawerResize = (side: DrawerResizeSide, clientX: number) => {
|
||||||
|
setActiveResizeSide(side)
|
||||||
|
setResizeStartX(clientX)
|
||||||
|
setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth())
|
||||||
|
drawerPointerDrag.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
startDrawerResize(side, event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
startDrawerResize(side, touch.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stopDrawerResize()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDrawerResizeMouseDown,
|
||||||
|
handleDrawerResizeTouchStart,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
type GlobalPointerDragHandlers = {
|
||||||
|
onMouseMove: (event: MouseEvent) => void
|
||||||
|
onMouseUp: (event: MouseEvent) => void
|
||||||
|
onTouchMove: (event: TouchEvent) => void
|
||||||
|
onTouchEnd: (event: TouchEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalPointerDrag = {
|
||||||
|
start: () => void
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag {
|
||||||
|
const start = () => {
|
||||||
|
document.addEventListener("mousemove", handlers.onMouseMove)
|
||||||
|
document.addEventListener("mouseup", handlers.onMouseUp)
|
||||||
|
document.addEventListener("touchmove", handlers.onTouchMove, { passive: false })
|
||||||
|
document.addEventListener("touchend", handlers.onTouchEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
document.removeEventListener("mousemove", handlers.onMouseMove)
|
||||||
|
document.removeEventListener("mouseup", handlers.onMouseUp)
|
||||||
|
document.removeEventListener("touchmove", handlers.onTouchMove)
|
||||||
|
document.removeEventListener("touchend", handlers.onTouchEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop }
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
|
import type { Session } from "../../../types/session"
|
||||||
|
import {
|
||||||
|
activeParentSessionId,
|
||||||
|
activeSessionId as activeSessionMap,
|
||||||
|
getSessionFamily,
|
||||||
|
getSessionInfo,
|
||||||
|
getSessionThreads,
|
||||||
|
sessions,
|
||||||
|
setActiveParentSession,
|
||||||
|
setActiveSession,
|
||||||
|
} from "../../../stores/sessions"
|
||||||
|
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||||
|
import { getBackgroundProcesses } from "../../../stores/background-processes"
|
||||||
|
import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types"
|
||||||
|
|
||||||
|
type InstanceSessionContextOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceSessionContextState = {
|
||||||
|
// Session collections and selections
|
||||||
|
allInstanceSessions: Accessor<Map<string, Session>>
|
||||||
|
sessionThreads: Accessor<ReturnType<typeof getSessionThreads>>
|
||||||
|
activeSessions: Accessor<Map<string, SessionFamilyMember>>
|
||||||
|
activeSessionIdForInstance: Accessor<string | null>
|
||||||
|
parentSessionIdForInstance: Accessor<string | null>
|
||||||
|
activeSessionForInstance: Accessor<SessionFamilyMember | null>
|
||||||
|
activeSessionDiffs: Accessor<SessionFamilyMember["diff"] | undefined>
|
||||||
|
|
||||||
|
// Usage / info summaries
|
||||||
|
activeSessionUsage: Accessor<SessionUsageState | null>
|
||||||
|
activeSessionInfoDetails: Accessor<ReturnType<typeof getSessionInfo> | null>
|
||||||
|
tokenStats: Accessor<{ used: number; avail: number | null }>
|
||||||
|
|
||||||
|
// Todo state
|
||||||
|
latestTodoSnapshot: Accessor<LatestTodoSnapshot | null>
|
||||||
|
latestTodoState: Accessor<ToolState | null>
|
||||||
|
|
||||||
|
// Background processes
|
||||||
|
backgroundProcessList: Accessor<ReturnType<typeof getBackgroundProcesses>>
|
||||||
|
|
||||||
|
// Controller
|
||||||
|
handleSessionSelect: (sessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionFamilyMember = ReturnType<typeof getSessionFamily>[number]
|
||||||
|
|
||||||
|
export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState {
|
||||||
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId()))
|
||||||
|
|
||||||
|
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
|
||||||
|
return sessions().get(options.instanceId()) ?? new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionThreads = createMemo(() => getSessionThreads(options.instanceId()))
|
||||||
|
|
||||||
|
const activeSessions = createMemo(() => {
|
||||||
|
const parentId = activeParentSessionId().get(options.instanceId())
|
||||||
|
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||||
|
const sessionFamily = getSessionFamily(options.instanceId(), parentId)
|
||||||
|
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionIdForInstance = createMemo(() => {
|
||||||
|
return activeSessionMap().get(options.instanceId()) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentSessionIdForInstance = createMemo(() => {
|
||||||
|
return activeParentSessionId().get(options.instanceId()) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionForInstance = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info") return null
|
||||||
|
return activeSessions().get(sessionId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionDiffs = createMemo(() => {
|
||||||
|
const session = activeSessionForInstance()
|
||||||
|
return session?.diff
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionUsage = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId) return null
|
||||||
|
const store = messageStore()
|
||||||
|
return store?.getSessionUsage(sessionId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionInfoDetails = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId) return null
|
||||||
|
return getSessionInfo(options.instanceId(), sessionId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenStats = createMemo(() => {
|
||||||
|
const usage = activeSessionUsage()
|
||||||
|
const info = activeSessionInfoDetails()
|
||||||
|
return {
|
||||||
|
used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0,
|
||||||
|
avail: info?.contextAvailableTokens ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestTodoSnapshot = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info") return null
|
||||||
|
const store = messageStore()
|
||||||
|
if (!store) return null
|
||||||
|
const snapshot = store.state.latestTodos[sessionId]
|
||||||
|
return snapshot ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestTodoState = createMemo<ToolState | null>(() => {
|
||||||
|
const snapshot = latestTodoSnapshot()
|
||||||
|
if (!snapshot) return null
|
||||||
|
const store = messageStore()
|
||||||
|
if (!store) return null
|
||||||
|
const message = store.getMessage(snapshot.messageId)
|
||||||
|
if (!message) return null
|
||||||
|
const partRecord = message.parts?.[snapshot.partId]
|
||||||
|
const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState }
|
||||||
|
if (!part || part.type !== "tool" || part.tool !== "todowrite") return null
|
||||||
|
const state = part.state
|
||||||
|
if (!state || state.status !== "completed") return null
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
|
||||||
|
const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId()))
|
||||||
|
|
||||||
|
const handleSessionSelect = (sessionId: string) => {
|
||||||
|
const instanceId = options.instanceId()
|
||||||
|
if (sessionId === "info") {
|
||||||
|
setActiveSession(instanceId, sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = allInstanceSessions().get(sessionId)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
if (session.parentId === null) {
|
||||||
|
setActiveParentSession(instanceId, sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = session.parentId
|
||||||
|
if (!parentId) return
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setActiveParentSession(instanceId, parentId)
|
||||||
|
setActiveSession(instanceId, sessionId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allInstanceSessions,
|
||||||
|
sessionThreads,
|
||||||
|
activeSessions,
|
||||||
|
activeSessionIdForInstance,
|
||||||
|
parentSessionIdForInstance,
|
||||||
|
activeSessionForInstance,
|
||||||
|
activeSessionDiffs,
|
||||||
|
activeSessionUsage,
|
||||||
|
activeSessionInfoDetails,
|
||||||
|
tokenStats,
|
||||||
|
latestTodoSnapshot,
|
||||||
|
latestTodoState,
|
||||||
|
backgroundProcessList,
|
||||||
|
handleSessionSelect,
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/ui/src/components/instance/shell/useSessionCache.ts
Normal file
99
packages/ui/src/components/instance/shell/useSessionCache.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
|
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||||
|
import { clearSessionRenderCache } from "../../message-block"
|
||||||
|
import { getLogger } from "../../../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
const SESSION_CACHE_LIMIT = 5
|
||||||
|
|
||||||
|
type SessionCacheOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
instanceSessions: Accessor<Map<string, unknown>>
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionCacheState = {
|
||||||
|
cachedSessionIds: Accessor<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
|
||||||
|
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
|
||||||
|
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
|
||||||
|
|
||||||
|
const evictSession = (sessionId: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
const instanceId = options.instanceId()
|
||||||
|
log.info("Evicting cached session", { instanceId, sessionId })
|
||||||
|
const store = messageStoreBus.getInstance(instanceId)
|
||||||
|
store?.clearSession(sessionId)
|
||||||
|
clearSessionRenderCache(instanceId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleEvictions = (ids: string[]) => {
|
||||||
|
if (!ids.length) return
|
||||||
|
setPendingEvictions((current) => {
|
||||||
|
const existing = new Set(current)
|
||||||
|
const next = [...current]
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (!existing.has(id)) {
|
||||||
|
next.push(id)
|
||||||
|
existing.add(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const pending = pendingEvictions()
|
||||||
|
if (!pending.length) return
|
||||||
|
const cached = new Set(cachedSessionIds())
|
||||||
|
const remaining: string[] = []
|
||||||
|
pending.forEach((id) => {
|
||||||
|
if (cached.has(id)) {
|
||||||
|
remaining.push(id)
|
||||||
|
} else {
|
||||||
|
evictSession(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (remaining.length !== pending.length) {
|
||||||
|
setPendingEvictions(remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const instanceSessions = options.instanceSessions()
|
||||||
|
const activeId = options.activeSessionId()
|
||||||
|
|
||||||
|
setCachedSessionIds((current) => {
|
||||||
|
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||||
|
|
||||||
|
const touch = (id: string | null) => {
|
||||||
|
if (!id || id === "info") return
|
||||||
|
if (!instanceSessions.has(id)) return
|
||||||
|
|
||||||
|
const index = next.indexOf(id)
|
||||||
|
if (index !== -1) {
|
||||||
|
next.splice(index, 1)
|
||||||
|
}
|
||||||
|
next.unshift(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(activeId)
|
||||||
|
|
||||||
|
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
|
||||||
|
|
||||||
|
const trimmedSet = new Set(trimmed)
|
||||||
|
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||||
|
if (removed.length) {
|
||||||
|
scheduleEvictions(removed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
cachedSessionIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
|
import {
|
||||||
|
SESSION_SIDEBAR_EVENT,
|
||||||
|
type SessionSidebarRequestAction,
|
||||||
|
type SessionSidebarRequestDetail,
|
||||||
|
} from "../../../lib/session-sidebar-events"
|
||||||
|
|
||||||
|
interface PendingSidebarAction {
|
||||||
|
action: SessionSidebarRequestAction
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSessionSidebarRequestsOptions {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
sidebarContentEl: Accessor<HTMLElement | null>
|
||||||
|
leftPinned: Accessor<boolean>
|
||||||
|
leftOpen: Accessor<boolean>
|
||||||
|
setLeftOpen: (next: boolean) => void
|
||||||
|
measureDrawerHost: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) {
|
||||||
|
let sidebarActionId = 0
|
||||||
|
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
|
||||||
|
|
||||||
|
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: options.key,
|
||||||
|
code: options.code,
|
||||||
|
keyCode: options.keyCode,
|
||||||
|
which: options.keyCode,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusAgentSelectorControl = () => {
|
||||||
|
const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null
|
||||||
|
if (!agentTrigger) return false
|
||||||
|
agentTrigger.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusModelSelectorControl = () => {
|
||||||
|
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-model-selector]")
|
||||||
|
if (!input) return false
|
||||||
|
input.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusVariantSelectorControl = () => {
|
||||||
|
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||||
|
if (!input) return false
|
||||||
|
input.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const pending = pendingSidebarAction()
|
||||||
|
if (!pending) return
|
||||||
|
const action = pending.action
|
||||||
|
const contentReady = Boolean(options.sidebarContentEl())
|
||||||
|
if (!contentReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === "show-session-list") {
|
||||||
|
setPendingSidebarAction(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const handled =
|
||||||
|
action === "focus-agent-selector"
|
||||||
|
? focusAgentSelectorControl()
|
||||||
|
: action === "focus-model-selector"
|
||||||
|
? focusModelSelectorControl()
|
||||||
|
: focusVariantSelectorControl()
|
||||||
|
if (handled) {
|
||||||
|
setPendingSidebarAction(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
|
||||||
|
setPendingSidebarAction({ action, id: sidebarActionId++ })
|
||||||
|
if (!options.leftPinned() && !options.leftOpen()) {
|
||||||
|
options.setLeftOpen(true)
|
||||||
|
options.measureDrawerHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<SessionSidebarRequestDetail>).detail
|
||||||
|
if (!detail || detail.instanceId !== options.instanceId()) return
|
||||||
|
handleSidebarRequest(detail.action)
|
||||||
|
}
|
||||||
|
window.addEventListener(SESSION_SIDEBAR_EVENT, handler)
|
||||||
|
onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSidebarRequest,
|
||||||
|
pendingSidebarAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -1010,10 +1010,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const toggle = () => setExpanded((prev) => !prev)
|
const toggle = () => setExpanded((prev) => !prev)
|
||||||
|
|
||||||
|
const viewHideLabel = () =>
|
||||||
|
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||||
|
|
||||||
const hasDeleteTarget = () => Boolean(props.partId)
|
const hasDeleteTarget = () => Boolean(props.partId)
|
||||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
const canDelete = () => hasDeleteTarget() && !deleting()
|
||||||
|
|
||||||
const handleDelete = async (event: Event) => {
|
const handleDelete = async (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canDelete()) return
|
if (!canDelete()) return
|
||||||
@@ -1033,6 +1036,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-reasoning-card">
|
<div class="message-reasoning-card">
|
||||||
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
@@ -1057,32 +1061,41 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
<span class="message-reasoning-meta">
|
</button>
|
||||||
<span class="message-reasoning-indicator">
|
|
||||||
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
<div class="message-reasoning-actions">
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
toggle()
|
||||||
|
}}
|
||||||
|
aria-label={viewHideLabel()}
|
||||||
|
title={viewHideLabel()}
|
||||||
|
>
|
||||||
|
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
|
||||||
|
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Show when={hasDeleteTarget()}>
|
<Show when={hasDeleteTarget()}>
|
||||||
<span
|
<button
|
||||||
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
|
type="button"
|
||||||
role="button"
|
class="message-action-button"
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
onKeyDown={(event) => {
|
disabled={!canDelete()}
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
handleDelete(event)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={t("messagePart.actions.deleteTitle")}
|
aria-label={t("messagePart.actions.deleteTitle")}
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
title={t("messagePart.actions.deleteTitle")}
|
||||||
>
|
>
|
||||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</span>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<span class="message-reasoning-time">{timestamp()}</span>
|
<span class="message-reasoning-time">{timestamp()}</span>
|
||||||
</span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
|
|||||||
@@ -268,12 +268,13 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
return (
|
return (
|
||||||
<div class={containerClass()}>
|
<div class={containerClass()}>
|
||||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||||
|
<div class="message-item-header-row message-item-header-row--top">
|
||||||
<div class="message-speaker">
|
<div class="message-speaker">
|
||||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||||
{speakerLabel()}
|
{speakerLabel()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-item-actions">
|
<div class="message-item-actions">
|
||||||
<Show when={isUser()}>
|
<Show when={isUser()}>
|
||||||
<div class="message-action-group">
|
<div class="message-action-group">
|
||||||
@@ -305,19 +306,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
>
|
>
|
||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={deletableTextPartId()}>
|
|
||||||
{(partId) => (
|
|
||||||
<button
|
|
||||||
class="message-action-button"
|
|
||||||
onClick={() => void handleDeletePart(partId())}
|
|
||||||
disabled={isDeletingPart(partId())}
|
|
||||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
|
||||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
|
||||||
>
|
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isUser()}>
|
<Show when={!isUser()}>
|
||||||
@@ -348,6 +336,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={agentMeta()}>
|
||||||
|
{(meta) => (
|
||||||
|
<div class="message-item-header-row message-item-header-row--bottom">
|
||||||
|
<span class="message-agent-meta">{meta()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
const seenTimelineMessageIds = new Set<string>()
|
const seenTimelineMessageIds = new Set<string>()
|
||||||
const seenTimelineSegmentKeys = new Set<string>()
|
const seenTimelineSegmentKeys = new Set<string>()
|
||||||
|
const timelinePartCountsByMessageId = new Map<string, number>()
|
||||||
|
let pendingTimelineMessagePartUpdates = new Set<string>()
|
||||||
|
let pendingTimelinePartUpdateFrame: number | null = null
|
||||||
|
|
||||||
function makeTimelineKey(segment: TimelineSegment) {
|
function makeTimelineKey(segment: TimelineSegment) {
|
||||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||||
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
function seedTimeline() {
|
function seedTimeline() {
|
||||||
seenTimelineMessageIds.clear()
|
seenTimelineMessageIds.clear()
|
||||||
seenTimelineSegmentKeys.clear()
|
seenTimelineSegmentKeys.clear()
|
||||||
|
timelinePartCountsByMessageId.clear()
|
||||||
const ids = untrack(messageIds)
|
const ids = untrack(messageIds)
|
||||||
const resolvedStore = untrack(store)
|
const resolvedStore = untrack(store)
|
||||||
const segments: TimelineSegment[] = []
|
const segments: TimelineSegment[] = []
|
||||||
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const record = resolvedStore.getMessage(messageId)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record) return
|
if (!record) return
|
||||||
seenTimelineMessageIds.add(messageId)
|
seenTimelineMessageIds.add(messageId)
|
||||||
|
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
const key = makeTimelineKey(segment)
|
const key = makeTimelineKey(segment)
|
||||||
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
function appendTimelineForMessage(messageId: string) {
|
function appendTimelineForMessage(messageId: string) {
|
||||||
const record = untrack(() => store().getMessage(messageId))
|
const record = untrack(() => store().getMessage(messageId))
|
||||||
if (!record) return
|
if (!record) return
|
||||||
|
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
if (built.length === 0) return
|
if (built.length === 0) return
|
||||||
const newSegments: TimelineSegment[] = []
|
const newSegments: TimelineSegment[] = []
|
||||||
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let previousTimelineIds: string[] = []
|
let previousTimelineIds: string[] = []
|
||||||
let previousLastTimelineMessageId: string | null = null
|
|
||||||
let previousLastTimelinePartCount = 0
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
previousTimelineIds = []
|
previousTimelineIds = []
|
||||||
previousLastTimelineMessageId = null
|
|
||||||
previousLastTimelinePartCount = 0
|
|
||||||
setTimelineSegments([])
|
setTimelineSegments([])
|
||||||
seenTimelineMessageIds.clear()
|
seenTimelineMessageIds.clear()
|
||||||
seenTimelineSegmentKeys.clear()
|
seenTimelineSegmentKeys.clear()
|
||||||
|
timelinePartCountsByMessageId.clear()
|
||||||
|
pendingTimelineMessagePartUpdates.clear()
|
||||||
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Keep part count tracking in sync with id replacement.
|
||||||
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||||
|
if (existingPartCount !== undefined) {
|
||||||
|
timelinePartCountsByMessageId.delete(oldId)
|
||||||
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||||
|
}
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
previousTimelineIds = ids.slice()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
previousTimelineIds = ids.slice()
|
previousTimelineIds = ids.slice()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clearPendingTimelinePartUpdateFrame() {
|
||||||
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTimelinePartUpdateFlush() {
|
||||||
|
if (pendingTimelinePartUpdateFrame !== null) return
|
||||||
|
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
|
if (pendingTimelineMessagePartUpdates.size === 0) return
|
||||||
|
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
|
||||||
|
pendingTimelineMessagePartUpdates = new Set<string>()
|
||||||
|
|
||||||
|
const ids = messageIds()
|
||||||
|
const resolvedStore = store()
|
||||||
|
|
||||||
|
setTimelineSegments((prev) => {
|
||||||
|
let next = prev
|
||||||
|
|
||||||
|
for (const changedId of changedIds) {
|
||||||
|
// Remove old segments for this message.
|
||||||
|
next = next.filter((segment) => segment.messageId !== changedId)
|
||||||
|
|
||||||
|
const record = resolvedStore.getMessage(changedId)
|
||||||
|
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
|
||||||
|
|
||||||
|
// Insert rebuilt segments in the correct place based on session message order.
|
||||||
|
if (rebuilt.length > 0) {
|
||||||
|
let insertAt = next.length
|
||||||
|
const changedIndex = ids.indexOf(changedId)
|
||||||
|
if (changedIndex >= 0) {
|
||||||
|
for (let i = changedIndex + 1; i < ids.length; i++) {
|
||||||
|
const followingId = ids[i]
|
||||||
|
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
insertAt = existingIndex
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the segment key set since we may have removed/replaced segments.
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep timeline segments in sync when message parts are added/removed.
|
||||||
|
// Part deletion does not remove message ids from the session, so we must
|
||||||
|
// explicitly replace segments for messages whose part count changed.
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
if (ids.length === 0) return
|
const resolvedStore = store()
|
||||||
const lastId = ids[ids.length - 1]
|
|
||||||
if (!lastId) return
|
let hasChanges = false
|
||||||
const record = store().getMessage(lastId)
|
for (const messageId of ids) {
|
||||||
if (!record) return
|
const record = resolvedStore.getMessage(messageId)
|
||||||
const partCount = record.partIds.length
|
const partCount = record?.partIds.length ?? 0
|
||||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||||
return
|
|
||||||
|
if (previousCount === undefined) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
previousLastTimelineMessageId = lastId
|
|
||||||
previousLastTimelinePartCount = partCount
|
if (previousCount !== partCount) {
|
||||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
const newSegments: TimelineSegment[] = []
|
pendingTimelineMessagePartUpdates.add(messageId)
|
||||||
built.forEach((segment) => {
|
hasChanges = true
|
||||||
const key = makeTimelineKey(segment)
|
}
|
||||||
if (seenTimelineSegmentKeys.has(key)) return
|
}
|
||||||
seenTimelineSegmentKeys.add(key)
|
|
||||||
newSegments.push(segment)
|
// Drop tracking for ids that are no longer present.
|
||||||
})
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||||
if (newSegments.length > 0) {
|
if (!ids.includes(trackedId)) {
|
||||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
timelinePartCountsByMessageId.delete(trackedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
scheduleTimelinePartUpdateFlush()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
cancelAnimationFrame(pendingAnchorScroll)
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
}
|
}
|
||||||
clearScrollToBottomFrames()
|
clearScrollToBottomFrames()
|
||||||
|
clearPendingTimelinePartUpdateFrame()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||||
let hoverTimer: number | null = null
|
let hoverTimer: number | null = null
|
||||||
|
let closeTimer: number | null = null
|
||||||
const showTools = () => props.showToolSegments ?? true
|
const showTools = () => props.showToolSegments ?? true
|
||||||
|
|
||||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||||
@@ -293,9 +294,29 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearCloseTimer = () => {
|
||||||
|
if (closeTimer !== null && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(closeTimer)
|
||||||
|
closeTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleClose = () => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||||
|
closeTimer = window.setTimeout(() => {
|
||||||
|
closeTimer = null
|
||||||
|
setHoveredSegment(null)
|
||||||
|
setHoverAnchorRect(null)
|
||||||
|
}, 160)
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
const target = event.currentTarget as HTMLButtonElement
|
const target = event.currentTarget as HTMLButtonElement
|
||||||
hoverTimer = window.setTimeout(() => {
|
hoverTimer = window.setTimeout(() => {
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect()
|
||||||
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
clearHoverTimer()
|
scheduleClose()
|
||||||
setHoveredSegment(null)
|
|
||||||
setHoverAnchorRect(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => clearHoverTimer())
|
onCleanup(() => {
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeId = props.activeMessageId
|
const activeId = props.activeMessageId
|
||||||
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
ref={(element) => setTooltipElement(element)}
|
ref={(element) => setTooltipElement(element)}
|
||||||
class="message-timeline-tooltip"
|
class="message-timeline-tooltip"
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
|
onMouseLeave={() => scheduleClose()}
|
||||||
>
|
>
|
||||||
<MessagePreview
|
<MessagePreview
|
||||||
messageId={data().messageId}
|
messageId={data().messageId}
|
||||||
|
|||||||
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { Component, Show, createEffect, createResource } from "solid-js"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import {
|
||||||
|
getOsNotificationCapability,
|
||||||
|
requestOsNotificationPermission,
|
||||||
|
type OsNotificationPermission,
|
||||||
|
} from "../lib/os-notifications"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
|
||||||
|
interface NotificationsSettingsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPermissionLabel(permission: OsNotificationPermission): string {
|
||||||
|
switch (permission) {
|
||||||
|
case "granted":
|
||||||
|
return "Granted"
|
||||||
|
case "denied":
|
||||||
|
return "Denied"
|
||||||
|
case "default":
|
||||||
|
return "Not granted"
|
||||||
|
case "unsupported":
|
||||||
|
return "Unsupported"
|
||||||
|
default:
|
||||||
|
return String(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
|
||||||
|
const { preferences, updatePreferences } = useConfig()
|
||||||
|
|
||||||
|
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEnableToggle = async (enabled: boolean) => {
|
||||||
|
if (!enabled) {
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message: cap.info ?? "OS notifications are not supported in this environment.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission !== "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? "Notification permission denied. Enable notifications in your system/browser settings."
|
||||||
|
: "Notification permission not granted.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreferences({ osNotificationsEnabled: true })
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestPermission = async () => {
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message: cap.info ?? "Notifications are not supported in this environment.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission === "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message: "Permission granted. You can now enable notifications.",
|
||||||
|
variant: "success",
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? "Permission denied. You may need to enable notifications in your system/browser settings."
|
||||||
|
: "Permission not granted.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = () => capability()?.supported ?? false
|
||||||
|
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
|
||||||
|
const infoMessage = () => capability()?.info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
|
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Session Status Notifications</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-primary">Enable</div>
|
||||||
|
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||||
|
disabled={!supported() && capability.state === "ready"}
|
||||||
|
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-primary">Request permission</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => void handleRequestPermission()}
|
||||||
|
>
|
||||||
|
Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={Boolean(infoMessage())}>
|
||||||
|
<div class="text-xs text-secondary">{infoMessage()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!supported() && capability.state === "ready"}>
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
Notifications are not supported in this environment. The bell icon stays disabled.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
|
||||||
|
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-primary">Session needs input</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-primary">Session becomes idle</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnIdle)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||||
|
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationsSettingsModal
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import { For, Show, type Component } from "solid-js"
|
||||||
|
import { Expand } from "lucide-solid"
|
||||||
|
import type { Attachment } from "../../types/attachment"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
interface PromptAttachmentsBarProps {
|
||||||
|
attachments: Attachment[]
|
||||||
|
onRemoveAttachment: (attachmentId: string) => void
|
||||||
|
onExpandTextAttachment: (attachmentId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptAttachmentsBar: Component<PromptAttachmentsBarProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||||
|
<For each={props.attachments}>
|
||||||
|
{(attachment) => {
|
||||||
|
const isText = attachment.source.type === "text"
|
||||||
|
return (
|
||||||
|
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||||
|
<span class="font-mono">{attachment.display}</span>
|
||||||
|
<Show when={isText}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attachment-expand"
|
||||||
|
onClick={() => props.onExpandTextAttachment(attachment.id)}
|
||||||
|
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||||
|
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||||
|
>
|
||||||
|
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attachment-remove"
|
||||||
|
onClick={() => props.onRemoveAttachment(attachment.id)}
|
||||||
|
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PromptAttachmentsBar
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Attachment } from "../../types/attachment"
|
||||||
|
|
||||||
|
export function formatPastedPlaceholder(value: string | number) {
|
||||||
|
return `[pasted #${value}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatImagePlaceholder(value: string | number) {
|
||||||
|
return `[Image #${value}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPastedPlaceholderRegex() {
|
||||||
|
return /\[pasted #(\d+)\]/g
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImagePlaceholderRegex() {
|
||||||
|
return /\[Image #(\d+)\]/g
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMentionRegex() {
|
||||||
|
return /@(\S+)/g
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pastedDisplayCounterRegex = /pasted #(\d+)/
|
||||||
|
export const imageDisplayCounterRegex = /Image #(\d+)/
|
||||||
|
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
|
||||||
|
|
||||||
|
export function parseCounter(value: string) {
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
return Number.isNaN(parsed) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||||
|
let highestPaste = 0
|
||||||
|
let highestImage = 0
|
||||||
|
|
||||||
|
for (const match of currentPrompt.matchAll(createPastedPlaceholderRegex())) {
|
||||||
|
const parsed = parseCounter(match[1])
|
||||||
|
if (parsed !== null) {
|
||||||
|
highestPaste = Math.max(highestPaste, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of sessionAttachments) {
|
||||||
|
if (attachment.source.type === "text") {
|
||||||
|
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
if (placeholderMatch) {
|
||||||
|
const parsed = parseCounter(placeholderMatch[1])
|
||||||
|
if (parsed !== null) {
|
||||||
|
highestPaste = Math.max(highestPaste, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||||
|
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
|
||||||
|
if (imageMatch) {
|
||||||
|
const parsed = parseCounter(imageMatch[1])
|
||||||
|
if (parsed !== null) {
|
||||||
|
highestImage = Math.max(highestImage, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||||
|
const parsed = parseCounter(match[1])
|
||||||
|
if (parsed !== null) {
|
||||||
|
highestImage = Math.max(highestImage, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { highestPaste, highestImage }
|
||||||
|
}
|
||||||
26
packages/ui/src/components/prompt-input/types.ts
Normal file
26
packages/ui/src/components/prompt-input/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Attachment } from "../../types/attachment"
|
||||||
|
|
||||||
|
export type PromptMode = "normal" | "shell"
|
||||||
|
export type ExpandState = "normal" | "expanded"
|
||||||
|
export type PickerMode = "mention" | "command"
|
||||||
|
export type PromptInsertMode = "quote" | "code"
|
||||||
|
|
||||||
|
export interface PromptInputApi {
|
||||||
|
insertSelection(text: string, mode: PromptInsertMode): void
|
||||||
|
expandTextAttachment(attachmentId: string): void
|
||||||
|
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||||
|
focus(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptInputProps {
|
||||||
|
instanceId: string
|
||||||
|
instanceFolder: string
|
||||||
|
sessionId: string
|
||||||
|
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||||
|
onRunShell?: (command: string) => Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
escapeInDebounce?: boolean
|
||||||
|
isSessionBusy?: boolean
|
||||||
|
onAbortSession?: () => Promise<void>
|
||||||
|
registerPromptInputApi?: (api: PromptInputApi) => void | (() => void)
|
||||||
|
}
|
||||||
296
packages/ui/src/components/prompt-input/usePromptAttachments.ts
Normal file
296
packages/ui/src/components/prompt-input/usePromptAttachments.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { createSignal, type Accessor } from "solid-js"
|
||||||
|
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
|
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||||
|
import type { Attachment } from "../../types/attachment"
|
||||||
|
import {
|
||||||
|
bracketedImageDisplayCounterRegex,
|
||||||
|
findHighestAttachmentCounters,
|
||||||
|
formatImagePlaceholder,
|
||||||
|
formatPastedPlaceholder,
|
||||||
|
pastedDisplayCounterRegex,
|
||||||
|
} from "./attachmentPlaceholders"
|
||||||
|
|
||||||
|
type PromptAttachmentsOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
sessionId: Accessor<string>
|
||||||
|
instanceFolder: Accessor<string>
|
||||||
|
prompt: Accessor<string>
|
||||||
|
setPrompt: (value: string) => void
|
||||||
|
getTextarea: () => HTMLTextAreaElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptAttachments = {
|
||||||
|
attachments: Accessor<Attachment[]>
|
||||||
|
pasteCount: Accessor<number>
|
||||||
|
imageCount: Accessor<number>
|
||||||
|
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
|
||||||
|
|
||||||
|
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||||
|
isDragging: Accessor<boolean>
|
||||||
|
handleDragOver: (e: DragEvent) => void
|
||||||
|
handleDragLeave: (e: DragEvent) => void
|
||||||
|
handleDrop: (e: DragEvent) => void
|
||||||
|
|
||||||
|
handleRemoveAttachment: (attachmentId: string) => void
|
||||||
|
handleExpandTextAttachment: (attachment: Attachment) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptAttachments(options: PromptAttachmentsOptions): PromptAttachments {
|
||||||
|
const attachments = () => getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false)
|
||||||
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
|
|
||||||
|
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||||
|
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
|
||||||
|
setPasteCount(highestPaste)
|
||||||
|
setImageCount(highestImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveAttachment(attachmentId: string) {
|
||||||
|
const currentAttachments = attachments()
|
||||||
|
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||||
|
|
||||||
|
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
let newPrompt = currentPrompt
|
||||||
|
|
||||||
|
if (attachment.source.type === "file") {
|
||||||
|
if (attachment.mediaType.startsWith("image/")) {
|
||||||
|
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
|
||||||
|
if (imageMatch) {
|
||||||
|
const placeholder = formatImagePlaceholder(imageMatch[1])
|
||||||
|
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const filename = attachment.filename
|
||||||
|
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
||||||
|
}
|
||||||
|
} else if (attachment.source.type === "agent") {
|
||||||
|
const agentName = attachment.filename
|
||||||
|
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
||||||
|
} else if (attachment.source.type === "text") {
|
||||||
|
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
if (placeholderMatch) {
|
||||||
|
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
|
||||||
|
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setPrompt(newPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExpandTextAttachment(attachment: Attachment) {
|
||||||
|
if (attachment.source.type !== "text") return
|
||||||
|
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
const value = attachment.source.value
|
||||||
|
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
const placeholder = match ? formatPastedPlaceholder(match[1]) : null
|
||||||
|
const currentText = options.prompt()
|
||||||
|
|
||||||
|
let nextText = currentText
|
||||||
|
let selectionTarget: number | null = null
|
||||||
|
|
||||||
|
if (placeholder) {
|
||||||
|
const placeholderIndex = currentText.indexOf(placeholder)
|
||||||
|
if (placeholderIndex !== -1) {
|
||||||
|
nextText =
|
||||||
|
currentText.substring(0, placeholderIndex) +
|
||||||
|
value +
|
||||||
|
currentText.substring(placeholderIndex + placeholder.length)
|
||||||
|
selectionTarget = placeholderIndex + value.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextText === currentText) {
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||||
|
selectionTarget = start + value.length
|
||||||
|
} else {
|
||||||
|
nextText = currentText + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setPrompt(nextText)
|
||||||
|
removeAttachment(options.instanceId(), options.sessionId(), attachment.id)
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
if (selectionTarget !== null) {
|
||||||
|
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePaste(e: ClipboardEvent) {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i]
|
||||||
|
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const blob = item.getAsFile()
|
||||||
|
if (!blob) continue
|
||||||
|
|
||||||
|
const count = imageCount() + 1
|
||||||
|
setImageCount(count)
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64Data = (reader.result as string).split(",")[1]
|
||||||
|
const display = formatImagePlaceholder(count)
|
||||||
|
const filename = `image-${count}.png`
|
||||||
|
|
||||||
|
const attachment = createFileAttachment(
|
||||||
|
filename,
|
||||||
|
filename,
|
||||||
|
"image/png",
|
||||||
|
new TextEncoder().encode(base64Data),
|
||||||
|
options.instanceFolder(),
|
||||||
|
)
|
||||||
|
attachment.url = `data:image/png;base64,${base64Data}`
|
||||||
|
attachment.display = display
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const currentText = options.prompt()
|
||||||
|
const placeholder = formatImagePlaceholder(count)
|
||||||
|
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = start + placeholder.length
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textarea.focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastedText = e.clipboardData?.getData("text/plain")
|
||||||
|
if (!pastedText) return
|
||||||
|
|
||||||
|
const lineCount = pastedText.split("\n").length
|
||||||
|
const charCount = pastedText.length
|
||||||
|
|
||||||
|
const isLongPaste = charCount > 150 || lineCount > 3
|
||||||
|
|
||||||
|
if (isLongPaste) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const count = pasteCount() + 1
|
||||||
|
setPasteCount(count)
|
||||||
|
|
||||||
|
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||||
|
const display = `pasted #${count} (${summary})`
|
||||||
|
const filename = `paste-${count}.txt`
|
||||||
|
|
||||||
|
const attachment = createTextAttachment(pastedText, display, filename)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const currentText = options.prompt()
|
||||||
|
const placeholder = formatPastedPlaceholder(count)
|
||||||
|
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = start + placeholder.length
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textarea.focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
const path = (file as File & { path?: string }).path || file.name
|
||||||
|
const filename = file.name
|
||||||
|
const mime = file.type || "text/plain"
|
||||||
|
|
||||||
|
const createAndStoreAttachment = (previewUrl?: string) => {
|
||||||
|
const attachment = createFileAttachment(path, filename, mime, undefined, options.instanceFolder())
|
||||||
|
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
|
||||||
|
attachment.url = previewUrl
|
||||||
|
}
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = typeof reader.result === "string" ? reader.result : undefined
|
||||||
|
createAndStoreAttachment(result)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
|
||||||
|
createAndStoreAttachment(dataUrl)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} else {
|
||||||
|
createAndStoreAttachment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.getTextarea()?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments,
|
||||||
|
pasteCount,
|
||||||
|
imageCount,
|
||||||
|
syncAttachmentCounters,
|
||||||
|
handlePaste,
|
||||||
|
isDragging,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
handleRemoveAttachment,
|
||||||
|
handleExpandTextAttachment,
|
||||||
|
}
|
||||||
|
}
|
||||||
272
packages/ui/src/components/prompt-input/usePromptKeyDown.ts
Normal file
272
packages/ui/src/components/prompt-input/usePromptKeyDown.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
import type { Attachment } from "../../types/attachment"
|
||||||
|
import type { PromptMode } from "./types"
|
||||||
|
import {
|
||||||
|
createImagePlaceholderRegex,
|
||||||
|
createMentionRegex,
|
||||||
|
createPastedPlaceholderRegex,
|
||||||
|
} from "./attachmentPlaceholders"
|
||||||
|
|
||||||
|
export type UsePromptKeyDownOptions = {
|
||||||
|
getTextarea: () => HTMLTextAreaElement | null
|
||||||
|
|
||||||
|
prompt: Accessor<string>
|
||||||
|
setPrompt: (v: string) => void
|
||||||
|
|
||||||
|
mode: Accessor<PromptMode>
|
||||||
|
setMode: (m: PromptMode) => void
|
||||||
|
|
||||||
|
isPickerOpen: Accessor<boolean>
|
||||||
|
closePicker: () => void
|
||||||
|
|
||||||
|
ignoredAtPositions: Accessor<Set<number>>
|
||||||
|
setIgnoredAtPositions: (next: Set<number> | ((s: Set<number>) => Set<number>)) => void
|
||||||
|
|
||||||
|
getAttachments: Accessor<Attachment[]>
|
||||||
|
removeAttachment: (attachmentId: string) => void
|
||||||
|
|
||||||
|
submitOnEnter: Accessor<boolean>
|
||||||
|
onSend: () => void
|
||||||
|
|
||||||
|
selectPreviousHistory: (force?: boolean) => boolean
|
||||||
|
selectNextHistory: (force?: boolean) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
||||||
|
const insertNewlineAtCursor = () => {
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
const current = options.prompt()
|
||||||
|
const start = textarea ? textarea.selectionStart : current.length
|
||||||
|
const end = textarea ? textarea.selectionEnd : current.length
|
||||||
|
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
|
||||||
|
const nextCursor = start + 1
|
||||||
|
|
||||||
|
options.setPrompt(nextValue)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (!nextTextarea) return
|
||||||
|
nextTextarea.focus()
|
||||||
|
nextTextarea.setSelectionRange(nextCursor, nextCursor)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const currentText = options.prompt()
|
||||||
|
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||||
|
const isShellMode = options.mode() === "shell"
|
||||||
|
|
||||||
|
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) {
|
||||||
|
e.preventDefault()
|
||||||
|
options.setMode("shell")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isPickerOpen() && e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
options.closePicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShellMode) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
options.setMode("normal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
options.setMode("normal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace" || e.key === "Delete") {
|
||||||
|
const cursorPos = textarea.selectionStart
|
||||||
|
const text = currentText
|
||||||
|
|
||||||
|
const pastePlaceholderRegex = createPastedPlaceholderRegex()
|
||||||
|
let pasteMatch
|
||||||
|
|
||||||
|
while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) {
|
||||||
|
const placeholderStart = pasteMatch.index
|
||||||
|
const placeholderEnd = pasteMatch.index + pasteMatch[0].length
|
||||||
|
const pasteNumber = pasteMatch[1]
|
||||||
|
|
||||||
|
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||||
|
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||||
|
const isSelected =
|
||||||
|
textarea.selectionStart <= placeholderStart &&
|
||||||
|
textarea.selectionEnd >= placeholderEnd &&
|
||||||
|
textarea.selectionStart !== textarea.selectionEnd
|
||||||
|
|
||||||
|
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const currentAttachments = options.getAttachments()
|
||||||
|
const attachment = currentAttachments.find(
|
||||||
|
(a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
options.removeAttachment(attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePlaceholderRegex = createImagePlaceholderRegex()
|
||||||
|
let imageMatch
|
||||||
|
|
||||||
|
while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) {
|
||||||
|
const placeholderStart = imageMatch.index
|
||||||
|
const placeholderEnd = imageMatch.index + imageMatch[0].length
|
||||||
|
const imageNumber = imageMatch[1]
|
||||||
|
|
||||||
|
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||||
|
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||||
|
const isSelected =
|
||||||
|
textarea.selectionStart <= placeholderStart &&
|
||||||
|
textarea.selectionEnd >= placeholderEnd &&
|
||||||
|
textarea.selectionStart !== textarea.selectionEnd
|
||||||
|
|
||||||
|
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const currentAttachments = options.getAttachments()
|
||||||
|
const attachment = currentAttachments.find(
|
||||||
|
(a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
options.removeAttachment(attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionRegex = createMentionRegex()
|
||||||
|
let mentionMatch
|
||||||
|
|
||||||
|
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
|
||||||
|
const mentionStart = mentionMatch.index
|
||||||
|
const mentionEnd = mentionMatch.index + mentionMatch[0].length
|
||||||
|
const name = mentionMatch[1]
|
||||||
|
|
||||||
|
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd
|
||||||
|
const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart
|
||||||
|
const isSelected =
|
||||||
|
textarea.selectionStart <= mentionStart &&
|
||||||
|
textarea.selectionEnd >= mentionEnd &&
|
||||||
|
textarea.selectionStart !== textarea.selectionEnd
|
||||||
|
|
||||||
|
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||||
|
const currentAttachments = options.getAttachments()
|
||||||
|
const attachment = currentAttachments.find(
|
||||||
|
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
options.removeAttachment(attachment.id)
|
||||||
|
|
||||||
|
options.setIgnoredAtPositions((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(mentionStart)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const newText = text.substring(0, mentionStart) + text.substring(mentionEnd)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const isModified = e.metaKey || e.ctrlKey
|
||||||
|
|
||||||
|
// If the picker is open, Enter should select from it.
|
||||||
|
if (!isModified && options.isPickerOpen()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.submitOnEnter()) {
|
||||||
|
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
|
||||||
|
if (isModified) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
insertNewlineAtCursor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift+Enter always inserts a newline.
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// If the picker is open, avoid selecting an item on Enter.
|
||||||
|
if (options.isPickerOpen()) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
options.onSend()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: Cmd/Ctrl+Enter submits.
|
||||||
|
if (isModified) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (options.isPickerOpen()) {
|
||||||
|
options.closePicker()
|
||||||
|
}
|
||||||
|
options.onSend()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
const handled = options.selectPreviousHistory()
|
||||||
|
if (handled) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
const handled = options.selectNextHistory()
|
||||||
|
if (handled) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
packages/ui/src/components/prompt-input/usePromptPicker.ts
Normal file
272
packages/ui/src/components/prompt-input/usePromptPicker.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { Agent } from "../../types/session"
|
||||||
|
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||||
|
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||||
|
import type { PickerMode } from "./types"
|
||||||
|
|
||||||
|
type PickerItem =
|
||||||
|
| { type: "agent"; agent: Agent }
|
||||||
|
| { type: "file"; file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } }
|
||||||
|
| { type: "command"; command: SDKCommand }
|
||||||
|
|
||||||
|
type PromptPickerOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
sessionId: Accessor<string>
|
||||||
|
instanceFolder: Accessor<string>
|
||||||
|
|
||||||
|
prompt: Accessor<string>
|
||||||
|
setPrompt: (value: string) => void
|
||||||
|
getTextarea: () => HTMLTextAreaElement | null
|
||||||
|
|
||||||
|
instanceAgents: Accessor<Agent[]>
|
||||||
|
commands: Accessor<SDKCommand[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptPickerController = {
|
||||||
|
showPicker: Accessor<boolean>
|
||||||
|
pickerMode: Accessor<PickerMode>
|
||||||
|
searchQuery: Accessor<string>
|
||||||
|
atPosition: Accessor<number | null>
|
||||||
|
ignoredAtPositions: Accessor<Set<number>>
|
||||||
|
|
||||||
|
setShowPicker: Setter<boolean>
|
||||||
|
setPickerMode: Setter<PickerMode>
|
||||||
|
setSearchQuery: Setter<string>
|
||||||
|
setAtPosition: Setter<number | null>
|
||||||
|
setIgnoredAtPositions: Setter<Set<number>>
|
||||||
|
|
||||||
|
handleInput: (e: Event) => void
|
||||||
|
handlePickerSelect: (item: PickerItem) => void
|
||||||
|
handlePickerClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptPicker(options: PromptPickerOptions): PromptPickerController {
|
||||||
|
const [showPicker, setShowPicker] = createSignal(false)
|
||||||
|
const [pickerMode, setPickerMode] = createSignal<PickerMode>("mention")
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
|
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||||
|
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLTextAreaElement
|
||||||
|
const value = target.value
|
||||||
|
options.setPrompt(value)
|
||||||
|
|
||||||
|
const cursorPos = target.selectionStart
|
||||||
|
|
||||||
|
// Slash command picker (only when editing the command token: "/<query>")
|
||||||
|
if (value.startsWith("/") && cursorPos >= 1) {
|
||||||
|
const firstWhitespaceIndex = value.slice(1).search(/\s/)
|
||||||
|
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
|
||||||
|
|
||||||
|
if (cursorPos <= tokenEnd) {
|
||||||
|
setPickerMode("command")
|
||||||
|
setAtPosition(0)
|
||||||
|
setSearchQuery(value.substring(1, cursorPos))
|
||||||
|
setShowPicker(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textBeforeCursor = value.substring(0, cursorPos)
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
||||||
|
|
||||||
|
const previousAtPosition = atPosition()
|
||||||
|
|
||||||
|
if (lastAtIndex === -1) {
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
||||||
|
setIgnoredAtPositions((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(previousAtPosition)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
|
||||||
|
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
|
||||||
|
|
||||||
|
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
||||||
|
if (!ignoredAtPositions().has(lastAtIndex)) {
|
||||||
|
setPickerMode("mention")
|
||||||
|
setAtPosition(lastAtIndex)
|
||||||
|
setSearchQuery(textAfterAt)
|
||||||
|
setShowPicker(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPicker(false)
|
||||||
|
setAtPosition(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePickerSelect(item: PickerItem) {
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
|
if (item.type === "command") {
|
||||||
|
const name = item.command.name
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
|
||||||
|
const afterSlash = currentPrompt.slice(1)
|
||||||
|
const firstWhitespaceIndex = afterSlash.search(/\s/)
|
||||||
|
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
|
||||||
|
|
||||||
|
const before = ""
|
||||||
|
const after = currentPrompt.substring(tokenEnd)
|
||||||
|
const newPrompt = before + `/${name} ` + after
|
||||||
|
options.setPrompt(newPrompt)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (nextTextarea) {
|
||||||
|
const newCursorPos = `/${name} `.length
|
||||||
|
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
nextTextarea.focus()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
} else if (item.type === "agent") {
|
||||||
|
const agentName = item.agent.name
|
||||||
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existingAttachments.some(
|
||||||
|
(att) => att.source.type === "agent" && att.source.name === agentName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
const attachment = createAgentAttachment(agentName)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const pos = atPosition()
|
||||||
|
const cursorPos = textarea?.selectionStart || 0
|
||||||
|
|
||||||
|
if (pos !== null) {
|
||||||
|
const before = currentPrompt.substring(0, pos)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
const attachmentText = `@${agentName}`
|
||||||
|
const newPrompt = before + attachmentText + " " + after
|
||||||
|
options.setPrompt(newPrompt)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (nextTextarea) {
|
||||||
|
const newCursorPos = pos + attachmentText.length + 1
|
||||||
|
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
} else if (item.type === "file") {
|
||||||
|
const displayPath = item.file.path
|
||||||
|
const relativePath = item.file.relativePath ?? displayPath
|
||||||
|
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const pos = atPosition()
|
||||||
|
const cursorPos = textarea?.selectionStart || 0
|
||||||
|
const folderMention =
|
||||||
|
relativePath === "." || relativePath === ""
|
||||||
|
? "/"
|
||||||
|
: relativePath.replace(/\/+$/, "") + "/"
|
||||||
|
|
||||||
|
if (pos !== null) {
|
||||||
|
const before = currentPrompt.substring(0, pos + 1)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
const newPrompt = before + folderMention + after
|
||||||
|
options.setPrompt(newPrompt)
|
||||||
|
setSearchQuery(folderMention)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (nextTextarea) {
|
||||||
|
const newCursorPos = pos + 1 + folderMention.length
|
||||||
|
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
||||||
|
const pathSegments = normalizedPath.split("/")
|
||||||
|
const filename = (() => {
|
||||||
|
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||||
|
return candidate === "." ? "/" : candidate
|
||||||
|
})()
|
||||||
|
|
||||||
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existingAttachments.some(
|
||||||
|
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
const attachment = createFileAttachment(
|
||||||
|
normalizedPath,
|
||||||
|
filename,
|
||||||
|
"text/plain",
|
||||||
|
undefined,
|
||||||
|
options.instanceFolder(),
|
||||||
|
)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const pos = atPosition()
|
||||||
|
const cursorPos = textarea?.selectionStart || 0
|
||||||
|
|
||||||
|
if (pos !== null) {
|
||||||
|
const before = currentPrompt.substring(0, pos)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
const attachmentText = `@${normalizedPath}`
|
||||||
|
const newPrompt = before + attachmentText + " " + after
|
||||||
|
options.setPrompt(newPrompt)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (nextTextarea) {
|
||||||
|
const newCursorPos = pos + attachmentText.length + 1
|
||||||
|
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPicker(false)
|
||||||
|
setAtPosition(null)
|
||||||
|
setSearchQuery("")
|
||||||
|
textarea?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePickerClose() {
|
||||||
|
const pos = atPosition()
|
||||||
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
}
|
||||||
|
setShowPicker(false)
|
||||||
|
setAtPosition(null)
|
||||||
|
setSearchQuery("")
|
||||||
|
setTimeout(() => options.getTextarea()?.focus(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showPicker,
|
||||||
|
pickerMode,
|
||||||
|
searchQuery,
|
||||||
|
atPosition,
|
||||||
|
ignoredAtPositions,
|
||||||
|
|
||||||
|
setShowPicker,
|
||||||
|
setPickerMode,
|
||||||
|
setSearchQuery,
|
||||||
|
setAtPosition,
|
||||||
|
setIgnoredAtPositions,
|
||||||
|
|
||||||
|
handleInput,
|
||||||
|
handlePickerSelect,
|
||||||
|
handlePickerClose,
|
||||||
|
}
|
||||||
|
}
|
||||||
203
packages/ui/src/components/prompt-input/usePromptState.ts
Normal file
203
packages/ui/src/components/prompt-input/usePromptState.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
|
import { addToHistory, getHistory } from "../../stores/message-history"
|
||||||
|
import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
type GetTextarea = () => HTMLTextAreaElement | undefined | null
|
||||||
|
|
||||||
|
type PromptStateOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
sessionId: Accessor<string>
|
||||||
|
instanceFolder: Accessor<string>
|
||||||
|
onSessionDraftLoaded?: (draft: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistorySelectOptions = {
|
||||||
|
force?: boolean
|
||||||
|
isPickerOpen: boolean
|
||||||
|
getTextarea: GetTextarea
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptState = {
|
||||||
|
prompt: Accessor<string>
|
||||||
|
setPrompt: (value: string) => void
|
||||||
|
clearPrompt: () => void
|
||||||
|
|
||||||
|
draftLoadedNonce: Accessor<number>
|
||||||
|
|
||||||
|
history: Accessor<string[]>
|
||||||
|
historyIndex: Accessor<number>
|
||||||
|
historyDraft: Accessor<string | null>
|
||||||
|
|
||||||
|
resetHistoryNavigation: () => void
|
||||||
|
clearHistoryDraft: () => void
|
||||||
|
recordHistoryEntry: (entry: string) => Promise<void>
|
||||||
|
|
||||||
|
selectPreviousHistory: (options: HistorySelectOptions) => boolean
|
||||||
|
selectNextHistory: (options: HistorySelectOptions) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 100
|
||||||
|
|
||||||
|
export function usePromptState(options: PromptStateOptions): PromptState {
|
||||||
|
const [prompt, setPromptInternal] = createSignal("")
|
||||||
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||||
|
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||||
|
const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0)
|
||||||
|
|
||||||
|
const setPrompt = (value: string) => {
|
||||||
|
setPromptInternal(value)
|
||||||
|
// Persist drafts only when the user is at the "fresh" position (not browsing history).
|
||||||
|
// This keeps the bottom-of-history draft stable even if the user edits recalled history entries.
|
||||||
|
if (historyIndex() === -1) {
|
||||||
|
setSessionDraftPrompt(options.instanceId(), options.sessionId(), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPrompt = () => {
|
||||||
|
clearSessionDraftPrompt(options.instanceId(), options.sessionId())
|
||||||
|
setPromptInternal("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetHistoryNavigation = () => {
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
setHistoryDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistoryDraft = () => {
|
||||||
|
setHistoryDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => `${options.instanceId()}:${options.sessionId()}`,
|
||||||
|
() => {
|
||||||
|
const instanceId = options.instanceId()
|
||||||
|
const sessionId = options.sessionId()
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
// Persist the previous session's draft when switching sessions.
|
||||||
|
setSessionDraftPrompt(instanceId, sessionId, prompt())
|
||||||
|
})
|
||||||
|
|
||||||
|
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
|
||||||
|
|
||||||
|
setPromptInternal(storedPrompt)
|
||||||
|
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
|
||||||
|
|
||||||
|
resetHistoryNavigation()
|
||||||
|
|
||||||
|
setDraftLoadedNonce((prev) => prev + 1)
|
||||||
|
options.onSessionDraftLoaded?.(storedPrompt)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
const loaded = await getHistory(options.instanceFolder())
|
||||||
|
setHistory(loaded)
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
const recordHistoryEntry = async (entry: string) => {
|
||||||
|
try {
|
||||||
|
await addToHistory(options.instanceFolder(), entry)
|
||||||
|
setHistory((prev) => {
|
||||||
|
const next = [entry, ...prev]
|
||||||
|
if (next.length > HISTORY_LIMIT) {
|
||||||
|
next.length = HISTORY_LIMIT
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
} catch (historyError) {
|
||||||
|
log.error("Failed to update prompt history:", historyError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUseHistory = (selectOptions: HistorySelectOptions) => {
|
||||||
|
if (selectOptions.force) return true
|
||||||
|
if (selectOptions.isPickerOpen) return false
|
||||||
|
|
||||||
|
const textarea = selectOptions.getTextarea()
|
||||||
|
if (!textarea) return false
|
||||||
|
|
||||||
|
// Only require the cursor to be at the buffer start when *entering* history navigation.
|
||||||
|
// Once we're already navigating history (historyIndex >= 0), allow ArrowUp/ArrowDown
|
||||||
|
// regardless of cursor position (we focus the end of the entry).
|
||||||
|
if (historyIndex() !== -1) return true
|
||||||
|
|
||||||
|
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusTextareaEnd = (getTextarea: GetTextarea) => {
|
||||||
|
const textarea = getTextarea()
|
||||||
|
if (!textarea) return
|
||||||
|
setTimeout(() => {
|
||||||
|
const next = getTextarea()
|
||||||
|
if (!next) return
|
||||||
|
const pos = next.value.length
|
||||||
|
next.setSelectionRange(pos, pos)
|
||||||
|
next.focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPreviousHistory = (selectOptions: HistorySelectOptions) => {
|
||||||
|
const entries = history()
|
||||||
|
if (entries.length === 0) return false
|
||||||
|
if (!canUseHistory(selectOptions)) return false
|
||||||
|
|
||||||
|
if (historyIndex() === -1) {
|
||||||
|
setHistoryDraft(prompt())
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||||
|
setHistoryIndex(newIndex)
|
||||||
|
setPrompt(entries[newIndex])
|
||||||
|
focusTextareaEnd(selectOptions.getTextarea)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNextHistory = (selectOptions: HistorySelectOptions) => {
|
||||||
|
const entries = history()
|
||||||
|
if (entries.length === 0) return false
|
||||||
|
if (!canUseHistory(selectOptions)) return false
|
||||||
|
if (historyIndex() === -1) return false
|
||||||
|
|
||||||
|
const newIndex = historyIndex() - 1
|
||||||
|
if (newIndex >= 0) {
|
||||||
|
setHistoryIndex(newIndex)
|
||||||
|
setPrompt(entries[newIndex])
|
||||||
|
} else {
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
const draft = historyDraft() ?? getSessionDraftPrompt(options.instanceId(), options.sessionId())
|
||||||
|
setPrompt(draft ?? "")
|
||||||
|
setHistoryDraft(null)
|
||||||
|
}
|
||||||
|
focusTextareaEnd(selectOptions.getTextarea)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt,
|
||||||
|
setPrompt,
|
||||||
|
clearPrompt,
|
||||||
|
|
||||||
|
draftLoadedNonce,
|
||||||
|
|
||||||
|
history,
|
||||||
|
historyIndex,
|
||||||
|
historyDraft,
|
||||||
|
|
||||||
|
resetHistoryNavigation,
|
||||||
|
clearHistoryDraft,
|
||||||
|
recordHistoryEntry,
|
||||||
|
|
||||||
|
selectPreviousHistory,
|
||||||
|
selectNextHistory,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (allowExternalConnections()) {
|
if (!allowExternalConnections()) {
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
return []
|
||||||
}
|
}
|
||||||
return list.filter((address) => address.scope === "loopback")
|
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||||
|
return list.filter((address) => address.scope !== "loopback")
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -311,34 +312,27 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
<For each={displayAddresses()}>
|
<Show when={meta()?.localUrl}>
|
||||||
{(address) => {
|
{(url) => {
|
||||||
const expandedState = () => expandedUrl() === address.url
|
const value = () => url()
|
||||||
const qr = () => qrCodes()[address.url]
|
const expandedState = () => expandedUrl() === value()
|
||||||
const scopeLabel = () =>
|
const qr = () => qrCodes()[value()]
|
||||||
address.scope === "external"
|
|
||||||
? t("remoteAccess.address.scope.network")
|
|
||||||
: address.scope === "loopback"
|
|
||||||
? t("remoteAccess.address.scope.loopback")
|
|
||||||
: t("remoteAccess.address.scope.internal")
|
|
||||||
return (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{address.url}</p>
|
<p class="remote-address-url">{value()}</p>
|
||||||
<p class="remote-address-meta">
|
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
||||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
||||||
<ExternalLink class="remote-icon" />
|
<ExternalLink class="remote-icon" />
|
||||||
{t("remoteAccess.address.open")}
|
{t("remoteAccess.address.open")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="remote-pill"
|
class="remote-pill"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void toggleExpanded(address.url)}
|
onClick={() => void toggleExpanded(value())}
|
||||||
aria-expanded={expandedState()}
|
aria-expanded={expandedState()}
|
||||||
>
|
>
|
||||||
<Link2 class="remote-icon" />
|
<Link2 class="remote-icon" />
|
||||||
@@ -352,7 +346,60 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
{(dataUrl) => (
|
{(dataUrl) => (
|
||||||
<img
|
<img
|
||||||
src={dataUrl()}
|
src={dataUrl()}
|
||||||
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
|
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
<For each={displayAddresses()}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||||
class="remote-qr-img"
|
class="remote-qr-img"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
|
|||||||
import type { SessionStatus } from "../types/session"
|
import type { SessionStatus } from "../types/session"
|
||||||
import type { SessionThread } from "../stores/session-state"
|
import type { SessionThread } from "../stores/session-state"
|
||||||
import { getSessionStatus } from "../stores/session-status"
|
import { getSessionStatus } from "../stores/session-status"
|
||||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare } from "lucide-solid"
|
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import SessionRenameDialog from "./session-rename-dialog"
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
setActiveSessionFromList,
|
setActiveSessionFromList,
|
||||||
toggleSessionParentExpanded,
|
toggleSessionParentExpanded,
|
||||||
} from "../stores/sessions"
|
} from "../stores/sessions"
|
||||||
|
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -49,7 +50,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||||
|
|
||||||
const [filterQuery, setFilterQuery] = createSignal("")
|
const [filterQuery, setFilterQuery] = createSignal("")
|
||||||
const normalizedQuery = createMemo(() => filterQuery().trim().toLowerCase())
|
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||||
|
|
||||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||||
|
|
||||||
@@ -353,6 +354,19 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
if (!session()) {
|
if (!session()) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worktreeSlug = createMemo(() => {
|
||||||
|
if (rowProps.isChild) return "root"
|
||||||
|
return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showWorktreeBadge = createMemo(() => {
|
||||||
|
if (rowProps.isChild) return false
|
||||||
|
if (getGitRepoStatus(props.instanceId) === false) return false
|
||||||
|
const slug = worktreeSlug()
|
||||||
|
return Boolean(slug) && slug !== "root"
|
||||||
|
})
|
||||||
|
|
||||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||||
@@ -459,6 +473,12 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{statusText()}
|
{statusText()}
|
||||||
</span>
|
</span>
|
||||||
|
<Show when={showWorktreeBadge()}>
|
||||||
|
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
|
||||||
|
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
<span class="worktree-indicator-label">{worktreeSlug()}</span>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-actions">
|
<div class="session-item-actions">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { useI18n } from "../../lib/i18n"
|
|||||||
interface ContextUsagePanelProps {
|
interface ContextUsagePanelProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
|
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
|
||||||
const headingClass = "text-xs font-semibold text-muted uppercase tracking-wide"
|
|
||||||
|
|
||||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -31,26 +31,16 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||||||
|
|
||||||
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||||
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||||
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
|
|
||||||
const availableTokens = createMemo(() => info().contextAvailableTokens)
|
|
||||||
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
|
|
||||||
const costValue = createMemo(() => {
|
const costValue = createMemo(() => {
|
||||||
const value = info().isSubscriptionModel ? 0 : info().cost
|
const value = info().isSubscriptionModel ? 0 : info().cost
|
||||||
return value > 0 ? value : 0
|
return value > 0 ? value : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const formatTokenValue = (value: number | null | undefined) => {
|
|
||||||
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
|
|
||||||
return formatTokenTotal(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
|
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
<div class={`session-context-panel px-4 py-2 ${props.class ?? ""}`}>
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||||
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
|
||||||
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||||
@@ -64,18 +54,6 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
|
||||||
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
|
|
||||||
<div class={chipClass}>
|
|
||||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
|
|
||||||
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
|
||||||
</div>
|
|
||||||
<div class={chipClass}>
|
|
||||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
|
|
||||||
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
|
import { Show, createMemo, createEffect, on, type Component } from "solid-js"
|
||||||
import { Expand } from "lucide-solid"
|
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import MessageSection from "../message-section"
|
import MessageSection from "../message-section"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import PromptInput from "../prompt-input"
|
import PromptInput from "../prompt-input"
|
||||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
|
||||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import { instances } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||||
@@ -15,6 +14,7 @@ import { showAlertDialog } from "../../stores/alerts"
|
|||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { requestData } from "../../lib/opencode-api"
|
import { requestData } from "../../lib/opencode-api"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -53,52 +53,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||||
|
|
||||||
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
let promptInputApi: PromptInputApi | null = null
|
||||||
if (attachment.source.type !== "text") return
|
let pendingPromptText: string | null = null
|
||||||
|
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
||||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
|
||||||
const value = attachment.source.value
|
|
||||||
const match = attachment.display.match(/pasted #(\d+)/)
|
|
||||||
const placeholder = match ? `[pasted #${match[1]}]` : null
|
|
||||||
|
|
||||||
const currentText = textarea?.value ?? ""
|
|
||||||
|
|
||||||
let nextText = currentText
|
|
||||||
let selectionTarget: number | null = null
|
|
||||||
|
|
||||||
if (placeholder) {
|
|
||||||
const placeholderIndex = currentText.indexOf(placeholder)
|
|
||||||
if (placeholderIndex !== -1) {
|
|
||||||
nextText =
|
|
||||||
currentText.substring(0, placeholderIndex) +
|
|
||||||
value +
|
|
||||||
currentText.substring(placeholderIndex + placeholder.length)
|
|
||||||
selectionTarget = placeholderIndex + value.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextText === currentText) {
|
|
||||||
if (textarea) {
|
|
||||||
const start = textarea.selectionStart
|
|
||||||
const end = textarea.selectionEnd
|
|
||||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
|
||||||
selectionTarget = start + value.length
|
|
||||||
} else {
|
|
||||||
nextText = currentText + value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textarea) {
|
|
||||||
textarea.value = nextText
|
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
|
||||||
textarea.focus()
|
|
||||||
if (selectionTarget !== null) {
|
|
||||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
let rootRef: HTMLDivElement | undefined
|
let rootRef: HTMLDivElement | undefined
|
||||||
@@ -135,6 +92,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
// Defer until the session pane is visible and the textarea is mounted.
|
// Defer until the session pane is visible and the textarea is mounted.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (promptInputApi) {
|
||||||
|
promptInputApi.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
|
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
|
||||||
if (!textarea) return
|
if (!textarea) return
|
||||||
if (textarea.disabled) return
|
if (textarea.disabled) return
|
||||||
@@ -149,7 +111,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const currentSession = session()
|
const currentSession = session()
|
||||||
@@ -158,18 +119,31 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
|
function registerPromptInputApi(api: PromptInputApi) {
|
||||||
quoteHandler = handler
|
promptInputApi = api
|
||||||
|
|
||||||
|
if (pendingPromptText) {
|
||||||
|
api.setPromptText(pendingPromptText, { focus: true })
|
||||||
|
pendingPromptText = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSelectionInsert) {
|
||||||
|
api.insertSelection(pendingSelectionInsert.text, pendingSelectionInsert.mode)
|
||||||
|
pendingSelectionInsert = null
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (quoteHandler === handler) {
|
if (promptInputApi === api) {
|
||||||
quoteHandler = null
|
promptInputApi = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuoteSelection(text: string, mode: "quote" | "code") {
|
function handleQuoteSelection(text: string, mode: PromptInsertMode) {
|
||||||
if (quoteHandler) {
|
if (promptInputApi) {
|
||||||
quoteHandler(text, mode)
|
promptInputApi.insertSelection(text, mode)
|
||||||
|
} else {
|
||||||
|
pendingSelectionInsert = { text, mode }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,11 +205,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
const restoredText = getUserMessageText(messageId)
|
const restoredText = getUserMessageText(messageId)
|
||||||
if (restoredText) {
|
if (restoredText) {
|
||||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
if (promptInputApi) {
|
||||||
if (textarea) {
|
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||||
textarea.value = restoredText
|
} else {
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
pendingPromptText = restoredText
|
||||||
textarea.focus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -272,11 +245,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||||
|
|
||||||
if (restoredText) {
|
if (restoredText) {
|
||||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
if (promptInputApi) {
|
||||||
if (textarea) {
|
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||||
textarea.value = restoredText
|
} else {
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
pendingPromptText = restoredText
|
||||||
textarea.focus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -328,37 +300,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
<Show when={attachments().length > 0}>
|
<Show when={attachments().length > 0}>
|
||||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
<PromptAttachmentsBar
|
||||||
<For each={attachments()}>
|
attachments={attachments()}
|
||||||
{(attachment) => {
|
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
|
||||||
const isText = attachment.source.type === "text"
|
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||||
return (
|
/>
|
||||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
|
||||||
<span class="font-mono">{attachment.display}</span>
|
|
||||||
<Show when={isText}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="attachment-expand"
|
|
||||||
onClick={() => handleExpandTextAttachment(attachment)}
|
|
||||||
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
|
||||||
title={t("sessionView.attachments.insertPastedTextTitle")}
|
|
||||||
>
|
|
||||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="attachment-remove"
|
|
||||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
|
||||||
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
@@ -371,7 +317,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
isSessionBusy={sessionBusy()}
|
isSessionBusy={sessionBusy()}
|
||||||
disabled={sessionNeedsInput()}
|
disabled={sessionNeedsInput()}
|
||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
registerQuoteHandler={registerQuoteHandler}
|
registerPromptInputApi={registerPromptInputApi}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,9 +132,16 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
|||||||
if (!multi) {
|
if (!multi) {
|
||||||
// When switching a radio to custom, clear existing selection first.
|
// When switching a radio to custom, clear existing selection first.
|
||||||
updateAnswer(questionIndex, [])
|
updateAnswer(questionIndex, [])
|
||||||
|
toggleOption(questionIndex, value)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOption(questionIndex, value)
|
// For multi-select, focusing the input should never toggle an existing custom value off.
|
||||||
|
// Ensure the current input value is selected; removal is handled by unchecking Custom.
|
||||||
|
const existing = answers()[questionIndex] ?? []
|
||||||
|
if (!existing.includes(value)) {
|
||||||
|
updateAnswer(questionIndex, [...existing, value])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
let lastWorkspaceId: string | null = null
|
let lastWorkspaceId: string | null = null
|
||||||
let lastQuery = ""
|
let lastQuery = ""
|
||||||
|
let lastCommandQuery = ""
|
||||||
let inflightWorkspaceId: string | null = null
|
let inflightWorkspaceId: string | null = null
|
||||||
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
|
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
|
||||||
let activeRequestId = 0
|
let activeRequestId = 0
|
||||||
@@ -243,6 +244,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
setLoadingState("idle")
|
setLoadingState("idle")
|
||||||
lastWorkspaceId = null
|
lastWorkspaceId = null
|
||||||
lastQuery = ""
|
lastQuery = ""
|
||||||
|
lastCommandQuery = ""
|
||||||
activeRequestId = 0
|
activeRequestId = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,8 +275,6 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
if (mode() !== "mention") return
|
if (mode() !== "mention") return
|
||||||
@@ -303,6 +303,37 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.open) return
|
||||||
|
if (mode() !== "command") return
|
||||||
|
|
||||||
|
const query = props.searchQuery
|
||||||
|
const count = filteredCommands().length
|
||||||
|
|
||||||
|
if (query !== lastCommandQuery) {
|
||||||
|
lastCommandQuery = query
|
||||||
|
setSelectedIndex(0)
|
||||||
|
resetScrollPosition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count <= 0) {
|
||||||
|
if (selectedIndex() !== 0) {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = selectedIndex()
|
||||||
|
if (current < 0) {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current >= count) {
|
||||||
|
setSelectedIndex(count - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const allItems = (): PickerItem[] => {
|
const allItems = (): PickerItem[] => {
|
||||||
const items: PickerItem[] = []
|
const items: PickerItem[] = []
|
||||||
if (mode() === "command") {
|
if (mode() === "command") {
|
||||||
@@ -335,20 +366,24 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
|
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
|
||||||
scrollToSelected()
|
scrollToSelected()
|
||||||
} else if (e.key === "ArrowUp") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||||
scrollToSelected()
|
scrollToSelected()
|
||||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
const selected = items[selectedIndex()]
|
const selected = items[selectedIndex()]
|
||||||
if (selected) {
|
if (selected) {
|
||||||
handleSelect(selected)
|
handleSelect(selected)
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,12 +437,12 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
<Show when={mode() === "command" && commandCount() > 0}>
|
<Show when={mode() === "command" && commandCount() > 0}>
|
||||||
<div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div>
|
<div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div>
|
||||||
<For each={filteredCommands()}>
|
<For each={filteredCommands()}>
|
||||||
{(command) => {
|
{(command, index) => {
|
||||||
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
|
const isSelected = () => index() === selectedIndex()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`dropdown-item ${itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""}`}
|
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
data-picker-selected={isSelected()}
|
||||||
onClick={() => handleSelect({ type: "command", command })}
|
onClick={() => handleSelect({ type: "command", command })}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
|
|||||||
429
packages/ui/src/components/worktree-selector.tsx
Normal file
429
packages/ui/src/components/worktree-selector.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import { Select } from "@kobalte/core/select"
|
||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { ChevronDown, Copy, Trash2 } from "lucide-solid"
|
||||||
|
import type { WorktreeDescriptor } from "../../../server/src/api-types"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import {
|
||||||
|
createWorktree,
|
||||||
|
deleteWorktree,
|
||||||
|
getParentSessionId,
|
||||||
|
getGitRepoStatus,
|
||||||
|
getWorktreeSlugForParentSession,
|
||||||
|
getWorktrees,
|
||||||
|
reloadWorktreeMap,
|
||||||
|
reloadWorktrees,
|
||||||
|
setWorktreeSlugForParentSession,
|
||||||
|
} from "../stores/worktrees"
|
||||||
|
import { sessions } from "../stores/sessions"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
type WorktreeOption =
|
||||||
|
| { kind: "action"; key: "__create__"; label: string }
|
||||||
|
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||||
|
|
||||||
|
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
|
||||||
|
|
||||||
|
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||||
|
// Prevent Select.Item from treating this as a selection.
|
||||||
|
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation?.()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input: string): string {
|
||||||
|
return (input ?? "").replace(/\\/g, "/").replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePath(fromDir: string, toDir: string): string {
|
||||||
|
const from = normalizePath(fromDir)
|
||||||
|
const to = normalizePath(toDir)
|
||||||
|
if (!from || !to) return to || from || ""
|
||||||
|
if (from === to) return "."
|
||||||
|
|
||||||
|
const fromParts = from.split("/").filter(Boolean)
|
||||||
|
const toParts = to.split("/").filter(Boolean)
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
while (i < fromParts.length && i < toParts.length) {
|
||||||
|
const a = fromParts[i]
|
||||||
|
const b = toParts[i]
|
||||||
|
if (!a || !b) break
|
||||||
|
if (a.toLowerCase() !== b.toLowerCase()) break
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = fromParts.length - i
|
||||||
|
const down = toParts.slice(i)
|
||||||
|
const relParts: string[] = []
|
||||||
|
for (let j = 0; j < up; j++) relParts.push("..")
|
||||||
|
relParts.push(...down)
|
||||||
|
return relParts.join("/") || "."
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorktreeSelectorProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
const [createOpen, setCreateOpen] = createSignal(false)
|
||||||
|
const [createSlug, setCreateSlug] = createSignal("")
|
||||||
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
|
|
||||||
|
const [deleteOpen, setDeleteOpen] = createSignal(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||||
|
const [forceDelete, setForceDelete] = createSignal(false)
|
||||||
|
const [isDeleting, setIsDeleting] = createSignal(false)
|
||||||
|
|
||||||
|
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||||
|
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||||
|
const parentId = createMemo(() => getParentSessionId(props.instanceId, props.sessionId))
|
||||||
|
const currentSlug = createMemo(() => getWorktreeSlugForParentSession(props.instanceId, parentId()))
|
||||||
|
|
||||||
|
const gitRepoStatus = createMemo(() => getGitRepoStatus(props.instanceId))
|
||||||
|
const worktreesUnavailable = createMemo(() => gitRepoStatus() === false)
|
||||||
|
const dropdownDisabled = createMemo(() => isChildSession() || worktreesUnavailable())
|
||||||
|
|
||||||
|
const worktreeOptions = createMemo<WorktreeOption[]>(() => {
|
||||||
|
const list = getWorktrees(props.instanceId)
|
||||||
|
const mapped: WorktreeOption[] = list.map((wt) => ({
|
||||||
|
kind: "worktree",
|
||||||
|
key: wt.slug,
|
||||||
|
slug: wt.slug,
|
||||||
|
directory: wt.directory,
|
||||||
|
raw: wt,
|
||||||
|
}))
|
||||||
|
return [CREATE_OPTION, ...mapped]
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||||
|
const slug = currentSlug()
|
||||||
|
const match = worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === slug)
|
||||||
|
if (match) return match
|
||||||
|
// Fallback to root if mapped slug is missing.
|
||||||
|
return worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === "root")
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||||
|
if (opt.slug === "root") return
|
||||||
|
setForceDelete(false)
|
||||||
|
setDeleteTarget(opt)
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = createMemo(() => {
|
||||||
|
const list = getWorktrees(props.instanceId)
|
||||||
|
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayPathFor = (directory: string) => {
|
||||||
|
const base = repoRoot()
|
||||||
|
if (!base) return directory
|
||||||
|
return relativePath(base, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyPath = async (directory: string) => {
|
||||||
|
try {
|
||||||
|
const ok = await copyToClipboard(directory)
|
||||||
|
showToastNotification({ message: ok ? "Copied worktree path" : "Failed to copy path", variant: ok ? "success" : "error" })
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to copy worktree path", error)
|
||||||
|
showToastNotification({ message: "Failed to copy path", variant: "error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = async (value: WorktreeOption | null) => {
|
||||||
|
if (worktreesUnavailable()) return
|
||||||
|
if (!value) return
|
||||||
|
if (value.kind === "action") {
|
||||||
|
setIsOpen(false)
|
||||||
|
setCreateSlug("")
|
||||||
|
setCreateOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sidebar-selector">
|
||||||
|
<Select<WorktreeOption>
|
||||||
|
open={isOpen()}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
value={selectedOption() ?? null}
|
||||||
|
onChange={(value) => {
|
||||||
|
void handleChange(value).catch((error) => log.warn("Failed to change worktree", error))
|
||||||
|
}}
|
||||||
|
options={worktreeOptions()}
|
||||||
|
optionValue="key"
|
||||||
|
optionTextValue={(opt) => (opt.kind === "action" ? opt.label : opt.slug)}
|
||||||
|
placeholder="Worktree"
|
||||||
|
disabled={dropdownDisabled()}
|
||||||
|
itemComponent={(itemProps) => {
|
||||||
|
const opt = itemProps.item.rawValue
|
||||||
|
if (opt.kind === "action") {
|
||||||
|
return (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
|
||||||
|
<div class="selector-option-content w-full">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{opt.label}</Select.ItemLabel>
|
||||||
|
<Select.ItemDescription class="selector-option-description">New from current branch</Select.ItemDescription>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
|
||||||
|
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Select.ItemLabel class="selector-option-label flex-1 min-w-0 truncate">
|
||||||
|
{opt.slug === "root" ? "Workspace" : opt.slug}
|
||||||
|
</Select.ItemLabel>
|
||||||
|
<Show when={opt.slug !== "root"}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
|
||||||
|
aria-label="Delete worktree"
|
||||||
|
title="Delete worktree"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
preventSelectPress(event)
|
||||||
|
setIsOpen(false)
|
||||||
|
openDeleteDialog(opt)
|
||||||
|
}}
|
||||||
|
onPointerUp={preventSelectPress}
|
||||||
|
onMouseDown={preventSelectPress}
|
||||||
|
onMouseUp={preventSelectPress}
|
||||||
|
onClick={preventSelectPress}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
class="selector-option-description flex-1 min-w-0 truncate font-mono"
|
||||||
|
title={opt.directory}
|
||||||
|
>
|
||||||
|
{displayPathFor(opt.directory)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
|
||||||
|
aria-label="Copy path"
|
||||||
|
title="Copy path"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
preventSelectPress(event)
|
||||||
|
void (async () => {
|
||||||
|
await handleCopyPath(opt.directory)
|
||||||
|
setIsOpen(false)
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
onPointerUp={preventSelectPress}
|
||||||
|
onMouseDown={preventSelectPress}
|
||||||
|
onMouseUp={preventSelectPress}
|
||||||
|
onClick={preventSelectPress}
|
||||||
|
>
|
||||||
|
<Copy class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<WorktreeOption>>
|
||||||
|
{(state) => {
|
||||||
|
if (worktreesUnavailable()) {
|
||||||
|
return (
|
||||||
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: Unavailable</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = state.selectedOption()
|
||||||
|
const label = value && value.kind === "worktree" ? (value.slug === "root" ? "Workspace" : value.slug) : "Workspace"
|
||||||
|
return (
|
||||||
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: {label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Dialog open={createOpen()} onOpenChange={(open) => !open && setCreateOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">Creates a git worktree</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-medium text-muted uppercase tracking-wide">Name</label>
|
||||||
|
<input
|
||||||
|
class="form-input w-full"
|
||||||
|
value={createSlug()}
|
||||||
|
onInput={(e) => setCreateSlug(e.currentTarget.value)}
|
||||||
|
placeholder="worktree-name"
|
||||||
|
disabled={isCreating()}
|
||||||
|
spellcheck={false}
|
||||||
|
autocapitalize="off"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => setCreateOpen(false)}
|
||||||
|
disabled={isCreating()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary"
|
||||||
|
disabled={
|
||||||
|
isCreating() ||
|
||||||
|
!createSlug().trim() ||
|
||||||
|
createSlug().trim() === "root" ||
|
||||||
|
/[\x00-\x1F\x7F]/.test(createSlug())
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const slug = createSlug().trim()
|
||||||
|
void (async () => {
|
||||||
|
setIsCreating(true)
|
||||||
|
await createWorktree(props.instanceId, slug)
|
||||||
|
await reloadWorktrees(props.instanceId)
|
||||||
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), slug)
|
||||||
|
setCreateOpen(false)
|
||||||
|
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("Failed to create worktree", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : "Failed to create worktree",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsCreating(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCreating() ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={deleteTarget()}>
|
||||||
|
{(target) => (
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
||||||
|
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
||||||
|
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceDelete()}
|
||||||
|
onChange={(e) => setForceDelete(e.currentTarget.checked)}
|
||||||
|
disabled={isDeleting()}
|
||||||
|
/>
|
||||||
|
Force delete (discard local changes)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
disabled={isDeleting()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary"
|
||||||
|
disabled={isDeleting() || !deleteTarget()}
|
||||||
|
onClick={() => {
|
||||||
|
const target = deleteTarget()
|
||||||
|
if (!target) {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
setIsDeleting(true)
|
||||||
|
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||||
|
await reloadWorktrees(props.instanceId)
|
||||||
|
await reloadWorktreeMap(props.instanceId)
|
||||||
|
|
||||||
|
if (currentSlug() === target.slug) {
|
||||||
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteOpen(false)
|
||||||
|
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("Failed to delete worktree", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsDeleting(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeleting() ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,12 +20,14 @@ import type {
|
|||||||
WorkspaceLogEntry,
|
WorkspaceLogEntry,
|
||||||
WorkspaceEventPayload,
|
WorkspaceEventPayload,
|
||||||
WorkspaceEventType,
|
WorkspaceEventType,
|
||||||
|
WorktreeListResponse,
|
||||||
|
WorktreeMap,
|
||||||
|
WorktreeCreateRequest,
|
||||||
} from "../../../server/src/api-types"
|
} from "../../../server/src/api-types"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
|
||||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE
|
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined
|
||||||
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||||
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||||
@@ -127,6 +129,39 @@ export const serverApi = {
|
|||||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
|
||||||
|
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||||
|
return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise<void> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options?.force) {
|
||||||
|
params.set("force", "true")
|
||||||
|
}
|
||||||
|
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||||
|
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
readWorktreeMap(id: string): Promise<WorktreeMap> {
|
||||||
|
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
|
||||||
|
},
|
||||||
|
|
||||||
|
writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
|
||||||
|
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(map),
|
||||||
|
})
|
||||||
|
},
|
||||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
||||||
return request<WorkspaceDescriptor>("/api/workspaces", {
|
return request<WorkspaceDescriptor>("/api/workspaces", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface UseCommandsOptions {
|
|||||||
toggleShowTimelineTools: () => void
|
toggleShowTimelineTools: () => void
|
||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
|
togglePromptSubmitOnEnter: () => void
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
@@ -423,6 +424,18 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
commandRegistry.register({
|
||||||
|
id: "prompt-submit-shortcut",
|
||||||
|
label: () =>
|
||||||
|
options.preferences().promptSubmitOnEnter
|
||||||
|
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
||||||
|
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
||||||
|
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
||||||
|
category: "Input & Focus",
|
||||||
|
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
||||||
|
action: options.togglePromptSubmitOnEnter,
|
||||||
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "thinking",
|
id: "thinking",
|
||||||
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
||||||
|
|||||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
|||||||
"commands.clearInput.description": "Clear the prompt textarea",
|
"commands.clearInput.description": "Clear the prompt textarea",
|
||||||
"commands.clearInput.keywords": "clear, reset",
|
"commands.clearInput.keywords": "clear, reset",
|
||||||
|
|
||||||
"commands.thinkingBlocks.label.show": "Show Thinking Blocks",
|
"commands.promptSubmitShortcut.label.default": "Enter: New Line, Cmd/Ctrl+Enter: Submit Prompt",
|
||||||
"commands.thinkingBlocks.label.hide": "Hide Thinking Blocks",
|
"commands.promptSubmitShortcut.label.swapped": "Enter: Submit Prompt, Cmd/Ctrl+Enter: New Line",
|
||||||
"commands.thinkingBlocks.description": "Show/hide AI thinking process",
|
"commands.promptSubmitShortcut.description": "Swap Enter and Cmd/Ctrl+Enter behavior in the prompt input",
|
||||||
|
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, submit, send, newline, shortcut, keybind, prompt",
|
||||||
|
|
||||||
|
"commands.thinkingBlocks.label.show": "Show Thinking",
|
||||||
|
"commands.thinkingBlocks.label.hide": "Hide Thinking",
|
||||||
|
"commands.thinkingBlocks.description": "Show or hide AI thinking sections",
|
||||||
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide",
|
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide",
|
||||||
|
|
||||||
"commands.timelineToolCalls.label.show": "Show Timeline Tool Calls",
|
"commands.timelineToolCalls.label.show": "Show Timeline Tool Calls",
|
||||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
|||||||
"commands.common.enabled": "Enabled",
|
"commands.common.enabled": "Enabled",
|
||||||
"commands.common.disabled": "Disabled",
|
"commands.common.disabled": "Disabled",
|
||||||
|
|
||||||
"commands.thinkingBlocksDefault.label": "Thinking Blocks Default · {state}",
|
"commands.thinkingBlocksDefault.label": "Thinking View: {state}",
|
||||||
"commands.thinkingBlocksDefault.description": "Toggle whether thinking blocks start expanded",
|
"commands.thinkingBlocksDefault.description": "Collapse / Expand AI thinking sections when shown",
|
||||||
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default",
|
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default",
|
||||||
|
|
||||||
"commands.diffViewSplit.label": "Use Split Diff View",
|
"commands.diffViewSplit.label": "Use Split Diff View",
|
||||||
|
|||||||
@@ -86,12 +86,32 @@ export const instanceMessages = {
|
|||||||
"instanceShell.empty.description": "Select a session to view messages",
|
"instanceShell.empty.description": "Select a session to view messages",
|
||||||
|
|
||||||
"instanceShell.rightPanel.title": "Status Panel",
|
"instanceShell.rightPanel.title": "Status Panel",
|
||||||
|
"instanceShell.rightPanel.tabs.changes": "Session Changes",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Git Changes",
|
||||||
|
"instanceShell.rightPanel.tabs.files": "Files",
|
||||||
|
"instanceShell.rightPanel.tabs.status": "Status",
|
||||||
|
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||||
|
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
|
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
|
||||||
"instanceShell.rightPanel.sections.mcp": "MCP Servers",
|
"instanceShell.rightPanel.sections.mcp": "MCP Servers",
|
||||||
"instanceShell.rightPanel.sections.lsp": "LSP Servers",
|
"instanceShell.rightPanel.sections.lsp": "LSP Servers",
|
||||||
"instanceShell.rightPanel.sections.plugins": "Plugins",
|
"instanceShell.rightPanel.sections.plugins": "Plugins",
|
||||||
|
|
||||||
|
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
|
||||||
|
"instanceShell.sessionChanges.loading": "Fetching session changes...",
|
||||||
|
"instanceShell.sessionChanges.empty": "No session changes yet.",
|
||||||
|
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||||
|
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||||
|
|
||||||
|
"instanceShell.filesShell.fileListTitle": "File list",
|
||||||
|
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||||
|
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||||
|
"instanceShell.filesShell.viewerTitle": "Change viewer",
|
||||||
|
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
|
||||||
|
"instanceShell.filesShell.viewerEmpty": "No file selected.",
|
||||||
|
|
||||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const sessionMessages = {
|
|||||||
"sessionList.expand.expandAriaLabel": "Expand session",
|
"sessionList.expand.expandAriaLabel": "Expand session",
|
||||||
"sessionList.expand.collapseTitle": "Collapse",
|
"sessionList.expand.collapseTitle": "Collapse",
|
||||||
"sessionList.expand.expandTitle": "Expand",
|
"sessionList.expand.expandTitle": "Expand",
|
||||||
|
"sessionList.actions.newSession.ariaLabel": "New session",
|
||||||
|
"sessionList.actions.newSession.title": "New session",
|
||||||
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
|
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
|
||||||
"sessionList.actions.copyId.title": "Copy session ID",
|
"sessionList.actions.copyId.title": "Copy session ID",
|
||||||
"sessionList.actions.rename.ariaLabel": "Rename session",
|
"sessionList.actions.rename.ariaLabel": "Rename session",
|
||||||
|
|||||||
@@ -82,9 +82,14 @@ export const commandMessages = {
|
|||||||
"commands.clearInput.description": "Borrar el área de texto del prompt",
|
"commands.clearInput.description": "Borrar el área de texto del prompt",
|
||||||
"commands.clearInput.keywords": "limpiar, reiniciar",
|
"commands.clearInput.keywords": "limpiar, reiniciar",
|
||||||
|
|
||||||
"commands.thinkingBlocks.label.show": "Mostrar bloques de pensamiento",
|
"commands.promptSubmitShortcut.label.default": "Enter: Nueva linea, Cmd/Ctrl+Enter: Enviar prompt",
|
||||||
"commands.thinkingBlocks.label.hide": "Ocultar bloques de pensamiento",
|
"commands.promptSubmitShortcut.label.swapped": "Enter: Enviar prompt, Cmd/Ctrl+Enter: Nueva linea",
|
||||||
"commands.thinkingBlocks.description": "Mostrar/ocultar el proceso de pensamiento de la IA",
|
"commands.promptSubmitShortcut.description": "Intercambiar el comportamiento de Enter y Cmd/Ctrl+Enter en la entrada de prompt",
|
||||||
|
"commands.promptSubmitShortcut.keywords": "enter, enviar, salto de linea, atajo, teclado, cmd, ctrl, prompt",
|
||||||
|
|
||||||
|
"commands.thinkingBlocks.label.show": "Mostrar pensamiento",
|
||||||
|
"commands.thinkingBlocks.label.hide": "Ocultar pensamiento",
|
||||||
|
"commands.thinkingBlocks.description": "Mostrar u ocultar secciones de pensamiento de IA",
|
||||||
"commands.thinkingBlocks.keywords": "pensamiento, razonamiento, alternar, mostrar, ocultar",
|
"commands.thinkingBlocks.keywords": "pensamiento, razonamiento, alternar, mostrar, ocultar",
|
||||||
|
|
||||||
"commands.timelineToolCalls.label.show": "Mostrar llamadas de herramienta en la línea de tiempo",
|
"commands.timelineToolCalls.label.show": "Mostrar llamadas de herramienta en la línea de tiempo",
|
||||||
@@ -99,8 +104,8 @@ export const commandMessages = {
|
|||||||
"commands.common.enabled": "Activado",
|
"commands.common.enabled": "Activado",
|
||||||
"commands.common.disabled": "Desactivado",
|
"commands.common.disabled": "Desactivado",
|
||||||
|
|
||||||
"commands.thinkingBlocksDefault.label": "Bloques de pensamiento por defecto · {state}",
|
"commands.thinkingBlocksDefault.label": "Vista de pensamiento: {state}",
|
||||||
"commands.thinkingBlocksDefault.description": "Alternar si los bloques de pensamiento empiezan expandidos",
|
"commands.thinkingBlocksDefault.description": "Contraer / Expandir secciones de pensamiento de IA cuando se muestran",
|
||||||
"commands.thinkingBlocksDefault.keywords": "pensamiento, razonamiento, expandir, colapsar, por defecto",
|
"commands.thinkingBlocksDefault.keywords": "pensamiento, razonamiento, expandir, colapsar, por defecto",
|
||||||
|
|
||||||
"commands.diffViewSplit.label": "Usar vista de diff dividida",
|
"commands.diffViewSplit.label": "Usar vista de diff dividida",
|
||||||
|
|||||||
@@ -86,12 +86,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.empty.description": "Selecciona una sesión para ver mensajes",
|
"instanceShell.empty.description": "Selecciona una sesión para ver mensajes",
|
||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panel de estado",
|
"instanceShell.rightPanel.title": "Panel de estado",
|
||||||
|
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
||||||
|
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||||
|
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||||
|
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||||
|
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
|
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
|
||||||
"instanceShell.rightPanel.sections.mcp": "Servidores MCP",
|
"instanceShell.rightPanel.sections.mcp": "Servidores MCP",
|
||||||
"instanceShell.rightPanel.sections.lsp": "Servidores LSP",
|
"instanceShell.rightPanel.sections.lsp": "Servidores LSP",
|
||||||
"instanceShell.rightPanel.sections.plugins": "Plugins",
|
"instanceShell.rightPanel.sections.plugins": "Plugins",
|
||||||
|
|
||||||
|
"instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.",
|
||||||
|
"instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...",
|
||||||
|
"instanceShell.sessionChanges.empty": "Aun no hay cambios.",
|
||||||
|
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
||||||
|
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
||||||
|
|
||||||
|
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||||
|
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||||
|
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
||||||
|
"instanceShell.filesShell.viewerTitle": "Visor de cambios",
|
||||||
|
"instanceShell.filesShell.viewerPlaceholder": "La vista detallada se agregará en el siguiente paso.",
|
||||||
|
"instanceShell.filesShell.viewerEmpty": "Ningún archivo seleccionado.",
|
||||||
|
|
||||||
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
|
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
|
||||||
"instanceShell.plan.empty": "Aún no hay nada planificado.",
|
"instanceShell.plan.empty": "Aún no hay nada planificado.",
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const sessionMessages = {
|
|||||||
"sessionList.expand.expandAriaLabel": "Expandir sesión",
|
"sessionList.expand.expandAriaLabel": "Expandir sesión",
|
||||||
"sessionList.expand.collapseTitle": "Colapsar",
|
"sessionList.expand.collapseTitle": "Colapsar",
|
||||||
"sessionList.expand.expandTitle": "Expandir",
|
"sessionList.expand.expandTitle": "Expandir",
|
||||||
|
"sessionList.actions.newSession.ariaLabel": "Nueva sesión",
|
||||||
|
"sessionList.actions.newSession.title": "Nueva sesión",
|
||||||
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
|
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
|
||||||
"sessionList.actions.copyId.title": "Copiar ID de sesión",
|
"sessionList.actions.copyId.title": "Copiar ID de sesión",
|
||||||
"sessionList.actions.rename.ariaLabel": "Renombrar sesión",
|
"sessionList.actions.rename.ariaLabel": "Renombrar sesión",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user