Compare commits
46 Commits
v0.13.3-de
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e022a158eb | ||
|
|
9d9a6a79ec | ||
|
|
82a7c95dba | ||
|
|
313a0e579e | ||
|
|
a795869064 | ||
|
|
9bf4d351de | ||
|
|
657e78da6a | ||
|
|
dee356558f | ||
|
|
03ed3d3b2c | ||
|
|
a111de1af8 | ||
|
|
8a3b162be9 | ||
|
|
c62cb3ce4a | ||
|
|
d9811e735d | ||
|
|
1ce58b9dd9 | ||
|
|
1907a4da03 | ||
|
|
abf4c67fcc | ||
|
|
bc130ceb5b | ||
|
|
8505a43b16 | ||
|
|
2a3329b5ed | ||
|
|
c9c1cf21f0 | ||
|
|
c7d4f99e48 | ||
|
|
d50c00afb4 | ||
|
|
0ef57df3bc | ||
|
|
0739ec857c | ||
|
|
b060ab45ff | ||
|
|
af6429162f | ||
|
|
2e9ee2cde6 | ||
|
|
d45c0b9367 | ||
|
|
197898c01c | ||
|
|
0c0cfd2d22 | ||
|
|
5107ac207e | ||
|
|
1130066a33 | ||
|
|
403a3ff189 | ||
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 | ||
|
|
d0a0325d7e | ||
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 |
8
.github/workflows/build-and-upload.yml
vendored
8
.github/workflows/build-and-upload.yml
vendored
@@ -212,7 +212,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for file in packages/electron-app/release/*.zip; do
|
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
@@ -313,7 +313,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for file in packages/electron-app/release/*.zip; do
|
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
@@ -324,7 +324,9 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||||
path: packages/electron-app/release/*.zip
|
path: |
|
||||||
|
packages/electron-app/release/*.zip
|
||||||
|
packages/electron-app/release/*.AppImage
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -18,6 +18,7 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work
|
|||||||
- **🎙️ Voice Input & Speech**
|
- **🎙️ Voice Input & Speech**
|
||||||
- **🌳 Git Worktrees**
|
- **🌳 Git Worktrees**
|
||||||
- **💬 Rich Message Experience**
|
- **💬 Rich Message Experience**
|
||||||
|
- **🧩 SideCars**
|
||||||
- **⌨️ Command Palette**
|
- **⌨️ Command Palette**
|
||||||
- **📁 File System Browser**
|
- **📁 File System Browser**
|
||||||
- **🔐 Authentication & Security**
|
- **🔐 Authentication & Security**
|
||||||
@@ -61,6 +62,60 @@ npx @neuralnomads/codenomad-dev --launch
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## SideCars
|
||||||
|
|
||||||
|
SideCars let you open local web tools inside CodeNomad as tabs.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Configuration</strong></summary>
|
||||||
|
|
||||||
|
- **Name**: Display name used in CodeNomad
|
||||||
|
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
|
||||||
|
- **Base path**: Mounted under `/sidecars/:id`
|
||||||
|
- **Prefix mode**:
|
||||||
|
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
|
||||||
|
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
|
||||||
|
|
||||||
|
Run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
|
||||||
|
```
|
||||||
|
|
||||||
|
Add SideCar as:
|
||||||
|
|
||||||
|
- **Name**: `VSCode`
|
||||||
|
- **Port**: `http://127.0.0.1:8000`
|
||||||
|
- **Base path**: `/sidecars/vscode`
|
||||||
|
- **Prefix mode**: `Preserve prefix`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Terminal (ttyd)</strong></summary>
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ttyd --writable zsh
|
||||||
|
```
|
||||||
|
|
||||||
|
Add SideCar as:
|
||||||
|
|
||||||
|
- **Name**: `Terminal`
|
||||||
|
- **Port**: `http://127.0.0.1:7681`
|
||||||
|
- **Base path**: `/sidecars/terminal`
|
||||||
|
- **Prefix mode**: `Strip prefix`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -12068,7 +12068,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12105,7 +12105,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12147,7 +12147,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12155,7 +12155,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.13.3",
|
"minServerVersion": "0.14.0",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"remote:openWindow",
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||||
|
): Promise<{ ok: boolean }> => {
|
||||||
|
const opener = (mainWindow as BrowserWindow & {
|
||||||
|
__codenomadOpenRemoteWindow?: (payload: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
}) => Promise<void>
|
||||||
|
}).__codenomadOpenRemoteWindow
|
||||||
|
if (!opener) {
|
||||||
|
throw new Error("Remote window opening is not available")
|
||||||
|
}
|
||||||
|
await opener(payload)
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, mkdirSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
|||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
function configureDevStoragePaths() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = "CodeNomad"
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.setName(appName)
|
||||||
|
|
||||||
|
const userDataPath = join(app.getPath("appData"), appName)
|
||||||
|
const sessionDataPath = join(userDataPath, "session-data")
|
||||||
|
|
||||||
|
mkdirSync(userDataPath, { recursive: true })
|
||||||
|
mkdirSync(sessionDataPath, { recursive: true })
|
||||||
|
|
||||||
|
app.setPath("userData", userDataPath)
|
||||||
|
app.setPath("sessionData", sessionDataPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to configure dev storage paths", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureDevStoragePaths()
|
||||||
|
|
||||||
const cliManager = new CliProcessManager()
|
const cliManager = new CliProcessManager()
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
|
|||||||
let pendingBootstrapToken: string | null = null
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
app.commandLine.appendSwitch("disable-spell-checking")
|
||||||
@@ -93,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedRendererOrigins(): string[] {
|
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||||
const origins = new Set<string>()
|
const origins = new Set<string>()
|
||||||
|
if (window) {
|
||||||
|
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||||
|
origins.add(origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||||
for (const candidate of rendererCandidates) {
|
for (const candidate of rendererCandidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -109,13 +141,13 @@ function getAllowedRendererOrigins(): string[] {
|
|||||||
return Array.from(origins)
|
return Array.from(origins)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldOpenExternally(url: string): boolean {
|
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const allowedOrigins = getAllowedRendererOrigins()
|
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||||
return !allowedOrigins.includes(parsed.origin)
|
return !allowedOrigins.includes(parsed.origin)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -128,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
@@ -136,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store allowed origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||||
|
remoteWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store insecure origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||||
|
insecureWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsecureOriginAllowed(url: string) {
|
||||||
|
try {
|
||||||
|
const targetOrigin = new URL(url).origin
|
||||||
|
for (const origins of insecureWindowOrigins.values()) {
|
||||||
|
if (origins.has(targetOrigin)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let cachedPreloadPath: string | null = null
|
let cachedPreloadPath: string | null = null
|
||||||
function getPreloadPath() {
|
function getPreloadPath() {
|
||||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||||
@@ -207,25 +280,30 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setupNavigationGuards(mainWindow)
|
const window = mainWindow
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
loadLoadingScreen(mainWindow)
|
clearWindowAllowedOrigin(window)
|
||||||
|
loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
}
|
}
|
||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(window)
|
||||||
setupCliIPC(mainWindow, cliManager)
|
setupCliIPC(window, cliManager)
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
window.on("closed", () => {
|
||||||
destroyPreloadingView()
|
destroyPreloadingView()
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
@@ -322,10 +400,66 @@ function finalizeCliSwap(url: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const window = mainWindow
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
currentCliUrl = url
|
currentCliUrl = url
|
||||||
|
setWindowAllowedOrigin(window, url)
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl)
|
||||||
|
return `${name} - ${parsed.host}`
|
||||||
|
} catch {
|
||||||
|
return `${name} - ${baseUrl}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||||
|
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||||
|
const targetUrl = new URL(payload.baseUrl)
|
||||||
|
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
icon: getIconPath(),
|
||||||
|
title,
|
||||||
|
webPreferences: {
|
||||||
|
preload: getPreloadPath(),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
spellcheck: !isMac,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||||
|
if (payload.skipTlsVerify) {
|
||||||
|
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
window.on("closed", () => {
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.loadURL(targetUrl.toString())
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bootstrapExchangeInFlight = false
|
let bootstrapExchangeInFlight = false
|
||||||
@@ -504,6 +638,17 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
|
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||||
|
if (isInsecureOriginAllowed(url)) {
|
||||||
|
event.preventDefault()
|
||||||
|
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
|||||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", 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.13.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -147,6 +147,13 @@
|
|||||||
"x64",
|
"x64",
|
||||||
"arm64"
|
"arm64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type BackgroundProcess = {
|
|||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackgroundProcessNotificationRequest = {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
type BackgroundProcessOptions = {
|
type BackgroundProcessOptions = {
|
||||||
baseDir: string
|
baseDir: string
|
||||||
}
|
}
|
||||||
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
|
|||||||
args: {
|
args: {
|
||||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||||
|
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args, context) {
|
||||||
assertCommandWithinBase(args.command, options.baseDir)
|
assertCommandWithinBase(args.command, options.baseDir)
|
||||||
|
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
|
||||||
|
? {
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
directory: context.directory,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
const process = await request<BackgroundProcess>("", {
|
const process = await request<BackgroundProcess>("", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||||
|
|||||||
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.13.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"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.13.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -81,6 +81,55 @@ export interface WorktreeMap {
|
|||||||
parentSessionWorktreeSlug: Record<string, string>
|
parentSessionWorktreeSlug: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
|
||||||
|
|
||||||
|
export interface WorktreeGitStatusEntry {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
stagedStatus: GitChangeKind | null
|
||||||
|
stagedAdditions: number
|
||||||
|
stagedDeletions: number
|
||||||
|
unstagedStatus: GitChangeKind | null
|
||||||
|
unstagedAdditions: number
|
||||||
|
unstagedDeletions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
|
||||||
|
|
||||||
|
export type WorktreeGitDiffScope = "staged" | "unstaged"
|
||||||
|
|
||||||
|
export interface WorktreeGitPathsRequest {
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitMutationResponse {
|
||||||
|
ok: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitCommitRequest {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitCommitResponse {
|
||||||
|
ok: true
|
||||||
|
commitSha?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitDiffResponse {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
before: string
|
||||||
|
after: string
|
||||||
|
isBinary?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitDiffRequest {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
}
|
||||||
|
|
||||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
@@ -170,6 +219,24 @@ export interface InstanceStreamEvent {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SideCarKind = "port"
|
||||||
|
|
||||||
|
export type SideCarPrefixMode = "strip" | "preserve"
|
||||||
|
|
||||||
|
export type SideCarStatus = "running" | "stopped"
|
||||||
|
|
||||||
|
export interface SideCar {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
status: SideCarStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinaryRecord {
|
export interface BinaryRecord {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -244,12 +311,40 @@ export interface VoiceModeStateResponse {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
lastConnectedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProbeRequest {
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProbeResponse {
|
||||||
|
ok: boolean
|
||||||
|
reachable: boolean
|
||||||
|
normalizedUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
requiresAuth: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
error?: string
|
||||||
|
errorCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
|
| "sidecar.updated"
|
||||||
|
| "sidecar.removed"
|
||||||
| "storage.configChanged"
|
| "storage.configChanged"
|
||||||
| "storage.stateChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
@@ -262,6 +357,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
|
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||||
|
| { type: "sidecar.removed"; sidecarId: string }
|
||||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
@@ -328,6 +425,8 @@ export interface ServerMeta {
|
|||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|
||||||
|
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
|
||||||
|
|
||||||
export interface BackgroundProcess {
|
export interface BackgroundProcess {
|
||||||
id: string
|
id: string
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -340,6 +439,8 @@ export interface BackgroundProcess {
|
|||||||
stoppedAt?: string
|
stoppedAt?: string
|
||||||
exitCode?: number
|
exitCode?: number
|
||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
|
terminalReason?: BackgroundProcessTerminalReason
|
||||||
|
notifyEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundProcessListResponse {
|
export interface BackgroundProcessListResponse {
|
||||||
|
|||||||
@@ -104,13 +104,18 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
return this.getSessionFromHeaders(request.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
// When auth is disabled, treat all requests as authenticated.
|
// When auth is disabled, treat all requests as authenticated.
|
||||||
// We still return a stable username so callers can display it.
|
// We still return a stable username so callers can display it.
|
||||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = parseCookies(request.headers.cookie)
|
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
|
||||||
|
const cookies = parseCookies(cookieHeader)
|
||||||
const sessionId = cookies[this.cookieName]
|
const sessionId = cookies[this.cookieName]
|
||||||
const session = this.sessionManager.getSession(sessionId)
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
|
|||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { WorkspaceManager } from "../workspaces/manager"
|
import type { WorkspaceManager } from "../workspaces/manager"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
|
||||||
|
|
||||||
const ROOT_DIR = ".codenomad/background_processes"
|
const ROOT_DIR = ".codenomad/background_processes"
|
||||||
const INDEX_FILE = "index.json"
|
const INDEX_FILE = "index.json"
|
||||||
@@ -27,6 +27,31 @@ interface RunningProcess {
|
|||||||
outputPath: string
|
outputPath: string
|
||||||
exitPromise: Promise<void>
|
exitPromise: Promise<void>
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
|
completion?: ProcessCompletion
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessCompletion {
|
||||||
|
reason: BackgroundProcessTerminalReason
|
||||||
|
endContext: "normal" | "workspace_cleanup"
|
||||||
|
removeAfterFinalize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackgroundProcessNotificationState {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
sentAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedBackgroundProcess extends BackgroundProcess {
|
||||||
|
notify?: BackgroundProcessNotificationState
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartOptions {
|
||||||
|
notify?: boolean
|
||||||
|
notification?: {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BackgroundProcessManager {
|
export class BackgroundProcessManager {
|
||||||
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
|
|||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
records.map(async (record) => ({
|
records.map(async (record) => ({
|
||||||
...record,
|
...this.toPublicProcess(record),
|
||||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
return enriched
|
return enriched
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
|
||||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error("Workspace not found")
|
throw new Error("Workspace not found")
|
||||||
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
|
|||||||
this.killProcessTree(child, "SIGTERM")
|
this.killProcessTree(child, "SIGTERM")
|
||||||
})
|
})
|
||||||
|
|
||||||
const record: BackgroundProcess = {
|
const record: PersistedBackgroundProcess = {
|
||||||
|
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
title,
|
||||||
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
|
|||||||
pid: child.pid,
|
pid: child.pid,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
outputSizeBytes: 0,
|
outputSizeBytes: 0,
|
||||||
|
notify: options.notify && options.notification
|
||||||
|
? {
|
||||||
|
sessionID: options.notification.sessionID,
|
||||||
|
directory: options.notification.directory,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningState: RunningProcess = {
|
||||||
|
id,
|
||||||
|
child,
|
||||||
|
outputPath,
|
||||||
|
exitPromise: Promise.resolve(),
|
||||||
|
workspaceId,
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitPromise = new Promise<void>((resolve) => {
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
|
|||||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||||
this.running.delete(id)
|
this.running.delete(id)
|
||||||
|
|
||||||
record.status = this.statusFromExit(code)
|
const completion = runningState.completion ?? this.completionFromExit(code)
|
||||||
|
|
||||||
|
record.terminalReason = completion.reason
|
||||||
|
record.status = this.statusFromReason(completion.reason)
|
||||||
record.exitCode = code === null ? undefined : code
|
record.exitCode = code === null ? undefined : code
|
||||||
record.stoppedAt = new Date().toISOString()
|
record.stoppedAt = new Date().toISOString()
|
||||||
|
|
||||||
await this.upsertIndex(workspaceId, record)
|
await this.finalizeRecord(workspaceId, record, completion)
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
|
||||||
this.publishUpdate(workspaceId, record)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
runningState.exitPromise = exitPromise
|
||||||
|
|
||||||
|
this.running.set(id, runningState)
|
||||||
|
|
||||||
let lastPublishAt = 0
|
let lastPublishAt = 0
|
||||||
const maybePublishSize = () => {
|
const maybePublishSize = () => {
|
||||||
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
|
|||||||
await this.upsertIndex(workspaceId, record)
|
await this.upsertIndex(workspaceId, record)
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||||
this.publishUpdate(workspaceId, record)
|
this.publishUpdate(workspaceId, record)
|
||||||
return record
|
return this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||||
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
|
running.completion = { reason: "user_stopped", endContext: "normal" }
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
|
const updated = await this.findProcess(workspaceId, processId)
|
||||||
|
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.status === "running") {
|
if (record.status === "running") {
|
||||||
record.status = "stopped"
|
record.status = "stopped"
|
||||||
|
record.terminalReason = "user_stopped"
|
||||||
record.stoppedAt = new Date().toISOString()
|
record.stoppedAt = new Date().toISOString()
|
||||||
await this.upsertIndex(workspaceId, record)
|
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
|
||||||
this.publishUpdate(workspaceId, record)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return record
|
return this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||||
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
|
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeFromIndex(workspaceId, processId)
|
record.status = "stopped"
|
||||||
await this.removeProcessDir(workspaceId, processId)
|
record.terminalReason = "user_terminated"
|
||||||
|
record.stoppedAt = new Date().toISOString()
|
||||||
this.deps.eventBus.publish({
|
await this.finalizeRecord(workspaceId, record, {
|
||||||
type: "instance.event",
|
reason: "user_terminated",
|
||||||
instanceId: workspaceId,
|
endContext: "normal",
|
||||||
event: { type: "background.process.removed", properties: { processId } },
|
removeAfterFinalize: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
|
|||||||
private async cleanupWorkspace(workspaceId: string) {
|
private async cleanupWorkspace(workspaceId: string) {
|
||||||
for (const [, running] of this.running.entries()) {
|
for (const [, running] of this.running.entries()) {
|
||||||
if (running.workspaceId !== workspaceId) continue
|
if (running.workspaceId !== workspaceId) continue
|
||||||
|
running.completion = {
|
||||||
|
reason: "user_terminated",
|
||||||
|
endContext: "workspace_cleanup",
|
||||||
|
removeAfterFinalize: true,
|
||||||
|
}
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
private completionFromExit(code: number | null): ProcessCompletion {
|
||||||
if (code === null) return "stopped"
|
if (code === 0) {
|
||||||
if (code === 0) return "stopped"
|
return { reason: "finished", endContext: "normal" }
|
||||||
return "error"
|
}
|
||||||
|
|
||||||
|
return { reason: "failed", endContext: "normal" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
|
||||||
|
if (reason === "failed") return "error"
|
||||||
|
return "stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||||
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
|
|||||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
|
||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
return records.find((entry) => entry.id === processId) ?? null
|
return records.find((entry) => entry.id === processId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
|
||||||
const indexPath = await this.getIndexPath(workspaceId)
|
const indexPath = await this.getIndexPath(workspaceId)
|
||||||
if (!existsSync(indexPath)) return []
|
if (!existsSync(indexPath)) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(indexPath, "utf-8")
|
const raw = await fs.readFile(indexPath, "utf-8")
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
const index = records.findIndex((entry) => entry.id === record.id)
|
const index = records.findIndex((entry) => entry.id === record.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
|
|||||||
await this.writeIndex(workspaceId, next)
|
await this.writeIndex(workspaceId, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
|
||||||
const indexPath = await this.getIndexPath(workspaceId)
|
const indexPath = await this.getIndexPath(workspaceId)
|
||||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||||
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
this.deps.eventBus.publish({
|
this.deps.eventBus.publish({
|
||||||
type: "instance.event",
|
type: "instance.event",
|
||||||
instanceId: workspaceId,
|
instanceId: workspaceId,
|
||||||
event: { type: "background.process.updated", properties: { process: record } },
|
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
workspaceId: record.workspaceId,
|
||||||
|
title: record.title,
|
||||||
|
command: record.command,
|
||||||
|
cwd: record.cwd,
|
||||||
|
status: record.status,
|
||||||
|
pid: record.pid,
|
||||||
|
startedAt: record.startedAt,
|
||||||
|
stoppedAt: record.stoppedAt,
|
||||||
|
exitCode: record.exitCode,
|
||||||
|
outputSizeBytes: record.outputSizeBytes,
|
||||||
|
terminalReason: record.terminalReason,
|
||||||
|
notifyEnabled: Boolean(record.notify),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||||
|
if (this.shouldSendCompletionPrompt(record, completion)) {
|
||||||
|
try {
|
||||||
|
await this.sendCompletionPrompt(workspaceId, record)
|
||||||
|
if (record.notify) {
|
||||||
|
record.notify.sentAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completion.removeAfterFinalize) {
|
||||||
|
await this.removeFromIndex(workspaceId, record.id)
|
||||||
|
await this.removeProcessDir(workspaceId, record.id)
|
||||||
|
|
||||||
|
this.deps.eventBus.publish({
|
||||||
|
type: "instance.event",
|
||||||
|
instanceId: workspaceId,
|
||||||
|
event: { type: "background.process.removed", properties: { processId: record.id } },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertIndex(workspaceId, record)
|
||||||
|
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||||
|
this.publishUpdate(workspaceId, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||||
|
if (completion.endContext === "workspace_cleanup") return false
|
||||||
|
if (!record.notify) return false
|
||||||
|
return !record.notify.sentAt
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
|
const notify = record.notify
|
||||||
|
if (!notify || !record.terminalReason) return
|
||||||
|
|
||||||
|
if (!this.deps.workspaceManager.get(workspaceId)) {
|
||||||
|
throw new Error("Workspace not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
|
||||||
|
if (!port) {
|
||||||
|
throw new Error("Workspace instance is not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
if (authorization) {
|
||||||
|
headers.authorization = authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: this.buildSyntheticCompletionPrompt(record),
|
||||||
|
synthetic: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Prompt request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||||
|
const ref = `Background process "${record.title}" (${record.id})`
|
||||||
|
|
||||||
|
switch (record.terminalReason) {
|
||||||
|
case "finished":
|
||||||
|
return `${ref} finished successfully.`
|
||||||
|
case "failed":
|
||||||
|
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
|
||||||
|
case "user_stopped":
|
||||||
|
return `${ref} was stopped by user.`
|
||||||
|
case "user_terminated":
|
||||||
|
return `${ref} was terminated by user.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ref} ended.`
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||||
|
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeTaggedText(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
}
|
||||||
|
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||||
const random = randomBytes(3).toString("hex")
|
const random = randomBytes(3).toString("hex")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
|||||||
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"),
|
||||||
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||||
|
|
||||||
// OS notifications
|
// OS notifications
|
||||||
osNotificationsEnabled: z.boolean().default(false),
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
|
this.on("sidecar.updated", handler)
|
||||||
|
this.on("sidecar.removed", handler)
|
||||||
this.on("storage.configChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("storage.stateChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
|
this.off("sidecar.updated", handler)
|
||||||
|
this.off("sidecar.removed", handler)
|
||||||
this.off("storage.configChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("storage.stateChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { resolveHttpsOptions } from "./server/tls"
|
|||||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
|
import { SideCarManager } from "./sidecars/manager"
|
||||||
|
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||||
|
import { PluginChannelManager } from "./plugins/channel"
|
||||||
|
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -315,6 +319,11 @@ async function main() {
|
|||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
|
const sidecarManager = new SideCarManager({
|
||||||
|
settings,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "sidecars" }),
|
||||||
|
})
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -372,6 +381,14 @@ async function main() {
|
|||||||
|
|
||||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
|
||||||
|
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||||
|
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
||||||
|
const voiceModeManager = new VoiceModeManager({
|
||||||
|
connections: clientConnectionManager,
|
||||||
|
channel: pluginChannel,
|
||||||
|
logger: logger.child({ component: "voice-mode" }),
|
||||||
|
})
|
||||||
|
|
||||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
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 httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||||
|
|
||||||
@@ -400,7 +417,11 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
@@ -421,7 +442,11 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
@@ -520,6 +545,18 @@ async function main() {
|
|||||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sidecarManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientConnectionManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, "Client connection manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ export class VoiceModeManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
|
||||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||||
this.options.logger.debug(
|
this.options.logger.debug(
|
||||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||||
"Ignoring voice mode enable for disconnected client connection",
|
"Ignoring voice mode enable for disconnected client connection",
|
||||||
)
|
)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = getConnectionKey(connection)
|
const key = getConnectionKey(connection)
|
||||||
@@ -44,6 +44,7 @@ export class VoiceModeManager {
|
|||||||
|
|
||||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||||
this.publishIfChanged(instanceId)
|
this.publishIfChanged(instanceId)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
syncInstance(instanceId: string): void {
|
syncInstance(instanceId: string): void {
|
||||||
@@ -76,7 +77,10 @@ export class VoiceModeManager {
|
|||||||
this.aggregateByInstance.delete(instanceId)
|
this.aggregateByInstance.delete(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
this.options.logger.debug(
|
||||||
|
{ instanceId, enabled },
|
||||||
|
"Broadcasting aggregate voice mode",
|
||||||
|
)
|
||||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import cors from "@fastify/cors"
|
|||||||
import fastifyStatic from "@fastify/static"
|
import fastifyStatic from "@fastify/static"
|
||||||
import replyFrom from "@fastify/reply-from"
|
import replyFrom from "@fastify/reply-from"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { connect as connectTls, type TLSSocket } from "tls"
|
||||||
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 { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
|
||||||
|
|
||||||
import type { SettingsService } from "../settings/service"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
@@ -22,6 +25,8 @@ import { registerPluginRoutes } from "./routes/plugin"
|
|||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
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"
|
||||||
@@ -32,6 +37,7 @@ import type { SpeechService } from "../speech/service"
|
|||||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||||
import { PluginChannelManager } from "../plugins/channel"
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -47,7 +53,11 @@ interface HttpServerDeps {
|
|||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
speechService: SpeechService
|
speechService: SpeechService
|
||||||
|
sidecarManager: SideCarManager
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
|
clientConnectionManager: ClientConnectionManager
|
||||||
|
pluginChannel: PluginChannelManager
|
||||||
|
voiceModeManager: VoiceModeManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -176,13 +186,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
|
||||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
|
||||||
const voiceModeManager = new VoiceModeManager({
|
|
||||||
connections: clientConnectionManager,
|
|
||||||
channel: pluginChannel,
|
|
||||||
logger: deps.logger.child({ component: "voice-mode" }),
|
|
||||||
})
|
|
||||||
|
|
||||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
@@ -203,7 +206,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
const session = deps.authManager.getSessionFromRequest(request)
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||||
if (requiresAuthForApi && !session) {
|
if (requiresAuthForApi && !session) {
|
||||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
@@ -262,7 +265,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
registerClient: registerSseClient,
|
registerClient: registerSseClient,
|
||||||
logger: sseLogger,
|
logger: sseLogger,
|
||||||
connectionManager: clientConnectionManager,
|
connectionManager: deps.clientConnectionManager,
|
||||||
})
|
})
|
||||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
@@ -270,13 +273,21 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||||
|
setupSideCarWebSocketProxy(app, {
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: proxyLogger,
|
||||||
|
})
|
||||||
registerPluginRoutes(app, {
|
registerPluginRoutes(app, {
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: proxyLogger,
|
logger: proxyLogger,
|
||||||
channel: pluginChannel,
|
channel: deps.pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager: deps.voiceModeManager,
|
||||||
})
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
@@ -342,7 +353,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
closeSseClients()
|
closeSseClients()
|
||||||
clientConnectionManager.shutdown()
|
|
||||||
return app.close()
|
return app.close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -353,6 +363,68 @@ interface InstanceProxyDeps {
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SideCarProxyDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||||
|
const proxyBaseHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyWildcardHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: request.params["*"] ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/sidecars/:id", proxyBaseHandler)
|
||||||
|
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||||
|
app.server.on("upgrade", (request, socket, head) => {
|
||||||
|
const rawUrl = request.url ?? "/"
|
||||||
|
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||||
|
if (!parsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void proxySideCarWebSocketUpgrade({
|
||||||
|
request,
|
||||||
|
socket: socket as Socket,
|
||||||
|
head,
|
||||||
|
sidecarId: parsed.sidecarId,
|
||||||
|
incomingPath: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||||
app.register(async (instance) => {
|
app.register(async (instance) => {
|
||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
@@ -689,52 +761,6 @@ 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")
|
||||||
@@ -837,3 +863,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proxySideCarRequest(args: {
|
||||||
|
request: FastifyRequest
|
||||||
|
reply: FastifyReply
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
pathSuffix?: string
|
||||||
|
}) {
|
||||||
|
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||||
|
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
args.reply.code(404).send({ error: "SideCar not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||||
|
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||||
|
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||||
|
const pathSuffix = args.pathSuffix ?? ""
|
||||||
|
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||||
|
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||||
|
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetUrl = `${targetOrigin}${targetPath}`
|
||||||
|
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||||
|
|
||||||
|
await args.reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||||
|
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||||
|
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||||
|
onError: (reply, { error }) => {
|
||||||
|
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl, "http://localhost")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||||
|
pathname: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxySideCarWebSocketUpgrade(args: {
|
||||||
|
request: import("http").IncomingMessage
|
||||||
|
socket: Socket
|
||||||
|
head: Buffer
|
||||||
|
sidecarId: string
|
||||||
|
incomingPath: string
|
||||||
|
search: string
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
authManager: AuthManager
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||||
|
|
||||||
|
if (!isWebSocketUpgradeRequest(request)) {
|
||||||
|
rejectUpgrade(socket, 400, "Bad Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromHeaders(request.headers)
|
||||||
|
if (!session) {
|
||||||
|
rejectUpgrade(socket, 401, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidecar = await sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
rejectUpgrade(socket, 404, "Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||||
|
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||||
|
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||||
|
|
||||||
|
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||||
|
|
||||||
|
const closeBoth = () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream.once("error", (error) => {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||||
|
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("error", (error) => {
|
||||||
|
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once(readyEvent, () => {
|
||||||
|
try {
|
||||||
|
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||||
|
if (head.length > 0) {
|
||||||
|
upstream.write(head)
|
||||||
|
}
|
||||||
|
upstream.pipe(socket)
|
||||||
|
socket.pipe(upstream)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||||
|
closeBoth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once("close", () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("close", () => {
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||||
|
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||||
|
if (targetUrl.protocol === "https:") {
|
||||||
|
return {
|
||||||
|
socket: connectTls({
|
||||||
|
host: targetUrl.hostname,
|
||||||
|
port,
|
||||||
|
servername: targetUrl.hostname,
|
||||||
|
}),
|
||||||
|
readyEvent: "secureConnect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
socket: connectTcp(port, targetUrl.hostname),
|
||||||
|
readyEvent: "connect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||||
|
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||||
|
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||||
|
const headerLines: string[] = []
|
||||||
|
const rawHeaders = request.rawHeaders ?? []
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
|
||||||
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||||
|
const key = rawHeaders[index]
|
||||||
|
const value = rawHeaders[index + 1]
|
||||||
|
if (!key || value === undefined) continue
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (blockedHeaders.has(lower)) continue
|
||||||
|
if (lower === "origin") {
|
||||||
|
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headerLines.push(`${key}: ${value}\r\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||||
|
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||||
|
headerLines.push("\r\n")
|
||||||
|
|
||||||
|
return requestLine + headerLines.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||||
|
const upgrade = request.headers.upgrade
|
||||||
|
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const connection = request.headers.connection
|
||||||
|
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||||
|
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSideCarResponseHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
sidecarId: string,
|
||||||
|
targetOrigin: string,
|
||||||
|
prefixMode: "strip" | "preserve",
|
||||||
|
) {
|
||||||
|
if (prefixMode === "preserve") {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...headers }
|
||||||
|
const locationHeader = next.location
|
||||||
|
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||||
|
if (!location) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||||
|
|
||||||
|
if (location.startsWith("/")) {
|
||||||
|
next.location = `${publicBase}${location}`
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(location)
|
||||||
|
if (parsed.origin === targetOrigin) {
|
||||||
|
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relative redirects should continue to resolve against the public sidecar path.
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSideCarProxyRequestHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
targetOrigin: string,
|
||||||
|
): Record<string, string | string[] | undefined> {
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
const next: Record<string, string | string[] | undefined> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!value) continue
|
||||||
|
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
next.origin = targetOrigin
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||||
|
return new Set([
|
||||||
|
"host",
|
||||||
|
"authorization",
|
||||||
|
"proxy-authorization",
|
||||||
|
"forwarded",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-port",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ interface RouteDeps {
|
|||||||
const StartSchema = z.object({
|
const StartSchema = z.object({
|
||||||
title: z.string().trim().min(1),
|
title: z.string().trim().min(1),
|
||||||
command: z.string().trim().min(1),
|
command: z.string().trim().min(1),
|
||||||
|
notify: z.boolean().optional(),
|
||||||
|
notification: z
|
||||||
|
.object({
|
||||||
|
sessionID: z.string().trim().min(1),
|
||||||
|
directory: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.notify && !value.notification) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Notification metadata is required when notify is enabled",
|
||||||
|
path: ["notification"],
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const OutputQuerySchema = z.object({
|
const OutputQuerySchema = z.object({
|
||||||
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
|
|||||||
|
|
||||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||||
const payload = StartSchema.parse(request.body ?? {})
|
const payload = StartSchema.parse(request.body ?? {})
|
||||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
|
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
|
||||||
|
notify: payload.notify,
|
||||||
|
notification: payload.notification,
|
||||||
|
})
|
||||||
reply.code(201)
|
reply.code(201)
|
||||||
return process
|
return process
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||||
deps.voiceModeManager.setEnabled(
|
const applied = deps.voiceModeManager.setEnabled(
|
||||||
request.params.id,
|
request.params.id,
|
||||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||||
payload.enabled,
|
payload.enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (payload.enabled && !applied) {
|
||||||
|
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return { enabled: payload.enabled }
|
return { enabled: payload.enabled }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProbeSchema = z.object({
|
||||||
|
baseUrl: z.string().min(1),
|
||||||
|
skipTlsVerify: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
|
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = ProbeSchema.parse(request.body ?? {})
|
||||||
|
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||||
|
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||||
|
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(probeUrl, {
|
||||||
|
method: "GET",
|
||||||
|
dispatcher,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: `Remote server returned HTTP ${response.status}`,
|
||||||
|
errorCode: "http_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { authenticated?: unknown }
|
||||||
|
if (typeof payload?.authenticated !== "boolean") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: "Remote server did not return a valid CodeNomad auth response",
|
||||||
|
errorCode: "invalid_server",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: !payload.authenticated,
|
||||||
|
authenticated: payload.authenticated,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = describeProbeError(error)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: message.message,
|
||||||
|
errorCode: message.code,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
await dispatcher?.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(input: string): string {
|
||||||
|
const parsed = new URL(input.trim())
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("Server URL must use http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.hash = ""
|
||||||
|
parsed.search = ""
|
||||||
|
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||||
|
const value = parsed.toString()
|
||||||
|
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||||
|
const chain = unwrapErrorChain(error)
|
||||||
|
const detailed =
|
||||||
|
chain.find((entry) => {
|
||||||
|
const code = (entry?.code ?? "").toString()
|
||||||
|
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||||
|
}) ?? chain[0]
|
||||||
|
|
||||||
|
const code = (detailed?.code ?? "").toString()
|
||||||
|
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||||
|
|
||||||
|
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||||
|
return {
|
||||||
|
code: "tls_error",
|
||||||
|
message: "Certificate check failed while connecting to the remote server.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code:
|
||||||
|
code === "ERR_INVALID_URL"
|
||||||
|
? "invalid_url"
|
||||||
|
: code === "ECONNREFUSED"
|
||||||
|
? "connection_refused"
|
||||||
|
: code === "ENOTFOUND"
|
||||||
|
? "dns_error"
|
||||||
|
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||||
|
? "timeout"
|
||||||
|
: code
|
||||||
|
? code.toLowerCase()
|
||||||
|
: "probe_failed",
|
||||||
|
message: exactMessage || "Failed to connect to the remote server.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||||
|
const results: Array<{ code?: unknown; message?: string }> = []
|
||||||
|
let current: unknown = error
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
|
||||||
|
while (current && typeof current === "object" && !seen.has(current)) {
|
||||||
|
seen.add(current)
|
||||||
|
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||||
|
results.push({ code: entry.code, message: entry.message })
|
||||||
|
current = entry.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0 && error instanceof Error) {
|
||||||
|
results.push({ message: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
56
packages/server/src/server/routes/sidecars.ts
Normal file
56
packages/server/src/server/routes/sidecars.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SideCarManager } from "../../sidecars/manager"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideCarCreateSchema = z.object({
|
||||||
|
kind: z.literal("port").default("port"),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
insecure: z.boolean().default(false),
|
||||||
|
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "At least one field is required",
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/sidecars", async () => {
|
||||||
|
return { sidecars: await deps.sidecarManager.list() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/sidecars", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarCreateSchema.parse(request.body ?? {})
|
||||||
|
const sidecar = await deps.sidecarManager.create(body)
|
||||||
|
reply.code(201)
|
||||||
|
return sidecar
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarUpdateSchema.parse(request.body ?? {})
|
||||||
|
return await deps.sidecarManager.update(request.params.id, body)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
const removed = await deps.sidecarManager.delete(request.params.id)
|
||||||
|
if (!removed) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "SideCar not found" }
|
||||||
|
}
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { FastifyInstance, FastifyReply } from "fastify"
|
import { FastifyInstance, FastifyReply } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { WorkspaceManager } from "../../workspaces/manager"
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
|
||||||
|
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
|
||||||
|
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
|
||||||
|
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
@@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({
|
|||||||
contents: z.string(),
|
contents: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WorktreeGitDiffQuerySchema = z.object({
|
||||||
|
path: z.string().trim().min(1, "Path is required"),
|
||||||
|
originalPath: z.string().trim().optional(),
|
||||||
|
scope: z.enum(["staged", "unstaged"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeGitPathsBodySchema = z.object({
|
||||||
|
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeGitCommitBodySchema = z.object({
|
||||||
|
message: z.string().trim().min(1, "Commit message is required"),
|
||||||
|
})
|
||||||
|
|
||||||
const WorkspaceFileSearchQuerySchema = z.object({
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, "Query is required"),
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
@@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return handleWorkspaceError(error, reply)
|
return handleWorkspaceError(error, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
return await getWorktreeGitDiff({
|
||||||
|
workspaceFolder: directory,
|
||||||
|
path: query.path,
|
||||||
|
originalPath: query.originalPath,
|
||||||
|
scope: query.scope,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Body: { paths: string[] }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
|
||||||
|
return { ok: true as const }
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Body: { paths: string[] }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
|
||||||
|
return { ok: true as const }
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Body: { message: string }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
|
||||||
|
return { ok: true as const, ...result }
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGitWorktreeDirectory(
|
||||||
|
workspaceManager: WorkspaceManager,
|
||||||
|
workspaceId: string,
|
||||||
|
worktreeSlug: string,
|
||||||
|
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const workspace = workspaceManager.get(workspaceId)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
reply.send({ error: "Workspace not found" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitAvailable = await isGitAvailable(workspace.path)
|
||||||
|
if (!gitAvailable) {
|
||||||
|
reply.code(503)
|
||||||
|
reply.send({ error: "Git is not installed or not available in PATH" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
reply.code(400)
|
||||||
|
reply.send({ error: "Workspace is not a Git repository" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = await resolveWorktreeDirectory({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
workspacePath: workspace.path,
|
||||||
|
worktreeSlug,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404)
|
||||||
|
reply.send({ error: "Worktree not found" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||||
|
if (isGitMutationError(error)) {
|
||||||
|
reply.code(error.statusCode)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
if (error instanceof Error && error.message === "Workspace not found") {
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
reply.code(404)
|
reply.code(404)
|
||||||
return { error: "Workspace not found" }
|
return { error: "Workspace not found" }
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
if (typeof listeningMode === "string") {
|
if (typeof listeningMode === "string") {
|
||||||
serverConfig.listeningMode = listeningMode
|
serverConfig.listeningMode = listeningMode
|
||||||
}
|
}
|
||||||
|
const logLevel = preferences.logLevel
|
||||||
|
if (typeof logLevel === "string") {
|
||||||
|
serverConfig.logLevel = logLevel
|
||||||
|
}
|
||||||
const lastUsedBinary = preferences.lastUsedBinary
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
if (typeof lastUsedBinary === "string") {
|
if (typeof lastUsedBinary === "string") {
|
||||||
serverConfig.opencodeBinary = lastUsedBinary
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
const moved = new Set([
|
const moved = new Set([
|
||||||
"environmentVariables",
|
"environmentVariables",
|
||||||
"listeningMode",
|
"listeningMode",
|
||||||
|
"logLevel",
|
||||||
"lastUsedBinary",
|
"lastUsedBinary",
|
||||||
"modelRecents",
|
"modelRecents",
|
||||||
"modelFavorites",
|
"modelFavorites",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { ConfigLocation } from "../config/location"
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { z } from "zod"
|
||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
|
|||||||
|
|
||||||
export type DocKind = "config" | "state"
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
const CanonicalLogLevelSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||||
|
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||||
|
if (parsedLogLevel.success) {
|
||||||
|
next.logLevel = parsedLogLevel.data
|
||||||
|
} else if (next.logLevel !== undefined) {
|
||||||
|
next.logLevel = "DEBUG"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(doc)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(doc.server)) {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
private readonly configStore: YamlDocStore
|
private readonly configStore: YamlDocStore
|
||||||
private readonly stateStore: YamlDocStore
|
private readonly stateStore: YamlDocStore
|
||||||
@@ -23,22 +72,44 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDoc(kind: DocKind): SettingsDoc {
|
getDoc(kind: DocKind): SettingsDoc {
|
||||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.configStore.get()
|
||||||
|
const normalized = normalizeConfigDoc(current)
|
||||||
|
if (!isDeepEqual(current, normalized)) {
|
||||||
|
this.configStore.replace(normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
const updated =
|
||||||
|
kind === "config"
|
||||||
|
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||||
|
: this.stateStore.mergePatch(patch)
|
||||||
this.publish(kind, "*")
|
this.publish(kind, "*")
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
return owner === "server"
|
||||||
|
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||||
|
: this.getDoc("config")[owner] as SettingsDoc
|
||||||
}
|
}
|
||||||
|
|
||||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
const updated =
|
const updated =
|
||||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
kind === "config"
|
||||||
|
? owner === "server"
|
||||||
|
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||||
|
: this.configStore.mergePatchOwner(owner, patch)
|
||||||
|
: this.stateStore.mergePatchOwner(owner, patch)
|
||||||
this.publish(kind, owner, updated)
|
this.publish(kind, owner, updated)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|||||||
256
packages/server/src/sidecars/manager.ts
Normal file
256
packages/server/src/sidecars/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { connect } from "net"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
|
||||||
|
|
||||||
|
interface SideCarManagerOptions {
|
||||||
|
settings: SettingsService
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarConfigRecord {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarRuntimeRecord {
|
||||||
|
status: SideCarStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SideCarManager {
|
||||||
|
private readonly configs = new Map<string, SideCarConfigRecord>()
|
||||||
|
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
|
||||||
|
|
||||||
|
constructor(private readonly options: SideCarManagerOptions) {
|
||||||
|
for (const record of this.loadConfiguredSideCars()) {
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
for (const record of this.configs.values()) {
|
||||||
|
void this.refreshPortSideCar(record.id).catch((error) => {
|
||||||
|
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<SideCar[]> {
|
||||||
|
await this.refreshPortStatuses()
|
||||||
|
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<SideCar | undefined> {
|
||||||
|
if (!this.configs.has(id)) return undefined
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(this.requireConfig(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}): Promise<SideCar> {
|
||||||
|
const normalizedName = input.name.trim()
|
||||||
|
const id = this.buildSideCarId(normalizedName)
|
||||||
|
if (this.configs.has(id)) {
|
||||||
|
throw new Error(`SideCar '${id}' already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const record: SideCarConfigRecord = {
|
||||||
|
id,
|
||||||
|
kind: input.kind,
|
||||||
|
name: normalizedName,
|
||||||
|
port: input.port,
|
||||||
|
insecure: input.insecure,
|
||||||
|
prefixMode: input.prefixMode,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(record.id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: Partial<{
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}>,
|
||||||
|
): Promise<SideCar> {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
|
||||||
|
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||||
|
record.port = typeof input.port === "number" ? input.port : record.port
|
||||||
|
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||||
|
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return false
|
||||||
|
|
||||||
|
this.configs.delete(id)
|
||||||
|
this.runtime.delete(id)
|
||||||
|
this.persistConfigs()
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
|
||||||
|
const protocol = sidecar.insecure ? "http" : "https"
|
||||||
|
return `${protocol}://127.0.0.1:${sidecar.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildProxyBasePath(id: string): string {
|
||||||
|
return `/sidecars/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetPath(id: string, incomingPath: string, search = ""): string {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
const publicBase = this.buildProxyBasePath(id)
|
||||||
|
const normalizedPath = incomingPath || publicBase
|
||||||
|
|
||||||
|
if (record.prefixMode === "preserve") {
|
||||||
|
return `${normalizedPath}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
|
||||||
|
if (!stripped || stripped === "/") {
|
||||||
|
stripped = "/"
|
||||||
|
} else if (!stripped.startsWith("/")) {
|
||||||
|
stripped = `/${stripped}`
|
||||||
|
}
|
||||||
|
return `${stripped}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortStatuses() {
|
||||||
|
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortSideCar(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
const isAvailable = await this.isPortAvailable(record.port)
|
||||||
|
const current = this.runtime.get(id)
|
||||||
|
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
|
||||||
|
if (current?.status === nextStatus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtime.set(id, { status: nextStatus })
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
this.publish(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSideCar(record: SideCarConfigRecord): SideCar {
|
||||||
|
const runtime = this.runtime.get(record.id)
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
kind: record.kind,
|
||||||
|
name: record.name,
|
||||||
|
port: record.port,
|
||||||
|
insecure: record.insecure,
|
||||||
|
prefixMode: record.prefixMode,
|
||||||
|
status: runtime?.status ?? "stopped",
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireConfig(id: string): SideCarConfigRecord {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("SideCar not found")
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistConfigs() {
|
||||||
|
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
|
||||||
|
this.options.settings.mergePatchOwner("config", "server", { sidecars })
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfiguredSideCars(): SideCarConfigRecord[] {
|
||||||
|
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
|
||||||
|
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
|
||||||
|
const records: SideCarConfigRecord[] = []
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
const kind = record.kind === "port" ? "port" : null
|
||||||
|
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
|
||||||
|
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
|
||||||
|
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
|
||||||
|
if (!kind || !id || !name || !port) continue
|
||||||
|
|
||||||
|
const insecure = record.insecure === true
|
||||||
|
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
|
||||||
|
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
|
||||||
|
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
|
||||||
|
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||||
|
socket.end()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
socket.once("error", () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSideCarId(name: string): string {
|
||||||
|
const normalized = name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("SideCar name must include letters or numbers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
121
packages/server/src/workspaces/git-mutations.ts
Normal file
121
packages/server/src/workspaces/git-mutations.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { spawn } from "child_process"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
|
||||||
|
class GitMutationError extends Error {
|
||||||
|
statusCode: number
|
||||||
|
|
||||||
|
constructor(message: string, statusCode = 400) {
|
||||||
|
super(message)
|
||||||
|
this.name = "GitMutationError"
|
||||||
|
this.statusCode = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function normalizeGitWorktreeRelativePath(input: string): string {
|
||||||
|
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
|
||||||
|
if (!normalized) {
|
||||||
|
throw new GitMutationError("Path is required", 400)
|
||||||
|
}
|
||||||
|
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
||||||
|
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
|
||||||
|
}
|
||||||
|
if (normalized === "." || normalized === "..") {
|
||||||
|
throw new GitMutationError(`Invalid path: ${input}`, 400)
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
|
||||||
|
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGitMutationPaths(paths: string[]): string[] {
|
||||||
|
const deduped = new Set<string>()
|
||||||
|
for (const rawPath of paths) {
|
||||||
|
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
|
||||||
|
}
|
||||||
|
const normalized = Array.from(deduped)
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new GitMutationError("At least one path is required", 400)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
|
||||||
|
const result = await resultPromise
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.stderr?.trim() || result.error.message || fallbackMessage
|
||||||
|
throw new GitMutationError(message, 409)
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGitMutationError(error: unknown): error is GitMutationError {
|
||||||
|
return error instanceof GitMutationError
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
|
||||||
|
const paths = normalizeGitMutationPaths(params.paths)
|
||||||
|
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
|
||||||
|
const paths = normalizeGitMutationPaths(params.paths)
|
||||||
|
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
|
||||||
|
if (headResult.ok) {
|
||||||
|
await ensureGitCommandSucceeded(
|
||||||
|
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
|
||||||
|
"Failed to unstage files",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureGitCommandSucceeded(
|
||||||
|
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
|
||||||
|
"Failed to unstage files",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
|
||||||
|
const message = params.message.trim()
|
||||||
|
if (!message) {
|
||||||
|
throw new GitMutationError("Commit message is required", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
|
||||||
|
|
||||||
|
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
|
||||||
|
if (!shaResult.ok) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitSha = shaResult.stdout.trim()
|
||||||
|
return commitSha ? { commitSha } : {}
|
||||||
|
}
|
||||||
385
packages/server/src/workspaces/git-status.ts
Normal file
385
packages/server/src/workspaces/git-status.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { spawn } from "child_process"
|
||||||
|
import { readFile } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
|
||||||
|
import type { LogLike } from "./git-worktrees"
|
||||||
|
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
|
||||||
|
|
||||||
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
type GitSuccessResult = Extract<GitResult, { ok: true }>
|
||||||
|
|
||||||
|
async function readFileAsDiffText(filePath: string): Promise<string> {
|
||||||
|
return readFile(filePath, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
|
||||||
|
const result = await resultPromise
|
||||||
|
if (!result.ok) {
|
||||||
|
return decodeGitShowResult(result, missingOk)
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): 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 (acceptedExitCodes.includes(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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
|
||||||
|
const existing = map.get(path)
|
||||||
|
if (existing) return existing
|
||||||
|
const next: WorktreeGitStatusEntry = {
|
||||||
|
path,
|
||||||
|
originalPath: null,
|
||||||
|
stagedStatus: null,
|
||||||
|
stagedAdditions: 0,
|
||||||
|
stagedDeletions: 0,
|
||||||
|
unstagedStatus: null,
|
||||||
|
unstagedAdditions: 0,
|
||||||
|
unstagedDeletions: 0,
|
||||||
|
}
|
||||||
|
map.set(path, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGitStatusPath(value: string): string {
|
||||||
|
return value.trim().replace(/\\+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitChangeKind(code: string): GitChangeKind | null {
|
||||||
|
const normalized = code.trim().toUpperCase()
|
||||||
|
if (!normalized) return null
|
||||||
|
if (normalized === "A") return "added"
|
||||||
|
if (normalized === "M") return "modified"
|
||||||
|
if (normalized === "D") return "deleted"
|
||||||
|
if (normalized.startsWith("R")) return "renamed"
|
||||||
|
if (normalized.startsWith("C")) return "copied"
|
||||||
|
if (normalized === "U") return "unmerged"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNameStatusOutput(
|
||||||
|
map: Map<string, WorktreeGitStatusEntry>,
|
||||||
|
output: string,
|
||||||
|
target: "stagedStatus" | "unstagedStatus",
|
||||||
|
) {
|
||||||
|
const tokens = output.split("\0")
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
while (index < tokens.length) {
|
||||||
|
const record = tokens[index++] ?? ""
|
||||||
|
if (!record) continue
|
||||||
|
|
||||||
|
const parts = record.split("\t")
|
||||||
|
const statusCode = parseGitChangeKind(parts[0] ?? "")
|
||||||
|
if (!statusCode) continue
|
||||||
|
|
||||||
|
const inlinePath = parts.slice(1).join("\t")
|
||||||
|
const firstPath = inlinePath || tokens[index++] || ""
|
||||||
|
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
|
||||||
|
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
|
||||||
|
const normalizedPath = normalizeGitStatusPath(path)
|
||||||
|
if (!normalizedPath) continue
|
||||||
|
const entry = ensureEntry(map, normalizedPath)
|
||||||
|
entry[target] = statusCode
|
||||||
|
if (statusCode === "renamed" || statusCode === "copied") {
|
||||||
|
const originalPath = normalizeGitStatusPath(firstPath)
|
||||||
|
entry.originalPath = originalPath || entry.originalPath || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
|
||||||
|
for (const rawLine of output.split(/\r?\n/)) {
|
||||||
|
const path = normalizeGitStatusPath(rawLine)
|
||||||
|
if (!path) continue
|
||||||
|
ensureEntry(map, path).unstagedStatus = "untracked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
|
||||||
|
for (const rawLine of output.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line) continue
|
||||||
|
const parts = rawLine.split("\t")
|
||||||
|
const isBinary = parts[0] === "-" || parts[1] === "-"
|
||||||
|
return {
|
||||||
|
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
|
||||||
|
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
|
||||||
|
isBinary,
|
||||||
|
found: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { additions: 0, deletions: 0, isBinary: false, found: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
|
||||||
|
const absolutePath = path.join(workspaceFolder, relativePath)
|
||||||
|
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSingleNumstat(result.stdout)
|
||||||
|
return { additions: parsed.additions, deletions: parsed.deletions }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
|
||||||
|
const pending = Array.from(map.values())
|
||||||
|
.filter((entry) => entry.unstagedStatus === "untracked")
|
||||||
|
.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
|
||||||
|
entry.unstagedAdditions = stats.additions
|
||||||
|
entry.unstagedDeletions = stats.deletions
|
||||||
|
} catch {
|
||||||
|
entry.unstagedAdditions = 0
|
||||||
|
entry.unstagedDeletions = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNumstatOutput(
|
||||||
|
map: Map<string, WorktreeGitStatusEntry>,
|
||||||
|
output: string,
|
||||||
|
target: "staged" | "unstaged",
|
||||||
|
) {
|
||||||
|
const tokens = output.split("\0")
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
while (index < tokens.length) {
|
||||||
|
const record = tokens[index++] ?? ""
|
||||||
|
if (!record) continue
|
||||||
|
|
||||||
|
const parts = record.split("\t")
|
||||||
|
if (parts.length < 3) continue
|
||||||
|
|
||||||
|
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
|
||||||
|
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
|
||||||
|
const inlinePath = parts.slice(2).join("\t")
|
||||||
|
const isRenameLike = inlinePath === ""
|
||||||
|
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
|
||||||
|
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
|
||||||
|
if (!normalizedPath) continue
|
||||||
|
|
||||||
|
const entry = ensureEntry(map, normalizedPath)
|
||||||
|
if (originalPath) {
|
||||||
|
entry.originalPath = originalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === "staged") {
|
||||||
|
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
|
||||||
|
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
|
||||||
|
} else {
|
||||||
|
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
|
||||||
|
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorktreeGitStatus(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<WorktreeGitStatusEntry[]> {
|
||||||
|
const { workspaceFolder, logger } = params
|
||||||
|
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
|
||||||
|
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
|
||||||
|
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
|
||||||
|
if (!result.ok) {
|
||||||
|
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedOutput = (stagedResult as GitSuccessResult).stdout
|
||||||
|
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
|
||||||
|
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
|
||||||
|
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
|
||||||
|
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
|
||||||
|
|
||||||
|
const entries = new Map<string, WorktreeGitStatusEntry>()
|
||||||
|
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
|
||||||
|
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
|
||||||
|
applyUntrackedOutput(entries, untrackedOutput)
|
||||||
|
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
|
||||||
|
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
|
||||||
|
await applyUntrackedFileStats(entries, workspaceFolder)
|
||||||
|
|
||||||
|
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeGitShowResult(result: GitResult, missingOk = false): string {
|
||||||
|
if (result.ok) return result.stdout
|
||||||
|
const message = result.stderr?.trim() || result.error.message || ""
|
||||||
|
if (
|
||||||
|
missingOk &&
|
||||||
|
(message.includes("exists on disk, but not in") ||
|
||||||
|
message.includes("Path '") ||
|
||||||
|
message.includes("does not exist") ||
|
||||||
|
message.includes("unknown revision or path not in the working tree"))
|
||||||
|
) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
|
||||||
|
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrackedDiffMetadata(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
normalizedPath: string
|
||||||
|
normalizedOriginalPath: string | null
|
||||||
|
}): Promise<{ isBinary: boolean; found: boolean }> {
|
||||||
|
const args = ["diff", "--numstat"]
|
||||||
|
if (params.scope === "staged") {
|
||||||
|
args.push("--cached")
|
||||||
|
}
|
||||||
|
args.push("--find-renames", "--find-copies", "--")
|
||||||
|
args.push(params.normalizedPath)
|
||||||
|
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
|
||||||
|
args.push(params.normalizedOriginalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runGit(args, params.workspaceFolder)
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSingleNumstat(result.stdout)
|
||||||
|
return { isBinary: parsed.isBinary, found: parsed.found }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUntrackedDiffMetadata(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
normalizedPath: string
|
||||||
|
}): Promise<{ isBinary: boolean }> {
|
||||||
|
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
|
||||||
|
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUnstagedBeforePath(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
normalizedPath: string
|
||||||
|
normalizedOriginalPath: string | null
|
||||||
|
}): Promise<GitResult> {
|
||||||
|
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
|
||||||
|
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
|
||||||
|
return currentPathResult
|
||||||
|
}
|
||||||
|
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorktreeGitDiff(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
}): Promise<WorktreeGitDiffResponse> {
|
||||||
|
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
|
||||||
|
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
|
||||||
|
|
||||||
|
const trackedMetadata = await getTrackedDiffMetadata({
|
||||||
|
workspaceFolder: params.workspaceFolder,
|
||||||
|
scope: params.scope,
|
||||||
|
normalizedPath,
|
||||||
|
normalizedOriginalPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffMetadata =
|
||||||
|
params.scope === "unstaged" && !trackedMetadata.found
|
||||||
|
? await getUntrackedDiffMetadata({
|
||||||
|
workspaceFolder: params.workspaceFolder,
|
||||||
|
normalizedPath,
|
||||||
|
})
|
||||||
|
: trackedMetadata
|
||||||
|
|
||||||
|
if (diffMetadata.isBinary) {
|
||||||
|
return {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: normalizedOriginalPath,
|
||||||
|
scope: params.scope,
|
||||||
|
before: "",
|
||||||
|
after: "",
|
||||||
|
isBinary: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.scope === "staged") {
|
||||||
|
const [beforeResult, afterResult] = await Promise.all([
|
||||||
|
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
|
||||||
|
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: normalizedOriginalPath,
|
||||||
|
scope: params.scope,
|
||||||
|
before: beforeResult,
|
||||||
|
after: afterResult,
|
||||||
|
isBinary: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexResult = await resolveUnstagedBeforePath({
|
||||||
|
workspaceFolder: params.workspaceFolder,
|
||||||
|
normalizedPath,
|
||||||
|
normalizedOriginalPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
|
||||||
|
let after = beforeResult
|
||||||
|
|
||||||
|
const fsPath = path.join(params.workspaceFolder, normalizedPath)
|
||||||
|
try {
|
||||||
|
after = await readFileAsDiffText(fsPath)
|
||||||
|
} catch {
|
||||||
|
after = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: normalizedOriginalPath,
|
||||||
|
scope: params.scope,
|
||||||
|
before: beforeResult,
|
||||||
|
after,
|
||||||
|
isBinary: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ export interface LogLike {
|
|||||||
|
|
||||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
|
||||||
|
function isGitUnavailableResult(result: GitResult): boolean {
|
||||||
|
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
|
||||||
|
}
|
||||||
|
|
||||||
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||||
@@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
|
|||||||
|
|
||||||
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||||
|
if (isGitUnavailableResult(result)) {
|
||||||
|
throw new Error("Git is not installed or not available in PATH")
|
||||||
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||||
return { repoRoot: folder, isGitRepo: false }
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
|
|||||||
return { repoRoot, isGitRepo: true }
|
return { repoRoot, isGitRepo: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isGitAvailable(folder: string): Promise<boolean> {
|
||||||
|
const result = await runGit(["--version"], folder)
|
||||||
|
return result.ok || !isGitUnavailableResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
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 records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||||
const lines = output.split(/\r?\n/)
|
const lines = output.split(/\r?\n/)
|
||||||
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
|
|||||||
logger?: LogLike
|
logger?: LogLike
|
||||||
}): Promise<WorktreeDescriptor[]> {
|
}): Promise<WorktreeDescriptor[]> {
|
||||||
const { repoRoot, workspaceFolder, logger } = params
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
|
||||||
|
|
||||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||||
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||||
return [rootDescriptor]
|
return [rootDescriptor]
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = parseWorktreePorcelain(result.stdout)
|
const records = parseWorktreePorcelain(result.stdout)
|
||||||
|
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
|
||||||
|
const rootDescriptor: WorktreeDescriptor = {
|
||||||
|
slug: "root",
|
||||||
|
directory: repoRoot,
|
||||||
|
kind: "root",
|
||||||
|
branch: rootRecord?.branch,
|
||||||
|
}
|
||||||
|
|
||||||
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
const seen = new Set<string>(["root"])
|
const seen = new Set<string>(["root"])
|
||||||
|
|||||||
@@ -142,12 +142,15 @@ export class WorkspaceManager {
|
|||||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logLevel = (serverConfig as any)?.logLevel
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
|
logLevel,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ interface LaunchOptions {
|
|||||||
folder: string
|
folder: string
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
environment?: Record<string, string>
|
environment?: Record<string, string>
|
||||||
|
logLevel?: string
|
||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
|
|||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||||
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||||
|
|||||||
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { realpath } from "fs/promises"
|
||||||
|
import type { LogLike } from "./git-worktrees"
|
||||||
|
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
|
||||||
|
|
||||||
|
type WorktreeCacheEntry = {
|
||||||
|
expiresAt: number
|
||||||
|
repoRoot: string
|
||||||
|
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKTREE_CACHE_TTL_MS = 2000
|
||||||
|
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||||
|
|
||||||
|
async function normalizeDirectoryPath(directory: string): Promise<string> {
|
||||||
|
const trimmed = (directory ?? "").trim()
|
||||||
|
if (!trimmed) return ""
|
||||||
|
try {
|
||||||
|
return await realpath(trimmed)
|
||||||
|
} catch {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
|
||||||
|
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: await Promise.all(
|
||||||
|
worktrees.map(async (wt) => ({
|
||||||
|
slug: wt.slug,
|
||||||
|
directory: wt.directory,
|
||||||
|
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
worktreeCache.set(params.workspaceId, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWorktreeDirectory(params: {
|
||||||
|
workspaceId: string
|
||||||
|
workspacePath: string
|
||||||
|
worktreeSlug: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const cached = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
|
||||||
|
if (match) {
|
||||||
|
return match.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreeCache.delete(params.workspaceId)
|
||||||
|
const refreshed = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWorktreeSlugForDirectory(params: {
|
||||||
|
workspaceId: string
|
||||||
|
workspacePath: string
|
||||||
|
directory: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const target = await normalizeDirectoryPath(params.directory ?? "")
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
const cached = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
|
||||||
|
if (match) {
|
||||||
|
return match.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreeCache.delete(params.workspaceId)
|
||||||
|
const refreshed = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
|
||||||
|
}
|
||||||
2
packages/tauri-app/Cargo.lock
generated
2
packages/tauri-app/Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -28,4 +28,4 @@ url = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||||
|
|||||||
@@ -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","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","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","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2378,6 +2378,72 @@
|
|||||||
"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": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:default",
|
||||||
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-is-registered",
|
||||||
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register",
|
||||||
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register-all",
|
||||||
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister",
|
||||||
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all 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`",
|
"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",
|
"type": "string",
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::ffi::c_void;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::mem::{size_of, zeroed};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
@@ -19,12 +23,95 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::System::JobObjects::{
|
||||||
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||||
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WindowsJobObject {
|
||||||
|
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
||||||
|
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
||||||
|
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
||||||
|
handle: HANDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl WindowsJobObject {
|
||||||
|
fn create() -> anyhow::Result<Self> {
|
||||||
|
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
||||||
|
if handle.is_null() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"CreateJobObjectW failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||||
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
|
||||||
|
let ok = unsafe {
|
||||||
|
SetInformationJobObject(
|
||||||
|
handle,
|
||||||
|
JobObjectExtendedLimitInformation,
|
||||||
|
&mut info as *mut _ as *mut c_void,
|
||||||
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(handle);
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
||||||
|
let process_handle = child.as_raw_handle() as HANDLE;
|
||||||
|
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"AssignProcessToJobObject failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl Drop for WindowsJobObject {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.handle.is_null() {
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(self.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe impl Send for WindowsJobObject {}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe impl Sync for WindowsJobObject {}
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
@@ -363,6 +450,8 @@ impl Default for CliStatus {
|
|||||||
pub struct CliProcessManager {
|
pub struct CliProcessManager {
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
@@ -372,6 +461,8 @@ impl CliProcessManager {
|
|||||||
Self {
|
Self {
|
||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -394,6 +485,8 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_arc = self.job.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
let token_arc = self.bootstrap_token.clone();
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -401,6 +494,8 @@ impl CliProcessManager {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
status_arc.clone(),
|
status_arc.clone(),
|
||||||
child_arc,
|
child_arc,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job_arc,
|
||||||
ready_flag,
|
ready_flag,
|
||||||
token_arc,
|
token_arc,
|
||||||
dev,
|
dev,
|
||||||
@@ -420,11 +515,12 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> anyhow::Result<()> {
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _job = self.job.lock().take();
|
||||||
|
|
||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(windows)]
|
|
||||||
let mut forced_tree_shutdown = false;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
let pid = child.id() as i32;
|
||||||
@@ -446,18 +542,16 @@ impl CliProcessManager {
|
|||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if !forced_tree_shutdown
|
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
|
||||||
{
|
|
||||||
log_line(&format!(
|
log_line(&format!(
|
||||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||||
child.id()
|
child.id()
|
||||||
));
|
));
|
||||||
forced_tree_shutdown = true;
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
@@ -476,11 +570,7 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !forced_tree_shutdown
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
&& !kill_process_tree_windows(child.id(), true)
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
} else if forced_tree_shutdown {
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,6 +581,9 @@ impl CliProcessManager {
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(windows)]
|
||||||
|
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut status = self.status.lock();
|
let mut status = self.status.lock();
|
||||||
@@ -511,6 +604,7 @@ impl CliProcessManager {
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
@@ -534,7 +628,9 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("using cwd={}", c.display()));
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let command_info = if supports_user_shell() {
|
let use_user_shell = supports_user_shell();
|
||||||
|
|
||||||
|
let command_info = if use_user_shell {
|
||||||
log_line("spawning via user shell");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
} else {
|
} else {
|
||||||
@@ -545,7 +641,7 @@ impl CliProcessManager {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if !supports_user_shell() {
|
if !use_user_shell {
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"Node binary not found. Make sure Node.js is installed."
|
"Node binary not found. Make sure Node.js is installed."
|
||||||
@@ -559,6 +655,8 @@ impl CliProcessManager {
|
|||||||
let mut c = Command::new(&cmd.shell);
|
let mut c = Command::new(&cmd.shell);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.env_remove("npm_config_prefix")
|
||||||
|
.env_remove("NPM_CONFIG_PREFIX")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
@@ -588,6 +686,22 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
log_line(&format!("spawned pid={pid}"));
|
log_line(&format!("spawned pid={pid}"));
|
||||||
|
#[cfg(windows)]
|
||||||
|
match WindowsJobObject::create().and_then(|job| {
|
||||||
|
job.assign_child(&child)?;
|
||||||
|
Ok(job)
|
||||||
|
}) {
|
||||||
|
Ok(job) => {
|
||||||
|
log_line(&format!("attached pid={pid} to Windows job object"));
|
||||||
|
*job_holder.lock() = Some(job);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_line(&format!(
|
||||||
|
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
locked.pid = Some(pid);
|
locked.pid = Some(pid);
|
||||||
@@ -619,26 +733,41 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stdout",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
auth_cookie_name_clone.as_str(),
|
reader,
|
||||||
);
|
"stdout",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stderr",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
auth_cookie_name_clone.as_str(),
|
reader,
|
||||||
);
|
"stderr",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -646,6 +775,8 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let child_holder_clone = child_holder.clone();
|
let child_holder_clone = child_holder.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_holder_clone = job_holder.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let timeout = Duration::from_secs(60);
|
let timeout = Duration::from_secs(60);
|
||||||
thread::sleep(timeout);
|
thread::sleep(timeout);
|
||||||
@@ -700,6 +831,10 @@ impl CliProcessManager {
|
|||||||
// Drop the handle after the process exits so other callers
|
// Drop the handle after the process exits so other callers
|
||||||
// don't attempt to stop/kill a finished process.
|
// don't attempt to stop/kill a finished process.
|
||||||
*guard = None;
|
*guard = None;
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = job_holder_clone.lock().take();
|
||||||
|
}
|
||||||
Some(status)
|
Some(status)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -757,8 +892,8 @@ impl CliProcessManager {
|
|||||||
auth_cookie_name: &str,
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
let local_url_regex =
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -800,38 +935,6 @@ impl CliProcessManager {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.to_lowercase().contains("http server listening") {
|
|
||||||
if let Some(port) = http_regex
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
|
||||||
{
|
|
||||||
Self::mark_ready(
|
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
auth_cookie_name,
|
|
||||||
format!("http://localhost:{port}"),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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()) {
|
|
||||||
Self::mark_ready(
|
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
auth_cookie_name,
|
|
||||||
format!("http://localhost:{}", port),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -976,6 +1079,7 @@ impl CliEntry {
|
|||||||
"--auth-cookie-name".to_string(),
|
"--auth-cookie-name".to_string(),
|
||||||
auth_cookie_name.to_string(),
|
auth_cookie_name.to_string(),
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
|
"--unrestricted-root".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
@@ -1031,27 +1135,58 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||||
let candidates = vec![
|
let cwd = std::env::current_dir().ok();
|
||||||
std::env::current_dir()
|
let workspace = workspace_root();
|
||||||
.ok()
|
let mut candidates = vec![
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
std::env::current_exe().ok().and_then(|ex| {
|
|
||||||
ex.parent()
|
|
||||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let cwd = std::env::current_dir().ok();
|
||||||
|
let workspace = workspace_root();
|
||||||
let candidates = vec![
|
let candidates = vec![
|
||||||
std::env::current_dir()
|
workspace
|
||||||
.ok()
|
.as_ref()
|
||||||
.map(|p| p.join("packages/server/src/index.ts")),
|
.map(|p| p.join("packages/server/src/index.ts")),
|
||||||
std::env::current_dir()
|
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||||
.ok()
|
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||||
.map(|p| p.join("../server/src/index.ts")),
|
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
@@ -1153,11 +1288,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
if shell_name.contains("zsh") {
|
let _ = shell_name;
|
||||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
} else {
|
|
||||||
vec!["-l".into(), "-c".into(), command.into()]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ use cli_manager::{CliProcessManager, CliStatus};
|
|||||||
use keepawake::KeepAwake;
|
use keepawake::KeepAwake;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
use tauri::{
|
||||||
|
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||||
|
};
|
||||||
use tauri_plugin_global_shortcut::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -30,7 +33,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||||
const ZOOM_STEP: f64 = 0.2;
|
const ZOOM_STEP: f64 = 0.1;
|
||||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||||
|
|
||||||
@@ -41,6 +44,16 @@ pub struct AppState {
|
|||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
pub zoom_level: Mutex<f64>,
|
||||||
|
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RemoteWindowPayload {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
base_url: String,
|
||||||
|
skip_tls_verify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -118,11 +131,32 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
fn should_allow_window_origin<R: Runtime>(
|
||||||
|
app_handle: &AppHandle<R>,
|
||||||
|
window_label: &str,
|
||||||
|
url: &Url,
|
||||||
|
) -> bool {
|
||||||
if should_allow_internal(url) {
|
if should_allow_internal(url) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
let Ok(allowed) = state.remote_origins.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(origin) = allowed.get(window_label) {
|
||||||
|
return origin == &url.origin().ascii_serialization();
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||||
|
let window_label = webview.label().to_string();
|
||||||
|
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(err) = webview
|
if let Err(err) = webview
|
||||||
.app_handle()
|
.app_handle()
|
||||||
.opener()
|
.opener()
|
||||||
@@ -133,6 +167,58 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||||
|
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||||
|
return Err(
|
||||||
|
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||||
|
let label = format!("remote-{}", payload.id);
|
||||||
|
let title = format!(
|
||||||
|
"{} - {}",
|
||||||
|
payload.name,
|
||||||
|
parsed.host_str().unwrap_or(payload.base_url.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(existing) = app.get_webview_window(&label) {
|
||||||
|
let _ = existing.navigate(parsed.clone());
|
||||||
|
let _ = existing.set_title(&title);
|
||||||
|
let _ = existing.show();
|
||||||
|
let _ = existing.unminimize();
|
||||||
|
let _ = existing.set_focus();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.state::<AppState>()
|
||||||
|
.remote_origins
|
||||||
|
.lock()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||||
|
|
||||||
|
let window =
|
||||||
|
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||||
|
.title(title)
|
||||||
|
.inner_size(1400.0, 900.0)
|
||||||
|
.min_inner_size(800.0, 600.0)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if let WindowEvent::Destroyed = event {
|
||||||
|
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||||
|
origins.remove(&label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -286,6 +372,7 @@ fn main() {
|
|||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||||
|
remote_origins: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
@@ -323,7 +410,8 @@ fn main() {
|
|||||||
cli_get_status,
|
cli_get_status,
|
||||||
cli_restart,
|
cli_restart,
|
||||||
wake_lock_start,
|
wake_lock_start,
|
||||||
wake_lock_stop
|
wake_lock_stop,
|
||||||
|
open_remote_window
|
||||||
])
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
@@ -455,11 +543,24 @@ fn main() {
|
|||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Ensure we have time to stop the CLI process before the app exits.
|
// Let windows close normally. App shutdown is handled only after the
|
||||||
|
// last window is actually gone so remote windows can outlive `main`.
|
||||||
|
let _ = api;
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
event: tauri::WindowEvent::Destroyed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !app_handle.webview_windows().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the CLI only when the final window is gone and the app is
|
||||||
|
// truly exiting.
|
||||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.prevent_close();
|
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.neuralnomads.codenomad.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
|||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
|
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
|
||||||
|
import { SideCarView } from "./components/sidecar-view"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { showAlertDialog } from "./stores/alerts"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
|
|||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
setIsSelectingFolder,
|
setIsSelectingFolder,
|
||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
|||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
instances,
|
instances,
|
||||||
activeInstanceId,
|
|
||||||
setActiveInstanceId,
|
|
||||||
stopInstance,
|
stopInstance,
|
||||||
getActiveInstance,
|
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
} from "./stores/instances"
|
} from "./stores/instances"
|
||||||
@@ -53,6 +52,22 @@ import {
|
|||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
import { openSettings } from "./stores/settings-screen"
|
import { openSettings } from "./stores/settings-screen"
|
||||||
|
import {
|
||||||
|
closeSidecarTab,
|
||||||
|
ensureSidecarsLoaded,
|
||||||
|
openSidecarTab,
|
||||||
|
} from "./stores/sidecars"
|
||||||
|
import {
|
||||||
|
activeAppTab,
|
||||||
|
activeAppTabId,
|
||||||
|
appTabs,
|
||||||
|
ensureActiveAppTab,
|
||||||
|
getAdjacentAppTabId,
|
||||||
|
getAppTabById,
|
||||||
|
selectAppTab,
|
||||||
|
selectInstanceTab,
|
||||||
|
selectSidecarTab,
|
||||||
|
} from "./stores/app-tabs"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
|||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
instances()
|
appTabs()
|
||||||
hasInstances()
|
|
||||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
|||||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeInstance = createMemo(() => getActiveInstance())
|
createEffect(() => {
|
||||||
|
appTabs()
|
||||||
|
ensureActiveAppTab()
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeInstance = createMemo(() => {
|
||||||
|
const tab = activeAppTab()
|
||||||
|
return tab?.kind === "instance" ? tab.instance : null
|
||||||
|
})
|
||||||
const activeSessionIdForInstance = createMemo(() => {
|
const activeSessionIdForInstance = createMemo(() => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (!instance) return null
|
if (!instance) return null
|
||||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
|||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
|
selectInstanceTab(instanceId)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
if (hasInstances()) {
|
setShowFolderSelection(true)
|
||||||
setShowFolderSelection(true)
|
}
|
||||||
|
|
||||||
|
function handleOpenSidecarPicker() {
|
||||||
|
setSidecarPickerOpen(true)
|
||||||
|
void ensureSidecarsLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenSidecar(sidecarId: string) {
|
||||||
|
try {
|
||||||
|
const tab = await openSidecarTab(sidecarId)
|
||||||
|
selectSidecarTab(tab.token)
|
||||||
|
setShowFolderSelection(false)
|
||||||
|
setSidecarPickerOpen(false)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
showAlertDialog(message, {
|
||||||
|
variant: "error",
|
||||||
|
title: t("sidecars.open.errorTitle"),
|
||||||
|
})
|
||||||
|
log.error("Failed to open SideCar", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +375,23 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCloseAppTab(tabId: string) {
|
||||||
|
const tab = getAppTabById(tabId)
|
||||||
|
if (!tab) return
|
||||||
|
|
||||||
|
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
|
||||||
|
|
||||||
|
if (tab.kind === "instance") {
|
||||||
|
await handleCloseInstance(tab.instance.id)
|
||||||
|
} else {
|
||||||
|
closeSidecarTab(tab.sidecarTab.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getAppTabById(tabId)) {
|
||||||
|
ensureActiveAppTab(fallbackTabId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||||
if (!instanceId || !sessionId || sessionId === "info") return
|
if (!instanceId || !sessionId || sessionId === "info") return
|
||||||
await updateSessionAgent(instanceId, sessionId, agent)
|
await updateSessionAgent(instanceId, sessionId, agent)
|
||||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
setToolInputsVisibility,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
|||||||
useAppLifecycle({
|
useAppLifecycle({
|
||||||
setEscapeInDebounce,
|
setEscapeInDebounce,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={appTabs().length === 0}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
<InstanceTabs
|
<InstanceTabs
|
||||||
instances={instances()}
|
tabs={appTabs()}
|
||||||
activeInstanceId={activeInstanceId()}
|
activeTabId={activeAppTabId()}
|
||||||
onSelect={setActiveInstanceId}
|
onSelect={selectAppTab}
|
||||||
onClose={handleCloseInstance}
|
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={Array.from(instances().values())}>
|
|
||||||
{(instance) => {
|
|
||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
|
||||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="flex-1 min-h-0 overflow-hidden"
|
|
||||||
style={{ display: isVisible() ? "flex" : "none" }}
|
|
||||||
data-instance-id={instance.id}
|
|
||||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
|
||||||
data-instance-visible={isVisible() ? "true" : "false"}
|
|
||||||
>
|
|
||||||
<InstanceMetadataProvider instance={instance}>
|
|
||||||
<InstanceShell
|
|
||||||
instance={instance}
|
|
||||||
isActiveInstance={isActiveInstance()}
|
|
||||||
escapeInDebounce={escapeInDebounce()}
|
|
||||||
paletteCommands={paletteCommands}
|
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
|
||||||
onExecuteCommand={executeCommand}
|
|
||||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
|
||||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
|
||||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
|
||||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
|
||||||
/>
|
|
||||||
</InstanceMetadataProvider>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
|
<For each={appTabs()}>
|
||||||
|
{(tab) => {
|
||||||
|
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
|
||||||
|
return tab.kind === "instance" ? (
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
|
data-instance-id={tab.instance.id}
|
||||||
|
data-tab-id={tab.id}
|
||||||
|
data-tab-kind={tab.kind}
|
||||||
|
data-tab-visible={isVisible() ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<InstanceMetadataProvider instance={tab.instance}>
|
||||||
|
<InstanceShell
|
||||||
|
instance={tab.instance}
|
||||||
|
isActiveInstance={isVisible()}
|
||||||
|
escapeInDebounce={escapeInDebounce()}
|
||||||
|
paletteCommands={paletteCommands}
|
||||||
|
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
|
||||||
|
onNewSession={() => handleNewSession(tab.instance.id)}
|
||||||
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
|
||||||
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
|
||||||
|
onExecuteCommand={executeCommand}
|
||||||
|
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||||
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
|
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||||
|
/>
|
||||||
|
</InstanceMetadataProvider>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
|
data-tab-id={tab.id}
|
||||||
|
data-tab-kind={tab.kind}
|
||||||
|
data-tab-visible={isVisible() ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<SideCarView tab={tab.sidecarTab} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<SettingsScreen />
|
<SettingsScreen />
|
||||||
|
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,69 @@
|
|||||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { loadMonaco } from "../../lib/monaco/setup"
|
import { loadMonaco } from "../../lib/monaco/setup"
|
||||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||||
import { useTheme } from "../../lib/theme"
|
import { useTheme } from "../../lib/theme"
|
||||||
|
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
|
||||||
|
|
||||||
interface MonacoDiffViewerProps {
|
interface MonacoDiffViewerProps {
|
||||||
scopeKey: string
|
scopeKey: string
|
||||||
path: string
|
path: string
|
||||||
before: string
|
patch?: string
|
||||||
after: string
|
before?: string
|
||||||
|
after?: string
|
||||||
viewMode?: "split" | "unified"
|
viewMode?: "split" | "unified"
|
||||||
contextMode?: "expanded" | "collapsed"
|
contextMode?: "expanded" | "collapsed"
|
||||||
wordWrap?: "on" | "off"
|
wordWrap?: "on" | "off"
|
||||||
|
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
|
||||||
|
insertContextLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineCount(value: string): number {
|
||||||
|
if (!value) return 1
|
||||||
|
return value.split("\n").length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDigitCount(value: number): number {
|
||||||
|
return String(Math.max(1, value)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnifiedGutterSizing(options: { before: string; after: string }) {
|
||||||
|
const beforeLineCount = getLineCount(options.before)
|
||||||
|
const afterLineCount = getLineCount(options.after)
|
||||||
|
const beforeDigitCount = getDigitCount(beforeLineCount)
|
||||||
|
const afterDigitCount = getDigitCount(afterLineCount)
|
||||||
|
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
|
||||||
|
const extraDigits = Math.max(0, maxDigitCount - 2)
|
||||||
|
const beforeNumberChars = Math.max(2, beforeDigitCount)
|
||||||
|
const afterNumberChars = Math.max(2, afterDigitCount)
|
||||||
|
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
|
||||||
|
|
||||||
|
return {
|
||||||
|
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
|
||||||
|
originalLineNumbersMinChars: beforeNumberChars,
|
||||||
|
modifiedLineNumbersMinChars: afterNumberChars,
|
||||||
|
lineDecorationsWidth: 6 + extraDigits * 2 + fourDigitPenalty * 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSplitGutterSizing(options: { before: string; after: string }) {
|
||||||
|
const beforeLineCount = getLineCount(options.before)
|
||||||
|
const afterLineCount = getLineCount(options.after)
|
||||||
|
const beforeDigitCount = getDigitCount(beforeLineCount)
|
||||||
|
const afterDigitCount = getDigitCount(afterLineCount)
|
||||||
|
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
|
||||||
|
const extraDigits = Math.max(0, maxDigitCount - 2)
|
||||||
|
const beforeNumberChars = Math.max(2, beforeDigitCount)
|
||||||
|
const afterNumberChars = Math.max(2, afterDigitCount)
|
||||||
|
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
|
||||||
|
|
||||||
|
return {
|
||||||
|
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
|
||||||
|
originalLineNumbersMinChars: beforeNumberChars,
|
||||||
|
modifiedLineNumbersMinChars: afterNumberChars,
|
||||||
|
lineDecorationsWidth: 8 + extraDigits * 2 + fourDigitPenalty,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
@@ -21,7 +72,22 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
|
|
||||||
let diffEditor: any = null
|
let diffEditor: any = null
|
||||||
let monaco: any = null
|
let monaco: any = null
|
||||||
|
let splitLayoutFrame: number | null = null
|
||||||
const [ready, setReady] = createSignal(false)
|
const [ready, setReady] = createSignal(false)
|
||||||
|
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
|
||||||
|
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
|
||||||
|
const [widgetHovered, setWidgetHovered] = createSignal(false)
|
||||||
|
const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null)
|
||||||
|
|
||||||
|
const resolvedContent = createMemo(() => {
|
||||||
|
if (props.patch !== undefined && props.patch !== null) {
|
||||||
|
return parsePatchToBeforeAfter(props.patch)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
before: props.before ?? "",
|
||||||
|
after: props.after ?? "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const disposeEditor = () => {
|
const disposeEditor = () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +103,90 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
diffEditor = null
|
diffEditor = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSplitLayoutVariables = () => {
|
||||||
|
if (!host) return
|
||||||
|
host.style.removeProperty("--split-original-line-number-width")
|
||||||
|
host.style.removeProperty("--split-original-delete-sign-left")
|
||||||
|
host.style.removeProperty("--split-original-gutter-width")
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncSplitLayoutVariables = (options: {
|
||||||
|
viewMode: "split" | "unified"
|
||||||
|
originalLineNumbersMinChars: number
|
||||||
|
lineDecorationsWidth: number
|
||||||
|
}) => {
|
||||||
|
if (!host) return
|
||||||
|
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||||
|
window.cancelAnimationFrame(splitLayoutFrame)
|
||||||
|
splitLayoutFrame = null
|
||||||
|
}
|
||||||
|
if (options.viewMode !== "split" || typeof window === "undefined") {
|
||||||
|
clearSplitLayoutVariables()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
splitLayoutFrame = window.requestAnimationFrame(() => {
|
||||||
|
splitLayoutFrame = null
|
||||||
|
if (!host) return
|
||||||
|
const originalLineNumbers = host.querySelector<HTMLElement>(".editor.original .line-numbers")
|
||||||
|
const measuredWidth = originalLineNumbers?.getBoundingClientRect().width ?? 0
|
||||||
|
const lineNumberWidth =
|
||||||
|
measuredWidth > 0 ? measuredWidth : Math.max(12, options.originalLineNumbersMinChars * 6)
|
||||||
|
host.style.setProperty("--split-original-line-number-width", `${lineNumberWidth}px`)
|
||||||
|
host.style.setProperty("--split-original-delete-sign-left", `${lineNumberWidth}px`)
|
||||||
|
host.style.setProperty(
|
||||||
|
"--split-original-gutter-width",
|
||||||
|
`${lineNumberWidth + options.lineDecorationsWidth}px`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
|
||||||
|
|
||||||
|
const getActiveInsertRange = () => {
|
||||||
|
const selection = selectedRange()
|
||||||
|
if (selection) return selection
|
||||||
|
if (widgetHovered() && hoveredLine()) {
|
||||||
|
return { startLine: hoveredLine() as number, endLine: hoveredLine() as number }
|
||||||
|
}
|
||||||
|
const line = hoveredLine()
|
||||||
|
if (!line) return null
|
||||||
|
return { startLine: line, endLine: line }
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutInsertWidget = () => {
|
||||||
|
const modifiedEditor = getModifiedEditor()
|
||||||
|
const container = host
|
||||||
|
if (!modifiedEditor || !container) return
|
||||||
|
const activeRange = getActiveInsertRange()
|
||||||
|
if (!activeRange) {
|
||||||
|
setWidgetPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null
|
||||||
|
if (!modifiedDom) {
|
||||||
|
setWidgetPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = modifiedDom.querySelector<HTMLElement>(".margin")
|
||||||
|
const scrollable = modifiedDom.querySelector<HTMLElement>(".monaco-scrollable-element.editor-scrollable")
|
||||||
|
const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0
|
||||||
|
const scrollTop = modifiedEditor.getScrollTop?.() ?? 0
|
||||||
|
const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18)
|
||||||
|
const modifiedRect = modifiedDom.getBoundingClientRect()
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0)
|
||||||
|
const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2
|
||||||
|
|
||||||
|
setWidgetPosition({ top: centerTop, left: seamLeft })
|
||||||
|
} catch {
|
||||||
|
setWidgetPosition(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -69,10 +219,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
|
|
||||||
|
layoutInsertWidget()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||||
|
window.cancelAnimationFrame(splitLayoutFrame)
|
||||||
|
splitLayoutFrame = null
|
||||||
|
}
|
||||||
|
clearSplitLayoutVariables()
|
||||||
setReady(false)
|
setReady(false)
|
||||||
disposeEditor()
|
disposeEditor()
|
||||||
})
|
})
|
||||||
@@ -83,15 +240,101 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!host) return
|
||||||
|
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const modifiedEditor = diffEditor.getModifiedEditor?.()
|
||||||
|
if (!modifiedEditor?.onDidChangeCursorSelection) return
|
||||||
|
|
||||||
|
const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => {
|
||||||
|
const selection = event?.selection
|
||||||
|
if (!selection || selection.isEmpty?.()) {
|
||||||
|
setSelectedRange(null)
|
||||||
|
layoutInsertWidget()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedRange({
|
||||||
|
startLine: Math.min(selection.startLineNumber, selection.endLineNumber),
|
||||||
|
endLine: Math.max(selection.startLineNumber, selection.endLineNumber),
|
||||||
|
})
|
||||||
|
layoutInsertWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
try {
|
||||||
|
disposable?.dispose?.()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const modifiedEditor = getModifiedEditor()
|
||||||
|
if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return
|
||||||
|
|
||||||
|
const moveDisposable = modifiedEditor.onMouseMove((event: any) => {
|
||||||
|
const lineNumber = event?.target?.position?.lineNumber
|
||||||
|
setHoveredLine(typeof lineNumber === "number" ? lineNumber : null)
|
||||||
|
layoutInsertWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
const leaveDisposable = modifiedEditor.onMouseLeave(() => {
|
||||||
|
if (!widgetHovered()) {
|
||||||
|
setHoveredLine(null)
|
||||||
|
}
|
||||||
|
layoutInsertWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => {
|
||||||
|
layoutInsertWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
try {
|
||||||
|
moveDisposable?.dispose?.()
|
||||||
|
leaveDisposable?.dispose?.()
|
||||||
|
scrollDisposable?.dispose?.()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const activeRange = getActiveInsertRange()
|
||||||
|
if (!activeRange) setWidgetPosition(null)
|
||||||
|
layoutInsertWidget()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||||
|
const { before, after } = resolvedContent()
|
||||||
|
const sizing =
|
||||||
|
viewMode === "unified"
|
||||||
|
? getUnifiedGutterSizing({ before, after })
|
||||||
|
: getSplitGutterSizing({ before, after })
|
||||||
|
const {
|
||||||
|
diffEditorLineNumbersMinChars,
|
||||||
|
originalLineNumbersMinChars,
|
||||||
|
modifiedLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
} = sizing
|
||||||
diffEditor.updateOptions({
|
diffEditor.updateOptions({
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
renderSideBySideInlineBreakpoint: 0,
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
renderIndicators: true,
|
||||||
|
lineNumbersMinChars: diffEditorLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
hideUnchangedRegions:
|
hideUnchangedRegions:
|
||||||
contextMode === "collapsed"
|
contextMode === "collapsed"
|
||||||
? { enabled: true }
|
? { enabled: true }
|
||||||
@@ -100,26 +343,41 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
diffEditor.getOriginalEditor?.()?.updateOptions?.({
|
||||||
|
wordWrap,
|
||||||
|
lineNumbersMinChars: originalLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
diffEditor.getModifiedEditor?.()?.updateOptions?.({
|
||||||
|
wordWrap,
|
||||||
|
lineNumbersMinChars: modifiedLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSplitLayoutVariables({
|
||||||
|
viewMode,
|
||||||
|
originalLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const languageId = inferMonacoLanguageId(monaco, props.path)
|
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||||
|
const { before, after } = resolvedContent()
|
||||||
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||||
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||||
|
|
||||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
|
||||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
|
||||||
diffEditor.setModel({ original, modified })
|
diffEditor.setModel({ original, modified })
|
||||||
|
|
||||||
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||||
@@ -132,5 +390,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return <div class="monaco-viewer" ref={host} />
|
return (
|
||||||
|
<div class="monaco-viewer" ref={host}>
|
||||||
|
<div class="git-change-context-overlay">
|
||||||
|
<Show when={widgetPosition()}>
|
||||||
|
{(position: () => { top: number; left: number }) => (
|
||||||
|
<div
|
||||||
|
class="git-change-context-widget-host"
|
||||||
|
style={{ top: `${position().top}px`, left: `${position().left}px` }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setWidgetHovered(true)
|
||||||
|
layoutInsertWidget()
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setWidgetHovered(false)
|
||||||
|
layoutInsertWidget()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="git-change-context-widget"
|
||||||
|
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
|
||||||
|
title={props.insertContextLabel ?? "Add git change context to prompt"}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const activeRange = getActiveInsertRange()
|
||||||
|
if (!activeRange) return
|
||||||
|
props.onRequestInsertContext?.(activeRange)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
@@ -14,25 +15,48 @@ import { useI18n, type Locale } from "../lib/i18n"
|
|||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
|
|
||||||
|
type HomeTab = "local" | "servers"
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
|
onOpenSidecar?: () => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
const {
|
||||||
|
recentFolders,
|
||||||
|
removeRecentFolder,
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
serverSettings,
|
||||||
|
remoteServers,
|
||||||
|
saveRemoteServerProfile,
|
||||||
|
markRemoteServerConnected,
|
||||||
|
removeRemoteServerProfile,
|
||||||
|
} = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
|
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
|
||||||
|
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
|
||||||
|
const [serverName, setServerName] = createSignal("")
|
||||||
|
const [serverUrl, setServerUrl] = createSignal("")
|
||||||
|
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
|
||||||
|
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||||
|
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||||
|
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -49,10 +73,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
|
const serverList = () => remoteServers()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
|
|
||||||
|
function getActiveListLength() {
|
||||||
|
return activeTab() === "local" ? folders().length : serverList().length
|
||||||
|
}
|
||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = serverSettings().opencodeBinary
|
const lastUsed = serverSettings().opencodeBinary
|
||||||
@@ -64,7 +93,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
function scrollToIndex(index: number) {
|
function scrollToIndex(index: number) {
|
||||||
const container = recentListRef
|
const container = recentListRef
|
||||||
if (!container) return
|
if (!container) return
|
||||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||||
if (!element) return
|
if (!element) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -113,19 +142,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderList = folders()
|
|
||||||
|
|
||||||
if (isBrowseShortcut) {
|
if (isBrowseShortcut) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleBrowse()
|
void handleBrowse()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderList.length === 0) return
|
const listLength = getActiveListLength()
|
||||||
|
if (listLength === 0) return
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -138,7 +166,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
} else if (e.key === "PageDown") {
|
} else if (e.key === "PageDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pageSize = 5
|
const pageSize = 5
|
||||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -156,7 +184,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
scrollToIndex(0)
|
scrollToIndex(0)
|
||||||
} else if (e.key === "End") {
|
} else if (e.key === "End") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = folderList.length - 1
|
const newIndex = listLength - 1
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -165,10 +193,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleEnterKey()
|
handleEnterKey()
|
||||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (folderList.length > 0 && focusMode() === "recent") {
|
if (listLength > 0 && focusMode() === "recent") {
|
||||||
const folder = folderList[selectedIndex()]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[selectedIndex()]
|
||||||
handleRemove(folder.path)
|
if (folder) {
|
||||||
|
handleRemove(folder.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const server = serverList()[selectedIndex()]
|
||||||
|
if (server) {
|
||||||
|
removeRemoteServerProfile(server.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,15 +212,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
function handleEnterKey() {
|
function handleEnterKey() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
const folderList = folders()
|
|
||||||
const index = selectedIndex()
|
const index = selectedIndex()
|
||||||
|
|
||||||
const folder = folderList[index]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[index]
|
||||||
handleFolderSelect(folder.path)
|
if (folder) {
|
||||||
|
handleFolderSelect(folder.path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serverList()[index]
|
||||||
|
if (server) {
|
||||||
|
void handleConnectSavedServer(server.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
activeTab()
|
||||||
|
setSelectedIndex(0)
|
||||||
|
setFocusMode("recent")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const length = getActiveListLength()
|
||||||
|
if (length === 0) {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex() >= length) {
|
||||||
|
setSelectedIndex(length - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -236,6 +296,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetServerDialog() {
|
||||||
|
setServerName("")
|
||||||
|
setServerUrl("")
|
||||||
|
setSkipTlsVerify(false)
|
||||||
|
setServerDialogError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openServerDialog() {
|
||||||
|
resetServerDialog()
|
||||||
|
setIsServerDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||||
|
const trimmedName = input.name.trim()
|
||||||
|
const trimmedUrl = input.baseUrl.trim()
|
||||||
|
if (!trimmedName || !trimmedUrl) {
|
||||||
|
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = await serverApi.probeRemoteServer({
|
||||||
|
baseUrl: trimmedUrl,
|
||||||
|
skipTlsVerify: input.skipTlsVerify,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!probe.ok) {
|
||||||
|
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await saveRemoteServerProfile({
|
||||||
|
id: input.id,
|
||||||
|
name: trimmedName,
|
||||||
|
baseUrl: probe.normalizedUrl,
|
||||||
|
skipTlsVerify: input.skipTlsVerify,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (openWindow) {
|
||||||
|
await openRemoteServerWindow(profile)
|
||||||
|
await markRemoteServerConnected(profile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveServer(openWindow: boolean) {
|
||||||
|
if (isSavingServer()) return
|
||||||
|
setIsSavingServer(true)
|
||||||
|
setServerDialogError(null)
|
||||||
|
try {
|
||||||
|
await probeAndOpenServer(
|
||||||
|
{
|
||||||
|
name: serverName(),
|
||||||
|
baseUrl: serverUrl(),
|
||||||
|
skipTlsVerify: skipTlsVerify(),
|
||||||
|
},
|
||||||
|
openWindow,
|
||||||
|
)
|
||||||
|
setIsServerDialogOpen(false)
|
||||||
|
resetServerDialog()
|
||||||
|
} catch (error) {
|
||||||
|
setServerDialogError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setIsSavingServer(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnectSavedServer(id: string) {
|
||||||
|
const target = remoteServers().find((entry) => entry.id === id)
|
||||||
|
if (!target || connectingServerId()) return
|
||||||
|
setConnectingServerId(id)
|
||||||
|
try {
|
||||||
|
await probeAndOpenServer(target, true)
|
||||||
|
} catch (error) {
|
||||||
|
showAlertDialog(error instanceof Error ? error.message : String(error), {
|
||||||
|
title: t("folderSelection.servers.errorTitle"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setConnectingServerId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -476,90 +617,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||||
{/* Right column: recent folders */}
|
{/* Right column: recent folders */}
|
||||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
<Show
|
|
||||||
when={folders().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="panel panel-empty-state flex-1">
|
|
||||||
<div class="panel-empty-state-icon">
|
|
||||||
<Clock class="w-12 h-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
|
||||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="panel flex flex-col flex-1 min-h-0">
|
<div class="panel flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header !gap-0 !p-0">
|
||||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||||
<p class="panel-subtitle">
|
<button
|
||||||
{t(
|
type="button"
|
||||||
folders().length === 1
|
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||||
? "folderSelection.recent.subtitle.one"
|
classList={{
|
||||||
: "folderSelection.recent.subtitle.other",
|
"text-primary": activeTab() === "local",
|
||||||
{ count: folders().length },
|
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||||
)}
|
}}
|
||||||
</p>
|
style={{
|
||||||
</div>
|
"background-color": "var(--surface-secondary)",
|
||||||
<div
|
}}
|
||||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
onClick={() => setActiveTab("local")}
|
||||||
ref={(el) => (recentListRef = el)}
|
>
|
||||||
>
|
|
||||||
<For each={folders()}>
|
|
||||||
{(folder, index) => (
|
|
||||||
<div
|
<div
|
||||||
class="panel-list-item"
|
class="panel-title text-base"
|
||||||
classList={{
|
style={{
|
||||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
"panel-list-item-disabled": isLoading(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 w-full px-1">
|
{t("folderSelection.recent.title")}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="panel-subtitle mt-1"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
folders().length === 1
|
||||||
|
? "folderSelection.recent.subtitle.one"
|
||||||
|
: "folderSelection.recent.subtitle.other",
|
||||||
|
{ count: folders().length },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-3 text-left transition-colors"
|
||||||
|
classList={{
|
||||||
|
"text-primary": activeTab() === "servers",
|
||||||
|
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"background-color": "var(--surface-secondary)",
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveTab("servers")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-title text-base"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.tabs.servers")}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="panel-subtitle mt-1"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={activeTab() === "local"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={remoteServers().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="panel-empty-state flex-1">
|
||||||
|
<div class="panel-empty-state-icon">
|
||||||
|
<Globe class="w-12 h-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||||
|
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||||
<button
|
<button
|
||||||
data-folder-index={index()}
|
type="button"
|
||||||
class="panel-list-item-content flex-1"
|
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||||
disabled={isLoading()}
|
onClick={openServerDialog}
|
||||||
onClick={() => handleFolderSelect(folder.path)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (isLoading()) return
|
|
||||||
setFocusMode("recent")
|
|
||||||
setSelectedIndex(index())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
<Globe class="w-4 h-4" />
|
||||||
<div class="flex-1 min-w-0">
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
|
||||||
<span class="text-sm font-medium truncate text-primary">
|
|
||||||
{splitFolderPath(folder.path).baseName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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)}
|
|
||||||
</span>
|
|
||||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
|
||||||
<kbd class="kbd">↵</kbd>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleRemove(folder.path, e)}
|
|
||||||
disabled={isLoading()}
|
|
||||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
|
||||||
title={t("folderSelection.recent.remove")}
|
|
||||||
>
|
|
||||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||||
|
ref={(el) => (recentListRef = el)}
|
||||||
|
>
|
||||||
|
<For each={remoteServers()}>
|
||||||
|
{(server, index) => (
|
||||||
|
<div
|
||||||
|
class="panel-list-item"
|
||||||
|
classList={{
|
||||||
|
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
|
<button
|
||||||
|
data-list-index={index()}
|
||||||
|
class="panel-list-item-content flex-1"
|
||||||
|
onClick={() => void handleConnectSavedServer(server.id)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setFocusMode("recent")
|
||||||
|
setSelectedIndex(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div class="flex-1 min-w-0 text-left">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
|
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
|
||||||
|
</div>
|
||||||
|
<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">{server.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeRemoteServerProfile(server.id)}
|
||||||
|
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||||
|
title={t("folderSelection.servers.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
}
|
||||||
</div>
|
>
|
||||||
|
<Show
|
||||||
|
when={folders().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="panel-empty-state flex-1">
|
||||||
|
<div class="panel-empty-state-icon">
|
||||||
|
<Clock class="w-12 h-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||||
|
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||||
|
ref={(el) => (recentListRef = el)}
|
||||||
|
>
|
||||||
|
<For each={folders()}>
|
||||||
|
{(folder, index) => (
|
||||||
|
<div
|
||||||
|
class="panel-list-item"
|
||||||
|
classList={{
|
||||||
|
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||||
|
"panel-list-item-disabled": isLoading(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
|
<button
|
||||||
|
data-list-index={index()}
|
||||||
|
class="panel-list-item-content flex-1"
|
||||||
|
disabled={isLoading()}
|
||||||
|
onClick={() => handleFolderSelect(folder.path)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isLoading()) return
|
||||||
|
setFocusMode("recent")
|
||||||
|
setSelectedIndex(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
|
<span class="text-sm font-medium truncate text-primary">
|
||||||
|
{splitFolderPath(folder.path).baseName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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)}
|
||||||
|
</span>
|
||||||
|
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||||
|
<kbd class="kbd">↵</kbd>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRemove(folder.path, e)}
|
||||||
|
disabled={isLoading()}
|
||||||
|
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||||
|
title={t("folderSelection.recent.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -567,11 +841,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||||
<div class="panel shrink-0">
|
<div class="panel shrink-0">
|
||||||
<div class="panel-header hidden sm:block">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleBrowse()}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
@@ -588,6 +862,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onOpenSidecar?.()}
|
||||||
|
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.sidecars.button")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={openServerDialog}
|
||||||
|
class="button-primary w-full flex items-center justify-center text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Globe class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
@@ -663,6 +958,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
onClose={() => setIsFolderBrowserOpen(false)}
|
onClose={() => setIsFolderBrowserOpen(false)}
|
||||||
onSelect={handleBrowserSelect}
|
onSelect={handleBrowserSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">
|
||||||
|
{t("folderSelection.servers.dialog.title")}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||||
|
{t("folderSelection.servers.dialog.description")}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||||
|
<span>{t("folderSelection.servers.dialog.name")}</span>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
value={serverName()}
|
||||||
|
onInput={(event) => setServerName(event.currentTarget.value)}
|
||||||
|
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||||
|
<span>{t("folderSelection.servers.dialog.url")}</span>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
value={serverUrl()}
|
||||||
|
onInput={(event) => setServerUrl(event.currentTarget.value)}
|
||||||
|
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 text-sm text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={skipTlsVerify()}
|
||||||
|
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Show when={serverDialogError()}>
|
||||||
|
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
|
||||||
|
{t("folderSelection.servers.dialog.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="selector-button selector-button-secondary w-auto px-4"
|
||||||
|
disabled={isSavingServer()}
|
||||||
|
onClick={() => void handleSaveServer(false)}
|
||||||
|
>
|
||||||
|
{t("folderSelection.servers.dialog.save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="selector-button selector-button-secondary w-auto px-4"
|
||||||
|
disabled={isSavingServer()}
|
||||||
|
onClick={() => void handleSaveServer(true)}
|
||||||
|
>
|
||||||
|
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
{t("folderSelection.servers.dialog.connecting")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
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, Bell, BellOff, Settings } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||||
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
import { openSettings } from "../stores/settings-screen"
|
||||||
|
import type { AppTabRecord } from "../stores/app-tabs"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
tabs: AppTabRecord[]
|
||||||
activeInstanceId: string | null
|
activeTabId: string | null
|
||||||
onSelect: (instanceId: string) => void
|
onSelect: (tabId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (tabId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<div class="tab-scroll">
|
<div class="tab-scroll">
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
<div class="tab-strip-tabs">
|
<div class="tab-strip-tabs">
|
||||||
<For each={Array.from(props.instances.entries())}>
|
<For each={props.tabs}>
|
||||||
{([id, instance]) => (
|
{(tab) =>
|
||||||
<InstanceTab
|
tab.kind === "instance" ? (
|
||||||
instance={instance}
|
<InstanceTab
|
||||||
active={id === props.activeInstanceId}
|
instance={tab.instance}
|
||||||
onSelect={() => props.onSelect(id)}
|
active={tab.id === props.activeTabId}
|
||||||
onClose={() => props.onClose(id)}
|
onSelect={() => props.onSelect(tab.id)}
|
||||||
/>
|
onClose={() => props.onClose(tab.id)}
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
|
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
|
||||||
|
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
|
||||||
|
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button"
|
class="new-tab-button"
|
||||||
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-strip-spacer" />
|
<div class="tab-strip-spacer" />
|
||||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
<Show when={props.tabs.length > 1}>
|
||||||
<div class="tab-shortcuts">
|
<div class="tab-shortcuts">
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel"
|
|||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||||
|
import type { PromptInputApi } from "../prompt-input/types"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +106,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
const [now, setNow] = createSignal(Date.now())
|
const [now, setNow] = createSignal(Date.now())
|
||||||
|
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
|
||||||
|
|
||||||
// Worktree selector manages its own dialogs.
|
// Worktree selector manages its own dialogs.
|
||||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||||
@@ -268,6 +270,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||||
|
|
||||||
|
const activePromptInputApi = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info") return null
|
||||||
|
return sessionPromptApis()[sessionId] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => {
|
||||||
|
setSessionPromptApis((current) => ({
|
||||||
|
...current,
|
||||||
|
[sessionId]: api,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
getPermissionAutoAcceptInFlightVersion()
|
getPermissionAutoAcceptInFlightVersion()
|
||||||
|
|
||||||
@@ -594,6 +609,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onCloseRightDrawer={closeRightDrawer}
|
onCloseRightDrawer={closeRightDrawer}
|
||||||
onPinRightDrawer={pinRightDrawer}
|
onPinRightDrawer={pinRightDrawer}
|
||||||
onUnpinRightDrawer={unpinRightDrawer}
|
onUnpinRightDrawer={unpinRightDrawer}
|
||||||
|
promptInputApi={activePromptInputApi}
|
||||||
setContentEl={setRightDrawerContentEl}
|
setContentEl={setRightDrawerContentEl}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -656,6 +672,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onCloseRightDrawer={closeRightDrawer}
|
onCloseRightDrawer={closeRightDrawer}
|
||||||
onPinRightDrawer={pinRightDrawer}
|
onPinRightDrawer={pinRightDrawer}
|
||||||
onUnpinRightDrawer={unpinRightDrawer}
|
onUnpinRightDrawer={unpinRightDrawer}
|
||||||
|
promptInputApi={activePromptInputApi}
|
||||||
setContentEl={setRightDrawerContentEl}
|
setContentEl={setRightDrawerContentEl}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -892,6 +909,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
isPhoneLayout={isPhoneLayout()}
|
isPhoneLayout={isPhoneLayout()}
|
||||||
compactPromptLayout={compactPromptLayout()}
|
compactPromptLayout={compactPromptLayout()}
|
||||||
|
registerSessionPromptApi={registerSessionPromptApi}
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
import IconButton from "@suid/material/IconButton"
|
import IconButton from "@suid/material/IconButton"
|
||||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
@@ -19,16 +19,23 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
|||||||
import type { Instance } from "../../../../types/instance"
|
import type { Instance } from "../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
import type { Session } from "../../../../types/session"
|
import type { Session } from "../../../../types/session"
|
||||||
|
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||||
import type { DrawerViewState } from "../types"
|
import type { DrawerViewState } from "../types"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||||
|
|
||||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
import {
|
||||||
|
getDefaultWorktreeSlug,
|
||||||
|
getGitRepoStatus,
|
||||||
|
getOrCreateWorktreeClient,
|
||||||
|
getWorktreeSlugForSession,
|
||||||
|
getWorktrees,
|
||||||
|
} from "../../../../stores/worktrees"
|
||||||
import { requestData } from "../../../../lib/opencode-api"
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
import { serverApi } from "../../../../lib/api-client"
|
import { serverApi } from "../../../../lib/api-client"
|
||||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||||
import { showToastNotification } from "../../../../lib/notifications"
|
import { showToastNotification } from "../../../../lib/notifications"
|
||||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
|
||||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
|
import { useGitChanges } from "./useGitChanges"
|
||||||
import {
|
import {
|
||||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||||
@@ -41,7 +48,11 @@ import {
|
|||||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_TAB_STORAGE_KEY,
|
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||||
readStoredBool,
|
readStoredBool,
|
||||||
readStoredEnum,
|
readStoredEnum,
|
||||||
@@ -82,6 +93,7 @@ interface RightPanelProps {
|
|||||||
onCloseRightDrawer: () => void
|
onCloseRightDrawer: () => void
|
||||||
onPinRightDrawer: () => void
|
onPinRightDrawer: () => void
|
||||||
onUnpinRightDrawer: () => void
|
onUnpinRightDrawer: () => void
|
||||||
|
promptInputApi: Accessor<PromptInputApi | null>
|
||||||
|
|
||||||
setContentEl: (el: HTMLElement | null) => void
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
}
|
}
|
||||||
@@ -133,6 +145,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||||
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||||
|
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
|
||||||
|
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
|
||||||
|
|
||||||
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
||||||
|
|
||||||
@@ -149,11 +163,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
|
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
|
||||||
|
const layout = listLayoutKey()
|
||||||
|
if (section === "staged") {
|
||||||
|
return layout === "phone"
|
||||||
|
? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY
|
||||||
|
: RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
return layout === "phone"
|
||||||
|
? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY
|
||||||
|
: RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||||
const layout = listLayoutKey()
|
const layout = listLayoutKey()
|
||||||
@@ -185,6 +216,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setGitChangesListOpen(true)
|
setGitChangesListOpen(true)
|
||||||
setGitChangesListTouched(false)
|
setGitChangesListTouched(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
|
||||||
|
setGitStagedOpen(stagedPersisted ?? true)
|
||||||
|
|
||||||
|
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
|
||||||
|
setGitUnstagedOpen(unstagedPersisted ?? true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -339,34 +376,56 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
return getDefaultWorktreeSlug(props.instanceId)
|
return getDefaultWorktreeSlug(props.instanceId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const gitChangesWorktreeSlug = createMemo(() => {
|
||||||
|
if (getGitRepoStatus(props.instanceId) === false) return null
|
||||||
|
const slug = worktreeSlugForViewer().trim()
|
||||||
|
return slug ? slug : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const gitChangesWorktree = createMemo(() => {
|
||||||
|
const slug = gitChangesWorktreeSlug()
|
||||||
|
if (!slug) return null
|
||||||
|
return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const gitChangesBranchLabel = createMemo(() => {
|
||||||
|
const branch = gitChangesWorktree()?.branch?.trim()
|
||||||
|
return branch || null
|
||||||
|
})
|
||||||
|
|
||||||
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||||
|
|
||||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
const {
|
||||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
gitStatusEntries,
|
||||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
gitStatusLoading,
|
||||||
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
gitStatusError,
|
||||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
gitSelectedItemId,
|
||||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
gitBulkSelectedItemIds,
|
||||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
gitSelectedLoading,
|
||||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
gitSelectedError,
|
||||||
|
gitSelectedBefore,
|
||||||
const gitMostChangedPath = createMemo<string | null>(() => {
|
gitSelectedAfter,
|
||||||
const entries = gitStatusEntries()
|
gitCommitMessage,
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return null
|
gitCommitSubmitting,
|
||||||
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
gitMostChangedItemId,
|
||||||
if (candidates.length === 0) return null
|
setGitCommitMessage,
|
||||||
const best = candidates.reduce((currentBest, item) => {
|
handleGitRowClick,
|
||||||
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
refreshGitStatus,
|
||||||
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
insertGitChangeContext,
|
||||||
if (score > bestScore) return item
|
submitGitCommit,
|
||||||
if (score < bestScore) return currentBest
|
stageGitFile,
|
||||||
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
unstageGitFile,
|
||||||
}, candidates[0])
|
} = useGitChanges({
|
||||||
return typeof best?.path === "string" ? best.path : null
|
t: props.t,
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
rightPanelTab,
|
||||||
|
worktreeSlug: worktreeSlugForViewer,
|
||||||
|
isPhoneLayout: props.isPhoneLayout,
|
||||||
|
promptInputApi: props.promptInputApi,
|
||||||
|
closeGitList: () => setGitChangesListOpen(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// Reset tab state when worktree context changes.
|
|
||||||
worktreeSlugForViewer()
|
worktreeSlugForViewer()
|
||||||
setBrowserPath(".")
|
setBrowserPath(".")
|
||||||
setBrowserEntries(null)
|
setBrowserEntries(null)
|
||||||
@@ -375,111 +434,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
setBrowserSelectedLoading(false)
|
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 bestDiffFile = createMemo<string | null>(() => {
|
||||||
const diffs = props.activeSessionDiffs()
|
const diffs = props.activeSessionDiffs()
|
||||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||||
@@ -680,21 +636,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedDirty(false)
|
setBrowserSelectedDirty(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (rightPanelTab() !== "git-changes") return
|
|
||||||
if (gitStatusLoading()) return
|
|
||||||
if (gitStatusEntries() !== null) return
|
|
||||||
void loadGitStatus()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (rightPanelTab() === "git-changes") return
|
|
||||||
setGitSelectedBefore(null)
|
|
||||||
setGitSelectedAfter(null)
|
|
||||||
setGitSelectedLoading(false)
|
|
||||||
setGitSelectedError(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
setSelectedFile(file)
|
setSelectedFile(file)
|
||||||
if (closeList) {
|
if (closeList) {
|
||||||
@@ -911,12 +852,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
entries={gitStatusEntries}
|
entries={gitStatusEntries}
|
||||||
statusLoading={gitStatusLoading}
|
statusLoading={gitStatusLoading}
|
||||||
statusError={gitStatusError}
|
statusError={gitStatusError}
|
||||||
selectedPath={gitSelectedPath}
|
selectedItemId={gitSelectedItemId}
|
||||||
|
selectedBulkItemIds={gitBulkSelectedItemIds}
|
||||||
selectedLoading={gitSelectedLoading}
|
selectedLoading={gitSelectedLoading}
|
||||||
selectedError={gitSelectedError}
|
selectedError={gitSelectedError}
|
||||||
selectedBefore={gitSelectedBefore}
|
selectedBefore={gitSelectedBefore}
|
||||||
selectedAfter={gitSelectedAfter}
|
selectedAfter={gitSelectedAfter}
|
||||||
mostChangedPath={gitMostChangedPath}
|
mostChangedItemId={gitMostChangedItemId}
|
||||||
scopeKey={gitScopeKey}
|
scopeKey={gitScopeKey}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
@@ -924,8 +866,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onOpenFile={(path: string) => void openGitFile(path)}
|
onRowClick={handleGitRowClick}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
onRefresh={() => void refreshGitStatus()}
|
||||||
|
onInsertContext={insertGitChangeContext}
|
||||||
|
onStageFile={stageGitFile}
|
||||||
|
onUnstageFile={unstageGitFile}
|
||||||
|
commitMessage={gitCommitMessage}
|
||||||
|
commitSubmitting={gitCommitSubmitting}
|
||||||
|
onCommitMessageInput={setGitCommitMessage}
|
||||||
|
onSubmitCommit={() => void submitGitCommit()}
|
||||||
|
branchLabel={gitChangesBranchLabel}
|
||||||
|
stagedOpen={gitStagedOpen}
|
||||||
|
unstagedOpen={gitUnstagedOpen}
|
||||||
|
onToggleStagedOpen={() => {
|
||||||
|
const next = !gitStagedOpen()
|
||||||
|
setGitStagedOpen(next)
|
||||||
|
persistGitSectionOpen("staged", next)
|
||||||
|
}}
|
||||||
|
onToggleUnstagedOpen={() => {
|
||||||
|
const next = !gitUnstagedOpen()
|
||||||
|
setGitUnstagedOpen(next)
|
||||||
|
persistGitSectionOpen("unstaged", next)
|
||||||
|
}}
|
||||||
listOpen={gitChangesListOpen}
|
listOpen={gitChangesListOpen}
|
||||||
onToggleList={toggleGitList}
|
onToggleList={toggleGitList}
|
||||||
splitWidth={gitChangesSplitWidth}
|
splitWidth={gitChangesSplitWidth}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types"
|
||||||
|
|
||||||
|
import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types"
|
||||||
|
|
||||||
|
function normalizeGitChangePath(path: unknown): string {
|
||||||
|
if (typeof path !== "string") return ""
|
||||||
|
const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim()
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGitChangeStatus(status: unknown): GitChangeStatus {
|
||||||
|
return typeof status === "string" && status.trim().length > 0 ? status : "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry {
|
||||||
|
return {
|
||||||
|
path: normalizeGitChangePath(entry?.path),
|
||||||
|
originalPath: null,
|
||||||
|
additions: typeof entry?.added === "number" ? entry.added : 0,
|
||||||
|
deletions: typeof entry?.removed === "number" ? entry.removed : 0,
|
||||||
|
status: normalizeGitChangeStatus(entry?.status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptSdkGitStatusEntries(
|
||||||
|
entries: SdkGitFileStatus[] | null | undefined,
|
||||||
|
details?: WorktreeGitStatusEntry[] | null,
|
||||||
|
): GitChangeEntry[] {
|
||||||
|
const detailsByPath = new Map(
|
||||||
|
(details ?? [])
|
||||||
|
.map((entry) => {
|
||||||
|
const path = normalizeGitChangePath(entry.path)
|
||||||
|
return path ? [{ ...entry, path }, path] : null
|
||||||
|
})
|
||||||
|
.filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry))
|
||||||
|
.map(([entry, path]) => [path, entry] as const),
|
||||||
|
)
|
||||||
|
const adaptedByPath = new Map<string, GitChangeEntry>()
|
||||||
|
|
||||||
|
for (const entry of entries ?? []) {
|
||||||
|
const adapted = adaptSdkGitStatusEntry(entry)
|
||||||
|
if (!adapted.path) continue
|
||||||
|
const detail = detailsByPath.get(adapted.path)
|
||||||
|
adaptedByPath.set(adapted.path, {
|
||||||
|
...adapted,
|
||||||
|
originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null,
|
||||||
|
stagedStatus: detail?.stagedStatus ?? null,
|
||||||
|
unstagedStatus: detail?.unstagedStatus ?? null,
|
||||||
|
stagedAdditions: detail?.stagedAdditions ?? 0,
|
||||||
|
stagedDeletions: detail?.stagedDeletions ?? 0,
|
||||||
|
unstagedAdditions: detail?.unstagedAdditions ?? 0,
|
||||||
|
unstagedDeletions: detail?.unstagedDeletions ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const detail of details ?? []) {
|
||||||
|
const normalizedPath = normalizeGitChangePath(detail.path)
|
||||||
|
if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue
|
||||||
|
adaptedByPath.set(normalizedPath, {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null,
|
||||||
|
additions: 0,
|
||||||
|
deletions: 0,
|
||||||
|
status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified",
|
||||||
|
stagedStatus: detail.stagedStatus,
|
||||||
|
unstagedStatus: detail.unstagedStatus,
|
||||||
|
stagedAdditions: detail.stagedAdditions,
|
||||||
|
stagedDeletions: detail.stagedDeletions,
|
||||||
|
unstagedAdditions: detail.unstagedAdditions,
|
||||||
|
unstagedDeletions: detail.unstagedDeletions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGitChangeListItemId(section: GitChangeSection, path: string): string {
|
||||||
|
return `${section}:${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitGitChangePath(path: string) {
|
||||||
|
const normalized = normalizeGitChangePath(path)
|
||||||
|
const lastSlash = normalized.lastIndexOf("/")
|
||||||
|
if (lastSlash === -1) {
|
||||||
|
return { displayName: normalized, parentPath: "" }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
displayName: normalized.slice(lastSlash + 1),
|
||||||
|
parentPath: normalized.slice(0, lastSlash),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] {
|
||||||
|
if (!Array.isArray(entries)) return []
|
||||||
|
|
||||||
|
const items: GitChangeListItem[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const pathParts = splitGitChangePath(entry.path)
|
||||||
|
if (entry.stagedStatus) {
|
||||||
|
items.push({
|
||||||
|
id: buildGitChangeListItemId("staged", entry.path),
|
||||||
|
path: entry.path,
|
||||||
|
originalPath: entry.originalPath ?? null,
|
||||||
|
section: "staged",
|
||||||
|
status: entry.stagedStatus,
|
||||||
|
additions: entry.stagedAdditions ?? 0,
|
||||||
|
deletions: entry.stagedDeletions ?? 0,
|
||||||
|
entry,
|
||||||
|
displayName: pathParts.displayName,
|
||||||
|
parentPath: pathParts.parentPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (entry.unstagedStatus) {
|
||||||
|
items.push({
|
||||||
|
id: buildGitChangeListItemId("unstaged", entry.path),
|
||||||
|
path: entry.path,
|
||||||
|
originalPath: entry.originalPath ?? null,
|
||||||
|
section: "unstaged",
|
||||||
|
status: entry.unstagedStatus,
|
||||||
|
additions: entry.unstagedAdditions ?? entry.additions,
|
||||||
|
deletions: entry.unstagedDeletions ?? entry.deletions,
|
||||||
|
entry,
|
||||||
|
displayName: pathParts.displayName,
|
||||||
|
parentPath: pathParts.parentPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!entry.stagedStatus && !entry.unstagedStatus) {
|
||||||
|
items.push({
|
||||||
|
id: buildGitChangeListItemId("unstaged", entry.path),
|
||||||
|
path: entry.path,
|
||||||
|
originalPath: entry.originalPath ?? null,
|
||||||
|
section: "unstaged",
|
||||||
|
status: entry.status,
|
||||||
|
additions: entry.additions,
|
||||||
|
deletions: entry.deletions,
|
||||||
|
entry,
|
||||||
|
displayName: pathParts.displayName,
|
||||||
|
parentPath: pathParts.parentPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
if (a.section !== b.section) return a.section.localeCompare(b.section)
|
||||||
|
return a.path.localeCompare(b.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -115,23 +115,22 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyMonacoDiffViewer
|
<LazyMonacoDiffViewer
|
||||||
scopeKey={scopeKey()}
|
scopeKey={scopeKey()}
|
||||||
path={String(file().file || "")}
|
path={String(file().file || "")}
|
||||||
before={String((file() as any).before || "")}
|
patch={String((file() as any).patch || "")}
|
||||||
after={String((file() as any).after || "")}
|
viewMode={props.diffViewMode()}
|
||||||
viewMode={props.diffViewMode()}
|
contextMode={props.diffContextMode()}
|
||||||
contextMode={props.diffContextMode()}
|
wordWrap={props.diffWordWrapMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
/>
|
||||||
/>
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import {
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
For,
|
||||||
|
Show,
|
||||||
|
Suspense,
|
||||||
|
createMemo,
|
||||||
|
lazy,
|
||||||
|
type Accessor,
|
||||||
|
type Component,
|
||||||
|
type JSX,
|
||||||
|
} from "solid-js"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types"
|
||||||
|
import { buildGitChangeListItems } from "../git-changes-model"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
@@ -16,16 +25,17 @@ interface GitChangesTabProps {
|
|||||||
|
|
||||||
activeSessionId: Accessor<string | null>
|
activeSessionId: Accessor<string | null>
|
||||||
|
|
||||||
entries: Accessor<GitFileStatus[] | null>
|
entries: Accessor<GitChangeEntry[] | null>
|
||||||
statusLoading: Accessor<boolean>
|
statusLoading: Accessor<boolean>
|
||||||
statusError: Accessor<string | null>
|
statusError: Accessor<string | null>
|
||||||
|
|
||||||
selectedPath: Accessor<string | null>
|
selectedItemId: Accessor<string | null>
|
||||||
|
selectedBulkItemIds: Accessor<Set<string>>
|
||||||
selectedLoading: Accessor<boolean>
|
selectedLoading: Accessor<boolean>
|
||||||
selectedError: Accessor<string | null>
|
selectedError: Accessor<string | null>
|
||||||
selectedBefore: Accessor<string | null>
|
selectedBefore: Accessor<string | null>
|
||||||
selectedAfter: Accessor<string | null>
|
selectedAfter: Accessor<string | null>
|
||||||
mostChangedPath: Accessor<string | null>
|
mostChangedItemId: Accessor<string | null>
|
||||||
|
|
||||||
scopeKey: Accessor<string>
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
@@ -36,8 +46,21 @@ interface GitChangesTabProps {
|
|||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
onOpenFile: (path: string) => void
|
onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void
|
||||||
|
onStageFile: (item: GitChangeListItem) => void
|
||||||
|
onUnstageFile: (item: GitChangeListItem) => void
|
||||||
|
commitMessage: Accessor<string>
|
||||||
|
commitSubmitting: Accessor<boolean>
|
||||||
|
onCommitMessageInput: (value: string) => void
|
||||||
|
onSubmitCommit: () => void
|
||||||
|
branchLabel: Accessor<string | null>
|
||||||
|
|
||||||
|
stagedOpen: Accessor<boolean>
|
||||||
|
unstagedOpen: Accessor<boolean>
|
||||||
|
onToggleStagedOpen: () => void
|
||||||
|
onToggleUnstagedOpen: () => void
|
||||||
|
|
||||||
listOpen: Accessor<boolean>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||||
|
|
||||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
const sorted = createMemo<GitChangeEntry[]>(() => {
|
||||||
const list = entries()
|
const list = entries()
|
||||||
if (!Array.isArray(list)) return []
|
if (!Array.isArray(list)) return []
|
||||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const totals = createMemo(() => {
|
||||||
return sorted().reduce(
|
return listItems().reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ additions: 0, deletions: 0 },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged"))
|
||||||
|
const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged"))
|
||||||
|
const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting())
|
||||||
|
|
||||||
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
|
const selectedEntry = createMemo<GitChangeEntry | null>(() => {
|
||||||
|
const list = listItems()
|
||||||
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
const selectedId = props.selectedItemId()
|
||||||
const list = sorted()
|
const fallbackId = props.mostChangedItemId()
|
||||||
const selectedPath = props.selectedPath()
|
|
||||||
const fallbackPath = props.mostChangedPath()
|
|
||||||
const found =
|
const found =
|
||||||
list.find((item) => item.path === selectedPath) ||
|
list.find((item) => item.id === selectedId) ||
|
||||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
|
||||||
return found ?? null
|
return found?.entry ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
const emptyViewerMessage = createMemo(() => {
|
||||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||||
const currentEntries = entries()
|
const currentEntries = entries()
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
const totalsValue = totals()
|
const totalsValue = totals()
|
||||||
const selected = selectedEntry()
|
const selected = selectedEntry()
|
||||||
const sortedList = sorted()
|
const allItems = listItems()
|
||||||
const nonDeletedList = nonDeleted()
|
const stagedList = stagedItems()
|
||||||
|
const unstagedList = unstagedItems()
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
selected &&
|
selected &&
|
||||||
props.selectedBefore() !== null &&
|
props.selectedBefore() !== null &&
|
||||||
props.selectedAfter() !== null &&
|
props.selectedAfter() !== null &&
|
||||||
selected.status !== "deleted"
|
true
|
||||||
? {
|
? {
|
||||||
path: selected.path,
|
path: selected.path,
|
||||||
before: props.selectedBefore() as string,
|
before: props.selectedBefore() as string,
|
||||||
@@ -139,6 +168,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
wordWrap={props.diffWordWrapMode()}
|
||||||
|
insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")}
|
||||||
|
onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => {
|
||||||
|
const selectedId = props.selectedItemId()
|
||||||
|
if (!selectedId) return
|
||||||
|
const item = listItems().find((entry) => entry.id === selectedId)
|
||||||
|
if (!item) return
|
||||||
|
props.onInsertContext(item, selection)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListItem = (item: GitChangeListItem) => {
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
|
||||||
<For each={sortedList}>
|
const actionLabel =
|
||||||
{(item) => (
|
item.section === "staged"
|
||||||
<div
|
? props.t("instanceShell.gitChanges.actions.unstage")
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
: props.t("instanceShell.gitChanges.actions.stage")
|
||||||
onClick={() => {
|
|
||||||
props.onOpenFile(item.path)
|
const triggerAction = () => {
|
||||||
}}
|
if (item.section === "staged") props.onUnstageFile(item)
|
||||||
>
|
else props.onStageFile(item)
|
||||||
<div class="file-list-item-content">
|
}
|
||||||
<div class="file-list-item-path" title={item.path}>
|
|
||||||
<span class="file-path-text">{item.path}</span>
|
return (
|
||||||
</div>
|
<div
|
||||||
<div class="file-list-item-stats">
|
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
|
||||||
<Show when={item.status === "deleted"}>
|
onMouseDown={(event) => {
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||||
</Show>
|
event.preventDefault()
|
||||||
<Show when={item.status !== "deleted"}>
|
}
|
||||||
<>
|
}}
|
||||||
<span class="file-list-item-additions">+{item.added}</span>
|
onClick={(event) => props.onRowClick(item, event)}
|
||||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
title={item.path}
|
||||||
</>
|
>
|
||||||
</Show>
|
<div class="file-list-item-content" title={item.path}>
|
||||||
</div>
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.path}</span>
|
||||||
|
</div>
|
||||||
|
<div class="git-change-list-item-right">
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</For>
|
<div class="git-change-list-item-actions-zone">
|
||||||
</Show>
|
<div class="git-change-list-item-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="git-change-row-action"
|
||||||
|
title={actionLabel}
|
||||||
|
aria-label={actionLabel}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
triggerAction()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={`git-change-row-action-glyph ${item.section === "staged" ? "git-change-row-action-glyph-minus" : "git-change-row-action-glyph-plus"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span class="git-change-row-action-bar git-change-row-action-bar-horizontal" />
|
||||||
|
<Show when={item.section !== "staged"}>
|
||||||
|
<span class="git-change-row-action-bar git-change-row-action-bar-vertical" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSection = (
|
||||||
|
title: string,
|
||||||
|
items: GitChangeListItem[],
|
||||||
|
isOpen: boolean,
|
||||||
|
onToggle: () => void,
|
||||||
|
) => (
|
||||||
|
<div class="git-change-section">
|
||||||
|
<button type="button" class="git-change-section-header" onClick={onToggle}>
|
||||||
|
<span class="git-change-section-header-main">
|
||||||
|
<span class="git-change-section-chevron">
|
||||||
|
{isOpen ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span class="git-change-section-title">{title}</span>
|
||||||
|
</span>
|
||||||
|
<span class="git-change-section-count">{items.length}</span>
|
||||||
|
</button>
|
||||||
|
<Show when={isOpen}>
|
||||||
|
<div class="git-change-section-items">
|
||||||
|
<For each={items}>{(item) => renderListItem(item)}</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderListOverlay = () => (
|
const renderGroupedList = () => (
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<div class="git-change-sections">
|
||||||
{(item) => (
|
<div class="git-change-section">
|
||||||
<div
|
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
<span class="git-change-section-header-main">
|
||||||
onClick={() => props.onOpenFile(item.path)}
|
<span class="git-change-section-chevron">
|
||||||
title={item.path}
|
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||||
>
|
</span>
|
||||||
<div class="file-list-item-content">
|
<span class="git-change-section-title-row">
|
||||||
<div class="file-list-item-path" title={item.path}>
|
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
|
||||||
<span class="file-path-text">{item.path}</span>
|
<Show when={props.branchLabel()}>
|
||||||
</div>
|
{(label) => (
|
||||||
<div class="file-list-item-stats">
|
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
|
||||||
<Show when={item.status === "deleted"}>
|
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="worktree-indicator-label">{label()}</span>
|
||||||
</Show>
|
</span>
|
||||||
<Show when={item.status !== "deleted"}>
|
)}
|
||||||
<>
|
|
||||||
<span class="file-list-item-additions">+{item.added}</span>
|
|
||||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="git-change-section-count">{stagedList.length}</span>
|
||||||
|
</button>
|
||||||
|
<Show when={props.stagedOpen()}>
|
||||||
|
<div class="git-change-section-items">
|
||||||
|
<div class="git-change-commit-box">
|
||||||
|
<div class="git-change-commit-input-wrap">
|
||||||
|
<textarea
|
||||||
|
class="git-change-commit-input"
|
||||||
|
value={props.commitMessage()}
|
||||||
|
rows={1}
|
||||||
|
placeholder={props.t("instanceShell.gitChanges.commit.placeholder")}
|
||||||
|
onInput={(event) => props.onCommitMessageInput(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="git-change-commit-button git-change-commit-button-overlay"
|
||||||
|
disabled={!canCommit()}
|
||||||
|
onClick={() => props.onSubmitCommit()}
|
||||||
|
>
|
||||||
|
{props.commitSubmitting()
|
||||||
|
? props.t("instanceShell.gitChanges.commit.submitting")
|
||||||
|
: props.t("instanceShell.gitChanges.commit.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<For each={stagedList}>{(item) => renderListItem(item)}</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
|
</div>
|
||||||
|
{renderSection(
|
||||||
|
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||||
|
unstagedList,
|
||||||
|
props.unstagedOpen(),
|
||||||
|
props.onToggleUnstagedOpen,
|
||||||
)}
|
)}
|
||||||
</For>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -264,9 +384,10 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onContextModeChange={props.onContextModeChange}
|
onContextModeChange={props.onContextModeChange}
|
||||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||||
viewer={renderViewer()}
|
viewer={renderViewer()}
|
||||||
listOpen={props.listOpen()}
|
listOpen={props.listOpen()}
|
||||||
onToggleList={props.onToggleList}
|
onToggleList={props.onToggleList}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
|
|||||||
import { Tooltip } from "@kobalte/core/tooltip"
|
import { Tooltip } from "@kobalte/core/tooltip"
|
||||||
import Switch from "@suid/material/Switch"
|
import Switch from "@suid/material/Switch"
|
||||||
|
|
||||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
|
|
||||||
import type { Instance } from "../../../../../types/instance"
|
import type { Instance } from "../../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||||
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
<div class="status-process-header">
|
<div class="status-process-header">
|
||||||
<span class="status-process-title">{process.title}</span>
|
<span class="status-process-title">{process.title}</span>
|
||||||
<div class="status-process-meta">
|
<div class="status-process-meta">
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"text-success": Boolean(process.notifyEnabled),
|
||||||
|
"text-tertiary": !process.notifyEnabled,
|
||||||
|
}}
|
||||||
|
aria-label={props.t(
|
||||||
|
process.notifyEnabled
|
||||||
|
? "instanceShell.backgroundProcesses.notify.enabled"
|
||||||
|
: "instanceShell.backgroundProcesses.notify.disabled",
|
||||||
|
)}
|
||||||
|
title={props.t(
|
||||||
|
process.notifyEnabled
|
||||||
|
? "instanceShell.backgroundProcesses.notify.enabled"
|
||||||
|
: "instanceShell.backgroundProcesses.notify.disabled",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BellRing class="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -5,3 +5,40 @@ export type DiffViewMode = "split" | "unified"
|
|||||||
export type DiffContextMode = "expanded" | "collapsed"
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
|
|
||||||
export type DiffWordWrapMode = "on" | "off"
|
export type DiffWordWrapMode = "on" | "off"
|
||||||
|
|
||||||
|
export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | string
|
||||||
|
|
||||||
|
export interface GitChangeEntry {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
status: GitChangeStatus
|
||||||
|
stagedStatus?: GitChangeStatus | null
|
||||||
|
unstagedStatus?: GitChangeStatus | null
|
||||||
|
stagedAdditions?: number
|
||||||
|
stagedDeletions?: number
|
||||||
|
unstagedAdditions?: number
|
||||||
|
unstagedDeletions?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitChangeSection = "staged" | "unstaged"
|
||||||
|
|
||||||
|
export interface GitChangeListItem {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
section: GitChangeSection
|
||||||
|
status: GitChangeStatus
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
entry: GitChangeEntry
|
||||||
|
displayName: string
|
||||||
|
parentPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitSelectionDescriptor {
|
||||||
|
itemId: string | null
|
||||||
|
path: string | null
|
||||||
|
section: GitChangeSection | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||||
|
import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
|
||||||
|
|
||||||
|
import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
|
||||||
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
|
import { serverApi } from "../../../../lib/api-client"
|
||||||
|
import { serverEvents } from "../../../../lib/server-events"
|
||||||
|
import { showToastNotification } from "../../../../lib/notifications"
|
||||||
|
import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
|
||||||
|
|
||||||
|
type UseGitChangesOptions = {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
instanceId: string
|
||||||
|
rightPanelTab: Accessor<RightPanelTab>
|
||||||
|
worktreeSlug: Accessor<string>
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
promptInputApi: Accessor<PromptInputApi | null>
|
||||||
|
closeGitList: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitChanges(options: UseGitChangesOptions) {
|
||||||
|
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitChangeEntry[] | null>(null)
|
||||||
|
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||||
|
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedItemId, setGitSelectedItemId] = createSignal<string | null>(null)
|
||||||
|
const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal<Set<string>>(new Set())
|
||||||
|
const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = 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 [gitCommitMessage, setGitCommitMessage] = createSignal("")
|
||||||
|
const [gitCommitSubmitting, setGitCommitSubmitting] = createSignal(false)
|
||||||
|
let gitStatusRequestVersion = 0
|
||||||
|
let gitDiffRequestVersion = 0
|
||||||
|
let passiveGitRefreshInFlight = false
|
||||||
|
let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null
|
||||||
|
let previousGitChangesActivationKey: string | null = null
|
||||||
|
|
||||||
|
const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
|
||||||
|
|
||||||
|
const clearGitBulkSelection = () => {
|
||||||
|
setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set<string>()))
|
||||||
|
setGitBulkSelectionAnchorId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGitBulkSelection = (itemId: string) => {
|
||||||
|
setGitBulkSelectedItemIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(itemId)) next.delete(itemId)
|
||||||
|
else next.add(itemId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addGitBulkRange = (anchorId: string, itemId: string) => {
|
||||||
|
const items = gitListItems()
|
||||||
|
const anchorIndex = items.findIndex((entry) => entry.id === anchorId)
|
||||||
|
const itemIndex = items.findIndex((entry) => entry.id === itemId)
|
||||||
|
if (anchorIndex < 0 || itemIndex < 0) {
|
||||||
|
setGitBulkSelectedItemIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
next.add(itemId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.min(anchorIndex, itemIndex)
|
||||||
|
const end = Math.max(anchorIndex, itemIndex)
|
||||||
|
const rangeIds = items.slice(start, end + 1).map((entry) => entry.id)
|
||||||
|
setGitBulkSelectedItemIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
for (const rangeId of rangeIds) {
|
||||||
|
next.add(rangeId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeGitSelection = (itemId: string | null): GitSelectionDescriptor => {
|
||||||
|
if (!itemId) {
|
||||||
|
return { itemId: null, path: null, section: null }
|
||||||
|
}
|
||||||
|
const match = gitListItems().find((item) => item.id === itemId) ?? null
|
||||||
|
return {
|
||||||
|
itemId,
|
||||||
|
path: match?.path ?? null,
|
||||||
|
section: match?.section ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitMostChangedItemId = createMemo<string | null>(() => {
|
||||||
|
const items = gitListItems()
|
||||||
|
if (items.length === 0) return null
|
||||||
|
const candidates = items.filter((item) => item.status !== "deleted")
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
const best = candidates.reduce((currentBest, item) => {
|
||||||
|
const bestScore = (currentBest?.additions ?? 0) + (currentBest?.deletions ?? 0)
|
||||||
|
const score = (item.additions ?? 0) + (item.deletions ?? 0)
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return currentBest
|
||||||
|
return String(item.id || "").localeCompare(String(currentBest?.id || "")) < 0 ? item : currentBest
|
||||||
|
}, candidates[0])
|
||||||
|
return typeof best?.id === "string" ? best.id : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveValidGitSelection = (selection: GitSelectionDescriptor): string | null => {
|
||||||
|
const items = gitListItems()
|
||||||
|
if (items.length === 0) return null
|
||||||
|
if (selection.itemId && items.some((item) => item.id === selection.itemId)) return selection.itemId
|
||||||
|
if (selection.path && selection.section) {
|
||||||
|
const oppositeSection = selection.section === "staged" ? "unstaged" : "staged"
|
||||||
|
const moved = items.find((item) => item.path === selection.path && item.section === oppositeSection)
|
||||||
|
if (moved) return moved.id
|
||||||
|
const samePath = items.find((item) => item.path === selection.path)
|
||||||
|
if (samePath) return samePath.id
|
||||||
|
}
|
||||||
|
return gitMostChangedItemId()
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeGitSelectionFingerprint = (itemId: string | null) => {
|
||||||
|
if (!itemId) return null
|
||||||
|
const item = gitListItems().find((entry) => entry.id === itemId) ?? null
|
||||||
|
if (!item) return null
|
||||||
|
return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelectedGitDiff = () => {
|
||||||
|
setGitSelectedError(null)
|
||||||
|
setGitSelectedBefore(null)
|
||||||
|
setGitSelectedAfter(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelectedGitDiffAndSelection = () => {
|
||||||
|
setGitSelectedItemId(null)
|
||||||
|
clearGitBulkSelection()
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneGitBulkSelection = () => {
|
||||||
|
const validIds = new Set(gitListItems().map((item) => item.id))
|
||||||
|
setGitBulkSelectedItemIds((current) => {
|
||||||
|
if (current.size === 0) return current
|
||||||
|
const next = new Set<string>()
|
||||||
|
for (const itemId of current) {
|
||||||
|
if (validIds.has(itemId)) next.add(itemId)
|
||||||
|
}
|
||||||
|
return next.size === current.size ? current : next
|
||||||
|
})
|
||||||
|
|
||||||
|
const anchorId = gitBulkSelectionAnchorId()
|
||||||
|
if (anchorId && !validIds.has(anchorId)) {
|
||||||
|
setGitBulkSelectionAnchorId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
gitListItems()
|
||||||
|
pruneGitBulkSelection()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadGitStatus = async (force = false) => {
|
||||||
|
if (!force && gitStatusEntries() !== null) return
|
||||||
|
const slug = options.worktreeSlug()
|
||||||
|
const client = getOrCreateWorktreeClient(options.instanceId, slug)
|
||||||
|
const requestVersion = ++gitStatusRequestVersion
|
||||||
|
setGitStatusLoading(true)
|
||||||
|
setGitStatusError(null)
|
||||||
|
try {
|
||||||
|
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status(), "file.status")
|
||||||
|
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
|
||||||
|
if (requestVersion !== gitStatusRequestVersion) return
|
||||||
|
if (slug !== options.worktreeSlug()) return
|
||||||
|
|
||||||
|
const sdkResult = await Promise.race([
|
||||||
|
sdkStatusPromise.then((value) => ({ kind: "fulfilled" as const, value })),
|
||||||
|
new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)),
|
||||||
|
]).catch(() => null)
|
||||||
|
|
||||||
|
const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null
|
||||||
|
setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList))
|
||||||
|
} catch (error) {
|
||||||
|
if (requestVersion !== gitStatusRequestVersion) return
|
||||||
|
if (slug !== options.worktreeSlug()) return
|
||||||
|
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
|
||||||
|
setGitStatusEntries([])
|
||||||
|
} finally {
|
||||||
|
if (requestVersion !== gitStatusRequestVersion) return
|
||||||
|
if (slug !== options.worktreeSlug()) return
|
||||||
|
setGitStatusLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGitFile(itemId: string) {
|
||||||
|
const requestVersion = ++gitDiffRequestVersion
|
||||||
|
setGitSelectedItemId(itemId)
|
||||||
|
setGitSelectedLoading(true)
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
|
||||||
|
const item = gitListItems().find((entry) => entry.id === itemId) || null
|
||||||
|
if (!item) {
|
||||||
|
if (requestVersion !== gitDiffRequestVersion) return
|
||||||
|
clearSelectedGitDiffAndSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isPhoneLayout()) {
|
||||||
|
options.closeGitList()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const diff = await serverApi.fetchWorktreeGitDiff(options.instanceId, options.worktreeSlug(), {
|
||||||
|
path: item.path,
|
||||||
|
originalPath: item.originalPath ?? null,
|
||||||
|
scope: item.section,
|
||||||
|
})
|
||||||
|
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
|
||||||
|
if (diff.isBinary) {
|
||||||
|
setGitSelectedError(options.t("instanceShell.gitChanges.binaryViewer"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGitSelectedBefore(diff.before)
|
||||||
|
setGitSelectedAfter(diff.after)
|
||||||
|
} catch (error) {
|
||||||
|
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
|
||||||
|
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
|
||||||
|
} finally {
|
||||||
|
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passiveRefreshGitStatus = async (optionsArg?: { forceReloadSelectedDiff?: boolean }) => {
|
||||||
|
if (options.rightPanelTab() !== "git-changes") return
|
||||||
|
if (passiveGitRefreshInFlight) {
|
||||||
|
pendingGitPassiveRefreshOptions = {
|
||||||
|
forceReloadSelectedDiff:
|
||||||
|
pendingGitPassiveRefreshOptions?.forceReloadSelectedDiff || optionsArg?.forceReloadSelectedDiff || false,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (gitCommitSubmitting()) return
|
||||||
|
|
||||||
|
passiveGitRefreshInFlight = true
|
||||||
|
const refreshSelectionId = gitSelectedItemId()
|
||||||
|
const previousSelection = describeGitSelection(gitSelectedItemId())
|
||||||
|
const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
|
||||||
|
const hadSelectedDiff =
|
||||||
|
previousSelection.itemId !== null &&
|
||||||
|
(gitSelectedBefore() !== null || gitSelectedAfter() !== null || gitSelectedError() !== null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadGitStatus(true)
|
||||||
|
if (gitSelectedItemId() !== refreshSelectionId) return
|
||||||
|
const nextSelection = resolveValidGitSelection(previousSelection)
|
||||||
|
setGitSelectedItemId(nextSelection)
|
||||||
|
|
||||||
|
if (!nextSelection) {
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFingerprint = describeGitSelectionFingerprint(nextSelection)
|
||||||
|
const shouldReloadSelectedDiff =
|
||||||
|
optionsArg?.forceReloadSelectedDiff ||
|
||||||
|
!hadSelectedDiff ||
|
||||||
|
previousFingerprint !== nextFingerprint ||
|
||||||
|
previousSelection.itemId === nextSelection
|
||||||
|
|
||||||
|
if (shouldReloadSelectedDiff) {
|
||||||
|
await openGitFile(nextSelection)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
passiveGitRefreshInFlight = false
|
||||||
|
if (pendingGitPassiveRefreshOptions) {
|
||||||
|
const nextOptions = pendingGitPassiveRefreshOptions
|
||||||
|
pendingGitPassiveRefreshOptions = null
|
||||||
|
void passiveRefreshGitStatus(nextOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
|
||||||
|
const currentSelection = describeGitSelection(gitSelectedItemId())
|
||||||
|
const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
|
||||||
|
const selectedIds = gitBulkSelectedItemIds()
|
||||||
|
const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
|
||||||
|
const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
|
||||||
|
const targetItems = bulkTargets.some((candidate) => candidate.id === item.id) ? bulkTargets : [item]
|
||||||
|
const targetPaths = Array.from(new Set(targetItems.map((candidate) => candidate.path)))
|
||||||
|
try {
|
||||||
|
if (action === "stage") {
|
||||||
|
await serverApi.stageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
|
||||||
|
} else {
|
||||||
|
await serverApi.unstageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadGitStatus(true)
|
||||||
|
clearGitBulkSelection()
|
||||||
|
const nextSelection = resolveValidGitSelection(fallbackSelection)
|
||||||
|
setGitSelectedItemId(nextSelection)
|
||||||
|
if (nextSelection) {
|
||||||
|
await openGitFile(nextSelection)
|
||||||
|
} else {
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : `Failed to ${action} file`,
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGitRowClick = (item: GitChangeListItem, event: MouseEvent) => {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
const anchorId = gitBulkSelectionAnchorId() ?? item.id
|
||||||
|
addGitBulkRange(anchorId, item.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleGitBulkSelection(item.id)
|
||||||
|
setGitBulkSelectionAnchorId(item.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGitBulkSelection()
|
||||||
|
setGitBulkSelectionAnchorId(item.id)
|
||||||
|
void openGitFile(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitGitCommit = async () => {
|
||||||
|
const message = gitCommitMessage().trim()
|
||||||
|
if (!message || gitCommitSubmitting()) return
|
||||||
|
|
||||||
|
setGitCommitSubmitting(true)
|
||||||
|
try {
|
||||||
|
await serverApi.commitWorktreeGitChanges(options.instanceId, options.worktreeSlug(), { message })
|
||||||
|
setGitCommitMessage("")
|
||||||
|
await loadGitStatus(true)
|
||||||
|
const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
|
||||||
|
setGitSelectedItemId(nextSelection)
|
||||||
|
if (nextSelection) {
|
||||||
|
await openGitFile(nextSelection)
|
||||||
|
} else {
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
}
|
||||||
|
showToastNotification({
|
||||||
|
message: options.t("instanceShell.gitChanges.commit.success"),
|
||||||
|
variant: "success",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : options.t("instanceShell.gitChanges.commit.error"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setGitCommitSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshGitStatus = async () => {
|
||||||
|
await loadGitStatus(true)
|
||||||
|
const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
|
||||||
|
setGitSelectedItemId(selected)
|
||||||
|
if (selected) {
|
||||||
|
void openGitFile(selected)
|
||||||
|
} else {
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
|
||||||
|
const startLine = selection?.startLine ?? 1
|
||||||
|
const endLine = selection?.endLine ?? startLine
|
||||||
|
options.promptInputApi()?.insertComment(`Git Diff: File: ${item.path} : ${startLine}-${endLine}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
options.worktreeSlug()
|
||||||
|
gitStatusRequestVersion += 1
|
||||||
|
gitDiffRequestVersion += 1
|
||||||
|
passiveGitRefreshInFlight = false
|
||||||
|
pendingGitPassiveRefreshOptions = null
|
||||||
|
setGitStatusEntries(null)
|
||||||
|
setGitStatusError(null)
|
||||||
|
setGitStatusLoading(false)
|
||||||
|
setGitSelectedItemId(null)
|
||||||
|
clearGitBulkSelection()
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
clearSelectedGitDiff()
|
||||||
|
setGitCommitMessage("")
|
||||||
|
setGitCommitSubmitting(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (options.rightPanelTab() !== "git-changes") return
|
||||||
|
const items = gitListItems()
|
||||||
|
if (gitStatusEntries() === null) return
|
||||||
|
if (items.length === 0) return
|
||||||
|
if (gitSelectedItemId()) return
|
||||||
|
const next = gitMostChangedItemId()
|
||||||
|
if (!next) return
|
||||||
|
void openGitFile(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activationKey = options.rightPanelTab() === "git-changes" ? `${options.instanceId}:${options.worktreeSlug()}` : null
|
||||||
|
if (!activationKey) {
|
||||||
|
previousGitChangesActivationKey = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (previousGitChangesActivationKey === activationKey) return
|
||||||
|
previousGitChangesActivationKey = activationKey
|
||||||
|
void passiveRefreshGitStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (options.rightPanelTab() !== "git-changes") return
|
||||||
|
|
||||||
|
const unsubscribe = serverEvents.on("instance.event", (event) => {
|
||||||
|
if (event.type !== "instance.event") return
|
||||||
|
if (event.instanceId !== options.instanceId) return
|
||||||
|
const eventType = (event.event as { type?: unknown } | undefined)?.type
|
||||||
|
if (eventType !== "session.updated" && eventType !== "session.diff") return
|
||||||
|
void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
unsubscribe()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (options.rightPanelTab() === "git-changes") return
|
||||||
|
setGitSelectedBefore(null)
|
||||||
|
setGitSelectedAfter(null)
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
setGitSelectedError(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
gitStatusEntries,
|
||||||
|
gitStatusLoading,
|
||||||
|
gitStatusError,
|
||||||
|
gitSelectedItemId,
|
||||||
|
gitBulkSelectedItemIds,
|
||||||
|
gitSelectedLoading,
|
||||||
|
gitSelectedError,
|
||||||
|
gitSelectedBefore,
|
||||||
|
gitSelectedAfter,
|
||||||
|
gitCommitMessage,
|
||||||
|
gitCommitSubmitting,
|
||||||
|
gitMostChangedItemId,
|
||||||
|
setGitCommitMessage,
|
||||||
|
handleGitRowClick,
|
||||||
|
refreshGitStatus,
|
||||||
|
insertGitChangeContext,
|
||||||
|
submitGitCommit,
|
||||||
|
stageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "stage"),
|
||||||
|
unstageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "unstage"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-
|
|||||||
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-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_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_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-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_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 RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||||
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
|
||||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { ClientPart, MessageInfo } from "../types/message"
|
import type { ClientPart, MessageInfo } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
|
||||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { selectInstanceTab } from "../stores/app-tabs"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
|||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||||
setActiveInstanceId(location.instanceId)
|
selectInstanceTab(location.instanceId)
|
||||||
const parentToActivate = location.parentId ?? location.sessionId
|
const parentToActivate = location.parentId ?? location.sessionId
|
||||||
setActiveParentSession(location.instanceId, parentToActivate)
|
setActiveParentSession(location.instanceId, parentToActivate)
|
||||||
if (location.parentId) {
|
if (location.parentId) {
|
||||||
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
|
|||||||
return type === "text" || type === "file"
|
return type === "text" || type === "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVisibleContentPart(part: ClientPart): boolean {
|
||||||
|
if (!part || !isContentPartType((part as any).type)) return false
|
||||||
|
if (isHiddenSyntheticTextPart(part)) return false
|
||||||
|
return partHasRenderableText(part)
|
||||||
|
}
|
||||||
|
|
||||||
function MessageContentItem(props: MessageContentItemProps) {
|
function MessageContentItem(props: MessageContentItemProps) {
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
return resolved
|
return resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
|
||||||
|
|
||||||
const showAgentMeta = createMemo(() => {
|
const showAgentMeta = createMemo(() => {
|
||||||
const current = record()
|
const current = record()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
if (current.role !== "assistant") return false
|
if (current.role !== "assistant") return false
|
||||||
|
|
||||||
const currentParts = parts()
|
const currentParts = parts()
|
||||||
if (!currentParts.some((part) => partHasRenderableText(part))) {
|
if (visibleParts().length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
if (!isSupportedPartType(part)) continue
|
if (!isSupportedPartType(part)) continue
|
||||||
|
|
||||||
if (!isContentPartType((part as any).type)) continue
|
if (!isContentPartType((part as any).type)) continue
|
||||||
if (partHasRenderableText(part)) {
|
if (isVisibleContentPart(part)) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
<MessageItem
|
<MessageItem
|
||||||
record={resolvedRecord()}
|
record={resolvedRecord()}
|
||||||
messageInfo={messageInfo()}
|
messageInfo={messageInfo()}
|
||||||
parts={parts()}
|
parts={visibleParts()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isQueued={isQueued()}
|
isQueued={isQueued()}
|
||||||
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const lastAssistantIdx = props.lastAssistantIndex()
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
|
|
||||||
// Intentionally untracked: messageInfoVersion updates should not trigger
|
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
|
||||||
// a full message block rebuild; record revision is the invalidation key.
|
|
||||||
const info = untrack(messageInfo)
|
|
||||||
|
|
||||||
const cacheSignature = [
|
const cacheSignature = [
|
||||||
current.id,
|
current.id,
|
||||||
current.revision,
|
current.revision,
|
||||||
|
messageInfoVersion,
|
||||||
isQueued ? 1 : 0,
|
isQueued ? 1 : 0,
|
||||||
props.showThinking() ? 1 : 0,
|
props.showThinking() ? 1 : 0,
|
||||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||||
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
return cachedBlock.block
|
return cachedBlock.block
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only capture info after cache check fails - ensures fresh data on version bump
|
||||||
|
const info = untrack(messageInfo)
|
||||||
|
|
||||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||||
const items: MessageBlockItem[] = []
|
const items: MessageBlockItem[] = []
|
||||||
const blockContentKeys: string[] = []
|
const blockContentKeys: string[] = []
|
||||||
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
data-message-id={resolvedBlock().record.id}
|
data-message-id={resolvedBlock().record.id}
|
||||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<For each={resolvedBlock().items}>
|
<Index each={resolvedBlock().items}>
|
||||||
{(item, index) => (
|
{(item, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item().type === "content"}>
|
||||||
<MessageContentItem
|
<MessageContentItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={(item as ContentDisplayItem).messageId}
|
messageId={(item() as ContentDisplayItem).messageId}
|
||||||
startPartId={(item as ContentDisplayItem).startPartId}
|
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||||
messageIndex={props.messageIndex}
|
messageIndex={props.messageIndex}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "tool"}>
|
<Match when={item().type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const toolItem = item as ToolDisplayItem
|
const toolItem = item() as ToolDisplayItem
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-message" data-key={toolItem.key}>
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
<ToolCallItem
|
<ToolCallItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
deleteHover={props.deleteHover}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-start"}>
|
<Match when={item().type === "step-start"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="start"
|
kind="start"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showAgentMeta
|
showAgentMeta
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item().type === "step-finish"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="finish"
|
kind="finish"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showUsage={props.showUsageMetrics()}
|
showUsage={props.showUsageMetrics()}
|
||||||
borderColor={(item as StepDisplayItem).accentColor}
|
borderColor={(item() as StepDisplayItem).accentColor}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "compaction"}>
|
<Match when={item().type === "compaction"}>
|
||||||
<CompactionCard
|
<CompactionCard
|
||||||
part={(item as CompactionDisplayItem).part}
|
part={(item() as CompactionDisplayItem).part}
|
||||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as CompactionDisplayItem).messageId}
|
messageId={(item() as CompactionDisplayItem).messageId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "reasoning"}>
|
<Match when={item().type === "reasoning"}>
|
||||||
<ReasoningCard
|
<ReasoningCard
|
||||||
part={(item as ReasoningDisplayItem).part}
|
part={(item() as ReasoningDisplayItem).part}
|
||||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as ReasoningDisplayItem).messageId}
|
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
if (!info || info.role !== "assistant" || !info.tokens) {
|
const part = props.part as any
|
||||||
|
|
||||||
|
// step-finish parts have tokens embedded; also check messageInfo
|
||||||
|
const partTokens = part?.tokens
|
||||||
|
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
|
||||||
|
const tokens = partTokens ?? infoTokens
|
||||||
|
if (!tokens) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const tokens = info.tokens
|
|
||||||
return {
|
return {
|
||||||
input: tokens.input ?? 0,
|
input: tokens.input ?? 0,
|
||||||
output: tokens.output ?? 0,
|
output: tokens.output ?? 0,
|
||||||
reasoning: tokens.reasoning ?? 0,
|
reasoning: tokens.reasoning ?? 0,
|
||||||
cacheRead: tokens.cache?.read ?? 0,
|
cacheRead: tokens.cache?.read ?? 0,
|
||||||
cacheWrite: tokens.cache?.write ?? 0,
|
cacheWrite: tokens.cache?.write ?? 0,
|
||||||
cost: info.cost ?? 0,
|
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningStreamOutput(props: {
|
||||||
const { t } = useI18n()
|
text: Accessor<string>
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
scrollTopSnapshot: Accessor<number>
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
setScrollTopSnapshot: (next: number) => void
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
onContentRendered?: () => void
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
ariaLabel: string
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
let pendingRenderNotificationFrame: number | null = null
|
let pendingRenderNotificationFrame: number | null = null
|
||||||
|
|
||||||
|
const followScroll = createFollowScroll({
|
||||||
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
|
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "reasoning-scroll-sentinel",
|
||||||
|
})
|
||||||
|
|
||||||
const notifyContentRendered = () => {
|
const notifyContentRendered = () => {
|
||||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
@@ -1312,6 +1339,15 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const nextText = props.text()
|
||||||
|
if (preRef && preRef.textContent !== nextText) {
|
||||||
|
preRef.textContent = nextText
|
||||||
|
}
|
||||||
|
followScroll.restoreAfterRender()
|
||||||
|
notifyContentRendered()
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||||
@@ -1319,6 +1355,37 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={followScroll.registerContainer}
|
||||||
|
class="message-reasoning-output"
|
||||||
|
role="region"
|
||||||
|
aria-label={props.ariaLabel}
|
||||||
|
onScroll={followScroll.handleScroll}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
ref={(element) => {
|
||||||
|
preRef = element || undefined
|
||||||
|
if (preRef) {
|
||||||
|
preRef.textContent = props.text() || ""
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="message-reasoning-text"
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
{followScroll.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!expanded()) return
|
|
||||||
reasoningText()
|
|
||||||
notifyContentRendered()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
@@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
<ReasoningStreamOutput
|
||||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
text={reasoningText}
|
||||||
</div>
|
scrollTopSnapshot={scrollTopSnapshot}
|
||||||
|
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
|||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const getRawContent = () => {
|
const getRawContent = () => {
|
||||||
return props.parts
|
return props.parts
|
||||||
.filter(part => part.type === "text")
|
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
|
||||||
.map(part => (part as { text?: string }).text || "")
|
.map((part) => (part as { text?: string }).text || "")
|
||||||
.filter(text => text.trim().length > 0)
|
.filter((text) => text.trim().length > 0)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
if (!hasContent() && !isGenerating()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
const shouldHideTextPart = () => {
|
const shouldHideTextPart = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if (!part || part.type !== "text") return false
|
if (!part || part.type !== "text") return false
|
||||||
|
return Boolean((part as any).synthetic)
|
||||||
const isSynthetic = Boolean((part as any).synthetic)
|
|
||||||
if (!isSynthetic) return false
|
|
||||||
|
|
||||||
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
|
|
||||||
if (props.messageType === "user") {
|
|
||||||
const primaryId = props.primaryUserTextPartId
|
|
||||||
if (!primaryId) return false
|
|
||||||
return part.id !== primaryId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide synthetic assistant text.
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||||
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||||
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
import { partHasRenderableText } from "../types/message"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
|
|
||||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||||
|
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
export interface MessageSectionProps {
|
export interface MessageSectionProps {
|
||||||
@@ -40,12 +42,40 @@ export interface MessageSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageSection(props: MessageSectionProps) {
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
const { preferences } = useConfig()
|
const { preferences, updatePreferences } = useConfig()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||||
|
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
|
||||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||||
|
const visibleMessageIds = createMemo(() => {
|
||||||
|
const resolvedStore = store()
|
||||||
|
return messageIds().filter((messageId) => {
|
||||||
|
const record = resolvedStore.getMessage(messageId)
|
||||||
|
if (!record) return false
|
||||||
|
|
||||||
|
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.role !== "assistant") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = resolvedStore.getMessageInfo(messageId)
|
||||||
|
if (!info || info.role !== "assistant") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.error) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeInfo = info.time as { created: number; end?: number } | undefined
|
||||||
|
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const scrollCache = useScrollCache({
|
const scrollCache = useScrollCache({
|
||||||
instanceId: props.instanceId,
|
instanceId: props.instanceId,
|
||||||
@@ -129,6 +159,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
|
||||||
|
|
||||||
const lastCompactionIndex = createMemo(() => {
|
const lastCompactionIndex = createMemo(() => {
|
||||||
// Depend on a single session revision signal (not every message/part read)
|
// Depend on a single session revision signal (not every message/part read)
|
||||||
// to keep reactive overhead small.
|
// to keep reactive overhead small.
|
||||||
@@ -315,15 +347,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastAssistantIndex = createMemo(() => {
|
const lastAssistantIndex = createMemo(() => {
|
||||||
const ids = messageIds()
|
const messageId = lastAssistantMessageId()
|
||||||
const resolvedStore = store()
|
if (!messageId) return -1
|
||||||
for (let index = ids.length - 1; index >= 0; index--) {
|
return messageIndexById().get(messageId) ?? -1
|
||||||
const record = resolvedStore.getMessage(ids[index])
|
|
||||||
if (record?.role === "assistant") {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||||
@@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
|
||||||
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
// Only preferences should force a follow-token re-anchor. Message/session
|
||||||
|
// revision churn at the end of a turn (message.updated, session.idle, etc.)
|
||||||
|
// should not trigger an immediate scroll-to-bottom.
|
||||||
|
const followToken = createMemo(() => preferenceSignature())
|
||||||
|
|
||||||
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
||||||
@@ -601,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||||
|
|
||||||
|
const lastVisibleMessageId = createMemo(() => {
|
||||||
|
const ids = visibleMessageIds()
|
||||||
|
return ids[ids.length - 1] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const autoPinHoldTargetKey = createMemo(() => {
|
||||||
|
if (!holdLongAssistantRepliesEnabled()) return null
|
||||||
|
const messageId = lastVisibleMessageId()
|
||||||
|
return isAssistantTextMessage(messageId) ? messageId : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleHoldLongAssistantReplies() {
|
||||||
|
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistantTextMessage(messageId: string | null | undefined) {
|
||||||
|
if (!messageId) return false
|
||||||
|
const resolvedStore = store()
|
||||||
|
const record = resolvedStore.getMessage(messageId)
|
||||||
|
if (!record || record.role !== "assistant") return false
|
||||||
|
|
||||||
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
||||||
|
return orderedParts.some((part) => {
|
||||||
|
if ((part as any)?.type !== "text") return false
|
||||||
|
if (partHasRenderableText(part)) return true
|
||||||
|
return typeof (part as { text?: unknown }).text === "string"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const api = listApi()
|
const api = listApi()
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -615,7 +673,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const api = listApi()
|
const api = listApi()
|
||||||
if (!element || !api) return
|
if (!element || !api) return
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
if (messageIds().length === 0) return
|
if (visibleMessageIds().length === 0) return
|
||||||
if (didRestoreScroll()) return
|
if (didRestoreScroll()) return
|
||||||
|
|
||||||
scrollCache.restore(element, {
|
scrollCache.restore(element, {
|
||||||
@@ -734,88 +792,93 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
|
|
||||||
if (loading) {
|
// Wrap all iteration of the store-proxied `ids` array in untrack()
|
||||||
handleClearTimelineSelection()
|
// to prevent O(n) per-element reactive subscriptions. The effect
|
||||||
previousTimelineIds = []
|
// only needs to re-run when `messageIds` (memo) changes.
|
||||||
setTimelineSegments([])
|
untrack(() => {
|
||||||
seenTimelineMessageIds.clear()
|
if (loading) {
|
||||||
seenTimelineSegmentKeys.clear()
|
handleClearTimelineSelection()
|
||||||
timelinePartCountsByMessageId.clear()
|
previousTimelineIds = []
|
||||||
pendingTimelineMessagePartUpdates.clear()
|
setTimelineSegments([])
|
||||||
if (pendingTimelinePartUpdateFrame !== null) {
|
seenTimelineMessageIds.clear()
|
||||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
seenTimelineSegmentKeys.clear()
|
||||||
pendingTimelinePartUpdateFrame = null
|
timelinePartCountsByMessageId.clear()
|
||||||
}
|
pendingTimelineMessagePartUpdates.clear()
|
||||||
return
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
}
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
|
||||||
seedTimeline()
|
|
||||||
previousTimelineIds = ids.slice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length < previousTimelineIds.length) {
|
|
||||||
seedTimeline()
|
|
||||||
previousTimelineIds = ids.slice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length === previousTimelineIds.length) {
|
|
||||||
let changedIndex = -1
|
|
||||||
let changeCount = 0
|
|
||||||
for (let index = 0; index < ids.length; index++) {
|
|
||||||
if (ids[index] !== previousTimelineIds[index]) {
|
|
||||||
changedIndex = index
|
|
||||||
changeCount += 1
|
|
||||||
if (changeCount > 1) break
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (changeCount === 1 && changedIndex >= 0) {
|
|
||||||
const oldId = previousTimelineIds[changedIndex]
|
|
||||||
const newId = ids[changedIndex]
|
|
||||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
|
||||||
seenTimelineMessageIds.delete(oldId)
|
|
||||||
seenTimelineMessageIds.add(newId)
|
|
||||||
setTimelineSegments((prev) => {
|
|
||||||
const next = prev.map((segment) => {
|
|
||||||
if (segment.messageId !== oldId) return segment
|
|
||||||
const updatedId = segment.id.replace(oldId, newId)
|
|
||||||
return { ...segment, messageId: newId, id: updatedId }
|
|
||||||
})
|
|
||||||
seenTimelineSegmentKeys.clear()
|
|
||||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep part count tracking in sync with id replacement.
|
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
seedTimeline()
|
||||||
if (existingPartCount !== undefined) {
|
previousTimelineIds = [...ids]
|
||||||
timelinePartCountsByMessageId.delete(oldId)
|
return
|
||||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
}
|
||||||
|
|
||||||
|
if (ids.length < previousTimelineIds.length) {
|
||||||
|
seedTimeline()
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === previousTimelineIds.length) {
|
||||||
|
let changedIndex = -1
|
||||||
|
let changeCount = 0
|
||||||
|
for (let index = 0; index < ids.length; index++) {
|
||||||
|
if (ids[index] !== previousTimelineIds[index]) {
|
||||||
|
changedIndex = index
|
||||||
|
changeCount += 1
|
||||||
|
if (changeCount > 1) break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (changeCount === 1 && changedIndex >= 0) {
|
||||||
|
const oldId = previousTimelineIds[changedIndex]
|
||||||
|
const newId = ids[changedIndex]
|
||||||
|
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||||
|
seenTimelineMessageIds.delete(oldId)
|
||||||
|
seenTimelineMessageIds.add(newId)
|
||||||
|
setTimelineSegments((prev) => {
|
||||||
|
const next = prev.map((segment) => {
|
||||||
|
if (segment.messageId !== oldId) return segment
|
||||||
|
const updatedId = segment.id.replace(oldId, newId)
|
||||||
|
return { ...segment, messageId: newId, id: updatedId }
|
||||||
|
})
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
// Keep part count tracking in sync with id replacement.
|
||||||
return
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||||
|
if (existingPartCount !== undefined) {
|
||||||
|
timelinePartCountsByMessageId.delete(oldId)
|
||||||
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const newIds: string[] = []
|
const newIds: string[] = []
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
if (!seenTimelineMessageIds.has(id)) {
|
if (!seenTimelineMessageIds.has(id)) {
|
||||||
newIds.push(id)
|
newIds.push(id)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (newIds.length > 0) {
|
|
||||||
newIds.forEach((id) => {
|
|
||||||
seenTimelineMessageIds.add(id)
|
|
||||||
appendTimelineForMessage(id)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
if (newIds.length > 0) {
|
||||||
|
newIds.forEach((id) => {
|
||||||
|
seenTimelineMessageIds.add(id)
|
||||||
|
appendTimelineForMessage(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearPendingTimelinePartUpdateFrame() {
|
function clearPendingTimelinePartUpdateFrame() {
|
||||||
@@ -886,36 +949,49 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
const resolvedStore = store()
|
// Also re-run when sessionRevision bumps (covers part additions within
|
||||||
|
// existing messages) but read individual records inside untrack() to
|
||||||
|
// avoid creating O(n) fine-grained subscriptions.
|
||||||
|
sessionRevision()
|
||||||
|
|
||||||
let hasChanges = false
|
// Wrap the iteration in untrack() so that accessing individual elements
|
||||||
for (const messageId of ids) {
|
// of the store-proxied `ids` array does not create O(n) per-element
|
||||||
const record = resolvedStore.getMessage(messageId)
|
// reactive subscriptions. We only need to re-run when the memo
|
||||||
const partCount = record?.partIds.length ?? 0
|
// (messageIds) or sessionRevision changes — not per-element.
|
||||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
untrack(() => {
|
||||||
|
const resolvedStore = store()
|
||||||
|
const idsSet = new Set(ids)
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
if (previousCount === undefined) {
|
for (const messageId of ids) {
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
continue
|
const partCount = record?.partIds.length ?? 0
|
||||||
|
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||||
|
|
||||||
|
if (previousCount === undefined) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousCount !== partCount) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
pendingTimelineMessagePartUpdates.add(messageId)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousCount !== partCount) {
|
// Drop tracking for ids that are no longer present.
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
|
||||||
pendingTimelineMessagePartUpdates.add(messageId)
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||||
hasChanges = true
|
if (!idsSet.has(trackedId)) {
|
||||||
|
timelinePartCountsByMessageId.delete(trackedId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Drop tracking for ids that are no longer present.
|
if (hasChanges) {
|
||||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
scheduleTimelinePartUpdateFlush()
|
||||||
if (!ids.includes(trackedId)) {
|
|
||||||
timelinePartCountsByMessageId.delete(trackedId)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
scheduleTimelinePartUpdateFlush()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -989,7 +1065,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
data-scroll-buttons={scrollButtonsCount()}
|
data-scroll-buttons={scrollButtonsCount()}
|
||||||
>
|
>
|
||||||
<VirtualFollowList
|
<VirtualFollowList
|
||||||
items={messageIds}
|
items={visibleMessageIds}
|
||||||
getKey={(messageId) => messageId}
|
getKey={(messageId) => messageId}
|
||||||
getAnchorId={getMessageAnchorId}
|
getAnchorId={getMessageAnchorId}
|
||||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||||
@@ -1003,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
initialAutoScroll={initialAutoScroll}
|
initialAutoScroll={initialAutoScroll}
|
||||||
resetKey={() => props.sessionId}
|
resetKey={() => props.sessionId}
|
||||||
followToken={followToken}
|
followToken={followToken}
|
||||||
|
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||||
|
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||||
|
resolveAutoPinHoldElement={(itemWrapper, key) => {
|
||||||
|
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
|
||||||
|
return candidates[candidates.length - 1] ?? null
|
||||||
|
}}
|
||||||
onScroll={() => {
|
onScroll={() => {
|
||||||
clearQuoteSelection()
|
clearQuoteSelection()
|
||||||
scrollCache.persist(streamElement())
|
scrollCache.persist(streamElement())
|
||||||
@@ -1033,9 +1115,55 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||||
registerApi={(api) => setListApi(api)}
|
registerApi={(api) => setListApi(api)}
|
||||||
registerState={(state) => setListState(state)}
|
registerState={(state) => setListState(state)}
|
||||||
|
renderControls={(state, api) => (
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
|
||||||
|
onClick={toggleHoldLongAssistantReplies}
|
||||||
|
aria-label={
|
||||||
|
holdLongAssistantRepliesEnabled()
|
||||||
|
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||||
|
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
holdLongAssistantRepliesEnabled()
|
||||||
|
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||||
|
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<Show when={state.showScrollTopButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => api.scrollToTop()}
|
||||||
|
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↑
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={state.showScrollBottomButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => api.scrollToBottom()}
|
||||||
|
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
renderBeforeItems={() => (
|
renderBeforeItems={() => (
|
||||||
<>
|
<>
|
||||||
<Show when={!props.loading && messageIds().length === 0}>
|
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-content">
|
<div class="empty-state-content">
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||||
|
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
import MessagePreview from "./message-preview"
|
import MessagePreview from "./message-preview"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { ClientPart } from "../types/message"
|
import type { ClientPart } from "../types/message"
|
||||||
|
import { isHiddenSyntheticTextPart } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
@@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
|
|||||||
const LONG_PRESS_MS = 500
|
const LONG_PRESS_MS = 500
|
||||||
const JITTER_THRESHOLD = 10
|
const JITTER_THRESHOLD = 10
|
||||||
const ABSOLUTE_TOKEN_CAP = 10000
|
const ABSOLUTE_TOKEN_CAP = 10000
|
||||||
|
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
@@ -65,6 +69,13 @@ interface PendingSegment {
|
|||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineSegmentState {
|
||||||
|
deleteHovered: boolean
|
||||||
|
deleteSelected: boolean
|
||||||
|
hasActivePermission: boolean
|
||||||
|
hidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value: string): string {
|
function truncateText(value: string): string {
|
||||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||||
return value
|
return value
|
||||||
@@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string {
|
|||||||
|
|
||||||
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
|
if (isHiddenSyntheticTextPart(part)) return ""
|
||||||
if (typeof (part as any).text === "string") {
|
if (typeof (part as any).text === "string") {
|
||||||
return (part as any).text as string
|
return (part as any).text as string
|
||||||
}
|
}
|
||||||
@@ -349,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearHoverPreview = () => {
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
setHoveredSegment(null)
|
||||||
|
setHoverAnchorRect(null)
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
@@ -356,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||||
closeTimer = window.setTimeout(() => {
|
closeTimer = window.setTimeout(() => {
|
||||||
closeTimer = null
|
closeTimer = null
|
||||||
setHoveredSegment(null)
|
clearHoverPreview()
|
||||||
setHoverAnchorRect(null)
|
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
clearHoverTimer()
|
clearHoverPreview()
|
||||||
clearCloseTimer()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Selection & histogram rib state ---
|
// --- Selection & histogram rib state ---
|
||||||
@@ -416,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||||
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
let xrayOverlayRef: HTMLDivElement | undefined
|
let xrayOverlayRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -447,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
if (renderVirtualizedTimeline()) {
|
||||||
|
if (hoveredSegment()) {
|
||||||
|
clearHoverPreview()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!isSelectionActive()) return
|
if (!isSelectionActive()) return
|
||||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||||
@@ -475,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
|
||||||
|
|
||||||
|
createEffect(on(renderVirtualizedTimeline, () => {
|
||||||
|
clearHoverPreview()
|
||||||
|
}))
|
||||||
|
|
||||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||||
|
|
||||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||||
@@ -577,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
wasLongPress = true
|
wasLongPress = true
|
||||||
|
|
||||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||||
const btn = buttonRefs.get(segment.id)
|
const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
|
||||||
let anchorOffset: number | null = null
|
let anchorOffset: number | null = null
|
||||||
if (btn && scrollContainerRef) {
|
if (btn && scrollContainerRef) {
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||||
@@ -629,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||||
if (!activeId) return
|
if (!activeId) return
|
||||||
const element = buttonRefs.get(activeId)
|
|
||||||
if (!element) return
|
|
||||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||||
|
if (renderVirtualizedTimeline()) {
|
||||||
|
const index = segmentIndexById().get(activeId)
|
||||||
|
if (index !== undefined) {
|
||||||
|
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = buttonRefs.get(activeId)
|
||||||
|
if (!element) return
|
||||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
}, 120) : null
|
}, 120) : null
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -682,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const segmentIndexById = createMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const segmentStates = createMemo(() => {
|
||||||
|
const hover = deleteHover()
|
||||||
|
const selectedMessages = props.selectedMessageIds?.()
|
||||||
|
const expandedMessages = props.expandedMessageIds?.()
|
||||||
|
const resolvedStore = store()
|
||||||
|
const indexMap = messageIdToSessionIndex()
|
||||||
|
const selectionActive = isSelectionActive()
|
||||||
|
const result = new Map<string, TimelineSegmentState>()
|
||||||
|
|
||||||
|
for (const segment of props.segments) {
|
||||||
|
let deleteHovered = false
|
||||||
|
if (hover.kind === "message") {
|
||||||
|
deleteHovered = hover.messageId === segment.messageId
|
||||||
|
} else if (hover.kind === "deleteUpTo") {
|
||||||
|
const targetIndex = indexMap.get(hover.messageId)
|
||||||
|
const segmentIndex = indexMap.get(segment.messageId)
|
||||||
|
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
|
||||||
|
|
||||||
|
let hasActivePermission = false
|
||||||
|
if (segment.type === "tool") {
|
||||||
|
const partIds = segment.toolPartIds ?? []
|
||||||
|
for (const partId of partIds) {
|
||||||
|
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
|
||||||
|
if (permissionState?.active) {
|
||||||
|
hasActivePermission = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = segment.type === "tool" && !(
|
||||||
|
showTools()
|
||||||
|
|| expandedMessages?.has(segment.messageId)
|
||||||
|
|| selectionActive
|
||||||
|
|| props.activeSegmentId === segment.id
|
||||||
|
|| hasActivePermission
|
||||||
|
|| deleteHovered
|
||||||
|
|| deleteSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
result.set(segment.id, {
|
||||||
|
deleteHovered,
|
||||||
|
deleteSelected,
|
||||||
|
hasActivePermission,
|
||||||
|
hidden,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
|
||||||
|
return segmentStates().get(segmentId) ?? {
|
||||||
|
deleteHovered: false,
|
||||||
|
deleteSelected: false,
|
||||||
|
hasActivePermission: false,
|
||||||
|
hidden: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentSpacerHeights = createMemo(() => {
|
||||||
|
const states = segmentStates()
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
let previousVisible: TimelineSegment | null = null
|
||||||
|
|
||||||
|
for (let index = 0; index < props.segments.length; index += 1) {
|
||||||
|
const segment = props.segments[index]
|
||||||
|
const state = states.get(segment.id)
|
||||||
|
|
||||||
|
if (state?.hidden) {
|
||||||
|
result.set(segment.id, "0")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousVisible) {
|
||||||
|
result.set(segment.id, "0")
|
||||||
|
previousVisible = segment
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRaw = index > 0 ? props.segments[index - 1] : null
|
||||||
|
const startsVisibleToolGroup = segment.type === "tool"
|
||||||
|
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
|
||||||
|
const startsCollapsedToolGroup = segment.type === "assistant"
|
||||||
|
&& previousVisible.messageId !== segment.messageId
|
||||||
|
&& messagesWithTools().has(segment.messageId)
|
||||||
|
&& previousRaw?.type === "tool"
|
||||||
|
&& previousRaw.messageId === segment.messageId
|
||||||
|
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
|
||||||
|
&& previousVisible.type === "assistant"
|
||||||
|
&& messagesWithTools().has(previousVisible.messageId)
|
||||||
|
|
||||||
|
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
|
||||||
|
result.set(
|
||||||
|
segment.id,
|
||||||
|
gapUnits === 1
|
||||||
|
? "var(--message-timeline-segment-gap)"
|
||||||
|
: "calc(var(--message-timeline-segment-gap) * 2)",
|
||||||
|
)
|
||||||
|
|
||||||
|
previousVisible = segment
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-timeline-container">
|
<div class="message-timeline-container">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={(element) => {
|
||||||
|
scrollContainerRef = element
|
||||||
|
setScrollElement(element)
|
||||||
|
}}
|
||||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("messageTimeline.ariaLabel")}
|
aria-label={t("messageTimeline.ariaLabel")}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<For each={props.segments}>
|
<Show
|
||||||
{(segment, segIndex) => {
|
when={renderVirtualizedTimeline()}
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
fallback={(
|
||||||
|
<For each={props.segments}>
|
||||||
|
{(segment, segIndex) => {
|
||||||
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
|
const isActive = () => props.activeSegmentId === segment.id
|
||||||
|
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||||
|
const state = () => segmentStateFor(segment.id)
|
||||||
|
const isDeleteHovered = () => state().deleteHovered
|
||||||
|
const isDeleteSelected = () => state().deleteSelected
|
||||||
|
const hasActivePermission = () => state().hasActivePermission
|
||||||
|
const isHidden = () => state().hidden
|
||||||
|
|
||||||
|
const groupRole = (): "child" | "parent" | "none" => {
|
||||||
|
if (segment.type === "tool") return "child"
|
||||||
|
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortLabelContent = () => {
|
||||||
|
if (segment.type === "tool") {
|
||||||
|
if (hasActivePermission()) {
|
||||||
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
|
}
|
||||||
|
if (segment.type === "compaction") {
|
||||||
|
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
if (segment.type === "user") {
|
||||||
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-timeline-item">
|
||||||
|
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||||
|
<button
|
||||||
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
|
type="button"
|
||||||
|
data-variant={segment.variant}
|
||||||
|
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||||
|
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||||
|
aria-current={isActive() ? "true" : undefined}
|
||||||
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (wasLongPress) {
|
||||||
|
wasLongPress = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = buttonRefs.get(segment.id)
|
||||||
|
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||||
|
let anchorOffset: number | null = null
|
||||||
|
if (stableBtn && scrollContainerRef) {
|
||||||
|
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
props.onSelectRange?.(segment.id)
|
||||||
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
|
props.onToggleSelection?.(segment.id)
|
||||||
|
} else if (isMultiSelectActive) {
|
||||||
|
props.onSegmentClick?.(segment)
|
||||||
|
} else {
|
||||||
|
props.onSegmentClick?.(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||||
|
const desired = stableBtn.offsetTop - anchorOffset
|
||||||
|
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||||
|
scrollContainerRef.scrollTop = desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||||
|
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
|
||||||
|
{(segment, index) => {
|
||||||
|
const segIndex = () => index()
|
||||||
const isActive = () => props.activeSegmentId === segment.id
|
const isActive = () => props.activeSegmentId === segment.id
|
||||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||||
|
const state = () => segmentStateFor(segment.id)
|
||||||
const isDeleteHovered = () => {
|
const isDeleteHovered = () => state().deleteHovered
|
||||||
const hover = deleteHover() as DeleteHoverState
|
const isDeleteSelected = () => state().deleteSelected
|
||||||
if (hover.kind === "message") {
|
const hasActivePermission = () => state().hasActivePermission
|
||||||
return hover.messageId === segment.messageId
|
const isHidden = () => state().hidden
|
||||||
}
|
|
||||||
|
|
||||||
if (hover.kind === "deleteUpTo") {
|
|
||||||
const indexMap = messageIdToSessionIndex()
|
|
||||||
const targetIndex = indexMap.get(hover.messageId)
|
|
||||||
if (targetIndex === undefined) return false
|
|
||||||
const segmentIndex = indexMap.get(segment.messageId)
|
|
||||||
if (segmentIndex === undefined) return false
|
|
||||||
return segmentIndex >= targetIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDeleteSelected = () => {
|
|
||||||
const selected = props.selectedMessageIds?.()
|
|
||||||
if (!selected) return false
|
|
||||||
return selected.has(segment.messageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActivePermission = () => {
|
|
||||||
if (segment.type !== "tool") return false
|
|
||||||
const partIds = segment.toolPartIds ?? []
|
|
||||||
if (partIds.length === 0) return false
|
|
||||||
for (const partId of partIds) {
|
|
||||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
|
||||||
if (permissionState?.active) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
|
||||||
const isHidden = () =>
|
|
||||||
segment.type === "tool" &&
|
|
||||||
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
|
||||||
|
|
||||||
// Group visual indicators: tools belong to the same message as their
|
// Group visual indicators: tools belong to the same message as their
|
||||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||||
@@ -744,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
const isGroupStart = () => {
|
|
||||||
if (segment.type !== "tool") return false
|
|
||||||
const idx = segIndex()
|
|
||||||
const prev = idx > 0 ? props.segments[idx - 1] : null
|
|
||||||
// First tool in the message's run: either nothing before, or previous
|
|
||||||
// segment is from a different message or is not a tool.
|
|
||||||
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortLabelContent = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type === "tool") {
|
||||||
if (hasActivePermission()) {
|
if (hasActivePermission()) {
|
||||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
@@ -765,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (segment.type === "user") {
|
if (segment.type === "user") {
|
||||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div class="message-timeline-item">
|
||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||||
type="button"
|
<button
|
||||||
data-variant={segment.variant}
|
type="button"
|
||||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
data-variant={segment.variant}
|
||||||
|
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||||
|
aria-current={isActive() ? "true" : undefined}
|
||||||
aria-current={isActive() ? "true" : undefined}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
if (wasLongPress) {
|
||||||
if (wasLongPress) {
|
wasLongPress = false
|
||||||
wasLongPress = false
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture scroll anchor before selection changes may toggle
|
|
||||||
// tool segment visibility, which shifts timeline layout.
|
|
||||||
const btn = buttonRefs.get(segment.id)
|
|
||||||
let anchorOffset: number | null = null
|
|
||||||
if (btn && scrollContainerRef) {
|
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
|
||||||
|
|
||||||
if (event.shiftKey) {
|
|
||||||
props.onSelectRange?.(segment.id)
|
|
||||||
} else if (event.ctrlKey || event.metaKey) {
|
|
||||||
props.onToggleSelection?.(segment.id)
|
|
||||||
} else if (isMultiSelectActive) {
|
|
||||||
// In selection mode, plain click scrolls to the message
|
|
||||||
// instead of clearing. Selection is cleared by clicking
|
|
||||||
// anywhere inside the chat container or pressing Esc.
|
|
||||||
props.onSegmentClick?.(segment)
|
|
||||||
} else {
|
|
||||||
props.onSegmentClick?.(segment)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore scroll anchor: keep the clicked badge at the same
|
|
||||||
// visual position after hidden tools appear or disappear.
|
|
||||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
|
||||||
const desired = btn.offsetTop - anchorOffset
|
|
||||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
|
||||||
scrollContainerRef.scrollTop = desired
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}}
|
const btn = buttonRefs.get(segment.id)
|
||||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||||
onPointerUp={handlePointerUp}
|
let anchorOffset: number | null = null
|
||||||
onPointerCancel={handlePointerUp}
|
if (stableBtn && scrollContainerRef) {
|
||||||
onPointerMove={handlePointerMove}
|
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||||
onContextMenu={handleContextMenu}
|
}
|
||||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||||
>
|
|
||||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
if (event.shiftKey) {
|
||||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
props.onSelectRange?.(segment.id)
|
||||||
</button>
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
)
|
props.onToggleSelection?.(segment.id)
|
||||||
}}
|
} else if (isMultiSelectActive) {
|
||||||
</For>
|
props.onSegmentClick?.(segment)
|
||||||
|
} else {
|
||||||
|
props.onSegmentClick?.(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||||
|
const desired = stableBtn.offsetTop - anchorOffset
|
||||||
|
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||||
|
scrollContainerRef.scrollTop = desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||||
|
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Virtualizer>
|
||||||
|
</Show>
|
||||||
<Show when={previewData()}>
|
<Show when={previewData()}>
|
||||||
{(data) => {
|
{(data) => {
|
||||||
onCleanup(() => setTooltipElement(null))
|
onCleanup(() => setTooltipElement(null))
|
||||||
return (
|
return (
|
||||||
<div
|
<Portal>
|
||||||
ref={(element) => setTooltipElement(element)}
|
<div
|
||||||
class="message-timeline-tooltip"
|
ref={(element) => setTooltipElement(element)}
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
class="message-timeline-tooltip"
|
||||||
onMouseEnter={() => clearCloseTimer()}
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
onMouseLeave={() => scheduleClose()}
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
>
|
onMouseLeave={() => scheduleClose()}
|
||||||
<MessagePreview
|
>
|
||||||
messageId={data().messageId}
|
<MessagePreview
|
||||||
instanceId={props.instanceId}
|
messageId={data().messageId}
|
||||||
sessionId={props.sessionId}
|
instanceId={props.instanceId}
|
||||||
store={store}
|
sessionId={props.sessionId}
|
||||||
deleteHover={props.deleteHover}
|
store={store}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
/>
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
insertQuotedSelection(text)
|
insertQuotedSelection(text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
insertComment: (text: string) => {
|
||||||
|
const normalized = (text ?? "").replace(/\r/g, "").trim()
|
||||||
|
if (!normalized) return
|
||||||
|
insertBlockContent(`${normalized}\n\n`)
|
||||||
|
},
|
||||||
expandTextAttachment: (attachmentId: string) => {
|
expandTextAttachment: (attachmentId: string) => {
|
||||||
const attachment = attachments().find((a) => a.id === attachmentId)
|
const attachment = attachments().find((a) => a.id === attachmentId)
|
||||||
if (!attachment) return
|
if (!attachment) return
|
||||||
@@ -540,6 +545,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
mode={pickerMode()}
|
mode={pickerMode()}
|
||||||
onClose={handlePickerClose}
|
onClose={handlePickerClose}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
|
onSubmitWithoutSelection={() => {
|
||||||
|
handlePickerClose()
|
||||||
|
void handleSend()
|
||||||
|
}}
|
||||||
agents={instanceAgents()}
|
agents={instanceAgents()}
|
||||||
commands={getCommands(props.instanceId)}
|
commands={getCommands(props.instanceId)}
|
||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
@@ -572,113 +581,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<div class="prompt-nav-buttons">
|
|
||||||
<div class="prompt-nav-column prompt-nav-column-left">
|
|
||||||
<Show when={showVoiceInput()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
beginVoicePress(event)
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
endVoicePress()
|
|
||||||
}}
|
|
||||||
onPointerCancel={() => endVoicePress()}
|
|
||||||
onLostPointerCapture={() => endVoicePress()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.repeat) return
|
|
||||||
if (event.key !== " " && event.key !== "Enter") return
|
|
||||||
event.preventDefault()
|
|
||||||
beginVoicePress(event)
|
|
||||||
}}
|
|
||||||
onKeyUp={(event) => {
|
|
||||||
if (event.key !== " " && event.key !== "Enter") return
|
|
||||||
event.preventDefault()
|
|
||||||
endVoicePress()
|
|
||||||
}}
|
|
||||||
onBlur={() => endVoicePress()}
|
|
||||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
|
||||||
aria-label={voiceInput.buttonTitle()}
|
|
||||||
title={voiceInput.buttonTitle()}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={voiceInput.isRecording()}
|
|
||||||
fallback={
|
|
||||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
|
||||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<Show when={showConversationToggle()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
|
||||||
onClick={() => toggleConversationMode(props.instanceId)}
|
|
||||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
|
||||||
aria-pressed={conversationModeEnabled()}
|
|
||||||
aria-label={conversationModeButtonTitle()}
|
|
||||||
title={conversationModeButtonTitle()}
|
|
||||||
>
|
|
||||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="prompt-clear-button"
|
|
||||||
onClick={handleClearPrompt}
|
|
||||||
disabled={!canClearPrompt()}
|
|
||||||
aria-label={t("promptInput.clear.ariaLabel")}
|
|
||||||
title={t("promptInput.clear.title")}
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="prompt-nav-column prompt-nav-column-right">
|
|
||||||
<ExpandButton
|
|
||||||
expandState={expandState}
|
|
||||||
onToggleExpand={handleExpandToggle}
|
|
||||||
/>
|
|
||||||
<Show when={hasHistory()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="prompt-history-button"
|
|
||||||
onClick={() =>
|
|
||||||
selectPreviousHistory({
|
|
||||||
force: true,
|
|
||||||
isPickerOpen: showPicker(),
|
|
||||||
getTextarea: () => textareaRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canHistoryGoPrevious()}
|
|
||||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
|
||||||
>
|
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="prompt-history-button"
|
|
||||||
onClick={() =>
|
|
||||||
selectNextHistory({
|
|
||||||
force: true,
|
|
||||||
isPickerOpen: showPicker(),
|
|
||||||
getTextarea: () => textareaRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canHistoryGoNext()}
|
|
||||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
|
||||||
>
|
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
@@ -733,6 +635,116 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
|
<div class="prompt-nav-buttons">
|
||||||
|
<div class="prompt-nav-column prompt-nav-column-left">
|
||||||
|
<Show when={showVoiceInput()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => endVoicePress()}
|
||||||
|
onLostPointerCapture={() => endVoicePress()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.repeat) return
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onBlur={() => endVoicePress()}
|
||||||
|
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||||
|
aria-label={voiceInput.buttonTitle()}
|
||||||
|
title={voiceInput.buttonTitle()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={voiceInput.isRecording()}
|
||||||
|
fallback={
|
||||||
|
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showConversationToggle()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||||
|
onClick={() => toggleConversationMode(props.instanceId)}
|
||||||
|
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||||
|
aria-pressed={conversationModeEnabled()}
|
||||||
|
aria-label={conversationModeButtonTitle()}
|
||||||
|
title={conversationModeButtonTitle()}
|
||||||
|
>
|
||||||
|
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-clear-button"
|
||||||
|
onClick={handleClearPrompt}
|
||||||
|
disabled={!canClearPrompt()}
|
||||||
|
aria-label={t("promptInput.clear.ariaLabel")}
|
||||||
|
title={t("promptInput.clear.title")}
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-nav-column prompt-nav-column-right">
|
||||||
|
<ExpandButton
|
||||||
|
expandState={expandState}
|
||||||
|
onToggleExpand={handleExpandToggle}
|
||||||
|
/>
|
||||||
|
<Show when={hasHistory()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-history-button"
|
||||||
|
onClick={() =>
|
||||||
|
selectPreviousHistory({
|
||||||
|
force: true,
|
||||||
|
isPickerOpen: showPicker(),
|
||||||
|
getTextarea: () => textareaRef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canHistoryGoPrevious()}
|
||||||
|
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||||
|
>
|
||||||
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-history-button"
|
||||||
|
onClick={() =>
|
||||||
|
selectNextHistory({
|
||||||
|
force: true,
|
||||||
|
isPickerOpen: showPicker(),
|
||||||
|
getTextarea: () => textareaRef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canHistoryGoNext()}
|
||||||
|
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||||
|
>
|
||||||
|
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-input-primary-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="stop-button"
|
class="stop-button"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type PromptInsertMode = "quote" | "code"
|
|||||||
|
|
||||||
export interface PromptInputApi {
|
export interface PromptInputApi {
|
||||||
insertSelection(text: string, mode: PromptInsertMode): void
|
insertSelection(text: string, mode: PromptInsertMode): void
|
||||||
|
insertComment(text: string): void
|
||||||
expandTextAttachment(attachmentId: string): void
|
expandTextAttachment(attachmentId: string): void
|
||||||
removeAttachment(attachmentId: string): void
|
removeAttachment(attachmentId: string): void
|
||||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||||
|
|||||||
@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
|
||||||
// Remove the partial @mention text from the textarea when ESC is pressed
|
|
||||||
const textarea = options.getTextarea()
|
|
||||||
if (textarea) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const cursorPos = textarea.selectionStart
|
|
||||||
// Remove text from @ position to cursor position
|
|
||||||
const before = currentPrompt.substring(0, pos)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
options.setPrompt(before + after)
|
|
||||||
|
|
||||||
// Restore cursor position to where @ was
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
nextTextarea.setSelectionRange(pos, pos)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// Clear ignoredAtPositions so typing @ again will work
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
|||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
const start = textarea ? textarea.selectionStart : current.length
|
const start = textarea ? textarea.selectionStart : current.length
|
||||||
const end = textarea ? textarea.selectionEnd : current.length
|
const end = textarea ? textarea.selectionEnd : current.length
|
||||||
|
const wasCursorAtEnd = end === current.length
|
||||||
|
const wasScrolledToBottom = textarea
|
||||||
|
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
|
||||||
|
: false
|
||||||
const before = current.slice(0, start)
|
const before = current.slice(0, start)
|
||||||
const after = current.slice(end)
|
const after = current.slice(end)
|
||||||
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
|
const prefix = ""
|
||||||
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
|
const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
|
||||||
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||||
const cursor = before.length + prefix.length + text.length
|
const cursor = before.length + prefix.length + text.length + suffix.length
|
||||||
|
|
||||||
options.setPrompt(nextValue)
|
options.setPrompt(nextValue)
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
textarea.setSelectionRange(cursor, cursor)
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
if (wasCursorAtEnd || wasScrolledToBottom) {
|
||||||
|
textarea.scrollTop = textarea.scrollHeight
|
||||||
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface SessionViewProps {
|
|||||||
onSidebarToggle?: () => void
|
onSidebarToggle?: () => void
|
||||||
forceCompactStatusLayout?: boolean
|
forceCompactStatusLayout?: boolean
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||||
@@ -79,11 +80,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
requestAnimationFrame(() => scrollToBottomHandle?.())
|
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (!props.isActive) return
|
on(
|
||||||
if (!shouldScrollToBottomOnActivate()) return
|
() => props.isActive,
|
||||||
scheduleScrollToBottom()
|
(isActive, wasActive) => {
|
||||||
})
|
if (!isActive) return
|
||||||
|
if (wasActive === true) return
|
||||||
|
if (!shouldScrollToBottomOnActivate()) return
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -143,6 +150,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
function registerPromptInputApi(api: PromptInputApi) {
|
function registerPromptInputApi(api: PromptInputApi) {
|
||||||
promptInputApi = api
|
promptInputApi = api
|
||||||
|
props.registerSessionPromptApi?.(props.sessionId, api)
|
||||||
|
|
||||||
if (pendingPromptText) {
|
if (pendingPromptText) {
|
||||||
api.setPromptText(pendingPromptText, { focus: true })
|
api.setPromptText(pendingPromptText, { focus: true })
|
||||||
@@ -157,6 +165,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
if (promptInputApi === api) {
|
if (promptInputApi === api) {
|
||||||
promptInputApi = null
|
promptInputApi = null
|
||||||
|
props.registerSessionPromptApi?.(props.sessionId, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,16 +341,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
isActive={props.isActive}
|
isActive={props.isActive}
|
||||||
registerScrollToBottom={(fn) => {
|
registerScrollToBottom={(fn) => {
|
||||||
scrollToBottomHandle = fn
|
scrollToBottomHandle = fn
|
||||||
if (props.isActive) {
|
}}
|
||||||
if (shouldScrollToBottomOnActivate()) {
|
|
||||||
scheduleScrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
|
||||||
import { createMemo, For, type Component } from "solid-js"
|
import { createMemo, For, type Component } from "solid-js"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
|
|||||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||||
|
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
|
|||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||||
|
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
|
|||||||
return <RemoteAccessSettingsSection />
|
return <RemoteAccessSettingsSection />
|
||||||
case "speech":
|
case "speech":
|
||||||
return <SpeechSettingsSection />
|
return <SpeechSettingsSection />
|
||||||
|
case "sidecars":
|
||||||
|
return <SideCarsSettingsSection />
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return <OpenCodeSettingsSection />
|
return <OpenCodeSettingsSection />
|
||||||
case "appearance":
|
case "appearance":
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { createEffect, createSignal, type Component } from "solid-js"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Terminal } from "lucide-solid"
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import { ChevronDown, Terminal } from "lucide-solid"
|
||||||
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||||
import { useConfig } from "../../stores/preferences"
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import type { ServerLogLevel } from "../../stores/preferences"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
type LogLevelOption = {
|
||||||
|
value: ServerLogLevel
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
export const OpenCodeSettingsSection: Component = () => {
|
export const OpenCodeSettingsSection: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { serverSettings, updateLastUsedBinary } = useConfig()
|
const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig()
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
|
const logLevelOptions = createMemo<LogLevelOption[]>(() => [
|
||||||
|
{ value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") },
|
||||||
|
{ value: "INFO", label: t("settings.opencode.logLevel.option.info") },
|
||||||
|
{ value: "WARN", label: t("settings.opencode.logLevel.option.warn") },
|
||||||
|
{ value: "ERROR", label: t("settings.opencode.logLevel.option.error") },
|
||||||
|
])
|
||||||
|
const selectedLogLevel = createMemo(
|
||||||
|
() => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0],
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const binary = serverSettings().opencodeBinary || "opencode"
|
const binary = serverSettings().opencodeBinary || "opencode"
|
||||||
@@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
|
|||||||
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<Select<LogLevelOption>
|
||||||
|
value={selectedLogLevel()}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
updateLogLevel(option.value)
|
||||||
|
}}
|
||||||
|
options={logLevelOptions()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t("settings.opencode.logLevel.title")}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<LogLevelOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="settings-card-header">
|
<div class="settings-card-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
|
||||||
|
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
|
||||||
|
|
||||||
|
function deriveSidecarId(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideCarsSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [name, setName] = createSignal("")
|
||||||
|
const [port, setPort] = createSignal("3000")
|
||||||
|
const [insecure, setInsecure] = createSignal(false)
|
||||||
|
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
|
||||||
|
const [busyId, setBusyId] = createSignal<string | null>(null)
|
||||||
|
const [creating, setCreating] = createSignal(false)
|
||||||
|
const [formError, setFormError] = createSignal<string | null>(null)
|
||||||
|
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void ensureSidecarsLoaded()
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const trimmedName = name().trim()
|
||||||
|
const nextPort = Number(port())
|
||||||
|
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
|
||||||
|
setFormError(t("sidecars.form.validation"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
await serverApi.createSidecar({
|
||||||
|
kind: "port",
|
||||||
|
name: trimmedName,
|
||||||
|
port: nextPort,
|
||||||
|
insecure: insecure(),
|
||||||
|
prefixMode: prefixMode(),
|
||||||
|
})
|
||||||
|
setName("")
|
||||||
|
setPort("3000")
|
||||||
|
setInsecure(false)
|
||||||
|
setPrefixMode("strip")
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setBusyId(id)
|
||||||
|
setActionError(null)
|
||||||
|
try {
|
||||||
|
await serverApi.deleteSidecar(id)
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Globe class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card-content">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full max-w-xs"
|
||||||
|
value={name()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setFormError(null)
|
||||||
|
setName(event.currentTarget.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
|
||||||
|
<div class="settings-toggle-caption">127.0.0.1</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full max-w-xs"
|
||||||
|
value={port()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setFormError(null)
|
||||||
|
setPort(event.currentTarget.value)
|
||||||
|
}}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
|
||||||
|
</div>
|
||||||
|
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
|
||||||
|
<option value="https">{t("sidecars.form.protocol.https")}</option>
|
||||||
|
<option value="http">{t("sidecars.form.protocol.http")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
|
||||||
|
</div>
|
||||||
|
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
|
||||||
|
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
|
||||||
|
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={formError()}>
|
||||||
|
<div class="text-sm text-red-500">{formError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
|
||||||
|
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
</Show>
|
||||||
|
<span>{t("sidecars.form.add")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card-content">
|
||||||
|
<Show when={actionError()}>
|
||||||
|
<div class="text-sm text-red-500">{actionError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
|
||||||
|
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
|
||||||
|
<For each={orderedSidecars()}>
|
||||||
|
{(sidecar) => (
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{sidecar.name}</div>
|
||||||
|
<div class="settings-toggle-caption">
|
||||||
|
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||||
|
</div>
|
||||||
|
<div class="settings-toggle-caption">
|
||||||
|
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||||
|
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -334,7 +334,7 @@ const Field: Component<{
|
|||||||
<div class="settings-toggle-title">{props.label}</div>
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
<div class="settings-toggle-caption">{props.caption}</div>
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
|
<div class="flex items-center gap-2 w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
|
||||||
{props.icon}
|
{props.icon}
|
||||||
<input
|
<input
|
||||||
type={props.type ?? "text"}
|
type={props.type ?? "text"}
|
||||||
@@ -361,7 +361,7 @@ const SelectField: Component<{
|
|||||||
<div class="settings-toggle-title">{props.label}</div>
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
<div class="settings-toggle-caption">{props.caption}</div>
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[18rem] max-w-[24rem] w-full">
|
<div class="w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
|
||||||
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
|
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
|
||||||
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
|
||||||
|
import { Globe, Square } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
|
||||||
|
|
||||||
|
interface SideCarPickerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onOpenSidecar: (sidecarId: string) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
void ensureSidecarsLoaded()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||||
|
{t("sidecars.picker.subtitle")}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto flex flex-col gap-3">
|
||||||
|
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
|
||||||
|
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
|
||||||
|
<For each={orderedSidecars()}>
|
||||||
|
{(sidecar) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={sidecar.status !== "running"}
|
||||||
|
onClick={() => void props.onOpenSidecar(sidecar.id)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4 w-full">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="panel-empty-state-icon !w-10 !h-10">
|
||||||
|
<Globe class="w-5 h-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-secondary flex items-center gap-2">
|
||||||
|
<Square class="w-4 h-4" />
|
||||||
|
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||||
|
{t("sidecars.picker.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
packages/ui/src/components/sidecar-view.tsx
Normal file
197
packages/ui/src/components/sidecar-view.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
|
||||||
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import type { SideCarTabRecord } from "../stores/sidecars"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
interface SideCarViewProps {
|
||||||
|
tab: SideCarTabRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideCarView: Component<SideCarViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
|
||||||
|
const [pathInput, setPathInput] = createSignal("/")
|
||||||
|
let iframeRef: HTMLIFrameElement | undefined
|
||||||
|
|
||||||
|
const lockedBaseLabel = createMemo(() => {
|
||||||
|
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
|
||||||
|
if (props.tab.prefixMode === "preserve") {
|
||||||
|
return `${hostLabel}${props.tab.proxyBasePath}`
|
||||||
|
}
|
||||||
|
return hostLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
const getEditablePathFromUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin)
|
||||||
|
const basePath = props.tab.proxyBasePath
|
||||||
|
let pathname = parsed.pathname
|
||||||
|
|
||||||
|
if (basePath && pathname.startsWith(basePath)) {
|
||||||
|
pathname = pathname.slice(basePath.length) || "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathname.startsWith("/")) {
|
||||||
|
pathname = `/${pathname}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${pathname}${parsed.search}${parsed.hash}`
|
||||||
|
} catch {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildNormalizedTargetUrl = (rawInput: string): string => {
|
||||||
|
const trimmed = rawInput.trim()
|
||||||
|
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
|
||||||
|
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
|
||||||
|
|
||||||
|
const safeSegments: string[] = []
|
||||||
|
for (const segment of parsed.pathname.split("/")) {
|
||||||
|
if (!segment || segment === ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (segment === "..") {
|
||||||
|
if (safeSegments.length > 0) {
|
||||||
|
safeSegments.pop()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safeSegments.push(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = `/${safeSegments.join("/")}` || "/"
|
||||||
|
const basePath = props.tab.proxyBasePath
|
||||||
|
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPathInputFromFrame = () => {
|
||||||
|
try {
|
||||||
|
const currentHref = iframeRef?.contentWindow?.location.href
|
||||||
|
if (!currentHref) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPathInput(getEditablePathFromUrl(currentHref))
|
||||||
|
} catch {
|
||||||
|
setPathInput(getEditablePathFromUrl(frameSrc()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setFrameSrc(props.tab.shellUrl)
|
||||||
|
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBack = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frameWindow = iframeRef?.contentWindow
|
||||||
|
if (!frameWindow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameWindow.history.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frameWindow.focus()
|
||||||
|
frameWindow.history.go(-1)
|
||||||
|
} catch {
|
||||||
|
// Ignore navigation errors from pages that do not expose history access.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
try {
|
||||||
|
iframeRef?.contentWindow?.location.reload()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
|
||||||
|
}
|
||||||
|
|
||||||
|
setFrameSrc("about:blank")
|
||||||
|
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGo = (event?: Event) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
|
||||||
|
const nextUrl = buildNormalizedTargetUrl(pathInput())
|
||||||
|
setFrameSrc(nextUrl)
|
||||||
|
setPathInput(getEditablePathFromUrl(nextUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 px-3 py-2"
|
||||||
|
style={{ "border-bottom": "1px solid var(--border-base)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="new-tab-button"
|
||||||
|
onClick={handleBack}
|
||||||
|
title={t("sidecars.back")}
|
||||||
|
aria-label={t("sidecars.back")}
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="new-tab-button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
title={t("sidecars.refresh")}
|
||||||
|
aria-label={t("sidecars.refresh")}
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border-base)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lockedBaseLabel()}
|
||||||
|
</div>
|
||||||
|
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border-base)",
|
||||||
|
}}
|
||||||
|
value={pathInput()}
|
||||||
|
onInput={(event) => setPathInput(event.currentTarget.value)}
|
||||||
|
spellcheck={false}
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={t("sidecars.path")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="new-tab-button"
|
||||||
|
title={t("sidecars.go")}
|
||||||
|
aria-label={t("sidecars.go")}
|
||||||
|
>
|
||||||
|
<ArrowRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={frameSrc()}
|
||||||
|
title={props.tab.name}
|
||||||
|
class="min-h-0 flex-1 w-full border-0 bg-surface"
|
||||||
|
referrerPolicy="same-origin"
|
||||||
|
onLoad={syncPathInputFromFrame}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
||||||
import { stringify as stringifyYaml } from "yaml"
|
import { stringify as stringifyYaml } from "yaml"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
|||||||
|
|
||||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
|
||||||
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
|
||||||
|
|
||||||
function makeRenderCacheKey(
|
function makeRenderCacheKey(
|
||||||
toolCallId?: string | null,
|
toolCallId?: string | null,
|
||||||
@@ -82,6 +81,27 @@ interface ToolCallProps {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ToolStatusIndicator(props: { status: Accessor<string> }) {
|
||||||
|
const isVisible = (value: string) => props.status() === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
|
||||||
|
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
|
||||||
|
<Hourglass class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
|
||||||
|
<XCircle class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToolCallDetails(props: {
|
function ToolCallDetails(props: {
|
||||||
toolCallMemo: () => ToolCallPart
|
toolCallMemo: () => ToolCallPart
|
||||||
toolState: () => ToolState | undefined
|
toolState: () => ToolState | undefined
|
||||||
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
|
|||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
const followScroll = createFollowScroll({
|
||||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||||
|
sentinelClassName: "tool-call-scroll-sentinel",
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
})
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
|
||||||
|
|
||||||
let pendingScrollFrame: number | null = null
|
|
||||||
let pendingAnchorScroll: number | null = null
|
|
||||||
let userScrollIntentUntil = 0
|
|
||||||
let lastKnownScrollTop = props.scrollTopSnapshot()
|
|
||||||
|
|
||||||
function restoreScrollPosition(forceBottom = false) {
|
|
||||||
const container = scrollContainerRef
|
|
||||||
if (!container) return
|
|
||||||
if (forceBottom) {
|
|
||||||
container.scrollTop = container.scrollHeight
|
|
||||||
lastKnownScrollTop = container.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
} else {
|
|
||||||
container.scrollTop = lastKnownScrollTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
|
||||||
if (!element) return
|
|
||||||
lastKnownScrollTop = element.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUserScrollIntent() {
|
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
||||||
return now <= userScrollIntentUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
const handlePointerIntent = () => markUserScrollIntent()
|
|
||||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
|
||||||
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
|
|
||||||
markUserScrollIntent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
|
||||||
element.addEventListener("keydown", handleKeyIntent)
|
|
||||||
detachScrollIntentListeners = () => {
|
|
||||||
element.removeEventListener("wheel", handlePointerIntent)
|
|
||||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
|
||||||
element.removeEventListener("touchstart", handlePointerIntent)
|
|
||||||
element.removeEventListener("keydown", handleKeyIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAnchorScroll(immediate = false) {
|
|
||||||
if (!autoScroll()) return
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
const container = scrollContainerRef
|
|
||||||
if (!sentinel || !container) return
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
|
||||||
const sentinelRect = sentinel.getBoundingClientRect()
|
|
||||||
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
|
|
||||||
if (Math.abs(delta) > 1) {
|
|
||||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
|
||||||
}
|
|
||||||
lastKnownScrollTop = container.scrollTop
|
|
||||||
props.setScrollTopSnapshot(lastKnownScrollTop)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
const container = scrollContainer()
|
|
||||||
if (!container) return
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
}
|
|
||||||
const isUserScroll = hasUserScrollIntent()
|
|
||||||
pendingScrollFrame = requestAnimationFrame(() => {
|
|
||||||
pendingScrollFrame = null
|
|
||||||
const atBottom = bottomSentinelVisible()
|
|
||||||
if (isUserScroll) {
|
|
||||||
if (atBottom) {
|
|
||||||
if (!autoScroll()) setAutoScroll(true)
|
|
||||||
} else if (autoScroll()) {
|
|
||||||
setAutoScroll(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
||||||
handleScroll()
|
|
||||||
persistScrollSnapshot(event.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScrollRendered = () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
restoreScrollPosition(autoScroll())
|
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
|
||||||
const next = element || undefined
|
|
||||||
if (next === scrollContainerRef) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollContainerRef = next
|
|
||||||
setScrollContainer(scrollContainerRef)
|
|
||||||
if (scrollContainerRef) {
|
|
||||||
// Refresh our snapshot on mount (e.g. when remounting after collapse)
|
|
||||||
lastKnownScrollTop = props.scrollTopSnapshot()
|
|
||||||
restoreScrollPosition(autoScroll())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollHelpers: ToolScrollHelpers = {
|
const scrollHelpers: ToolScrollHelpers = {
|
||||||
registerContainer: (element, options) => {
|
registerContainer: (element, options) => {
|
||||||
if (options?.disableTracking) return
|
followScroll.registerContainer(element, options)
|
||||||
initializeScrollContainer(element)
|
|
||||||
},
|
|
||||||
handleScroll: handleScrollEvent,
|
|
||||||
renderSentinel: (options) => {
|
|
||||||
if (options?.disableTracking) return null
|
|
||||||
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
|
||||||
},
|
},
|
||||||
|
handleScroll: followScroll.handleScroll,
|
||||||
|
renderSentinel: followScroll.renderSentinel,
|
||||||
|
restoreAfterRender: followScroll.restoreAfterRender,
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const handleScrollRendered = () => {
|
||||||
const container = scrollContainer()
|
scrollHelpers.restoreAfterRender()
|
||||||
if (!container) return
|
}
|
||||||
attachScrollIntentListeners(container)
|
|
||||||
onCleanup(() => {
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const container = scrollContainer()
|
|
||||||
const sentinel = bottomSentinel()
|
|
||||||
if (!container || !sentinel) return
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.target === sentinel) {
|
|
||||||
setBottomSentinelVisible(entry.isIntersecting)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
|
||||||
)
|
|
||||||
observer.observe(sentinel)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
|
|||||||
partVersion={options.partVersion}
|
partVersion={options.partVersion}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={options.sessionId}
|
sessionId={options.sessionId}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
forceCollapsed={options.forceCollapsed}
|
forceCollapsed={options.forceCollapsed}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollHelpers,
|
scrollHelpers,
|
||||||
|
onContentRendered: props.onContentRendered,
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousPartVersion: number | undefined
|
let previousPartVersion: number | undefined
|
||||||
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousPartVersion = version
|
previousPartVersion = version
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (autoScroll()) {
|
if (followScroll.autoScroll()) {
|
||||||
scheduleAnchorScroll(true)
|
scrollHelpers.restoreAfterRender()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingScrollFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
|
||||||
pendingScrollFrame = null
|
|
||||||
}
|
|
||||||
if (pendingAnchorScroll !== null) {
|
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
|
||||||
pendingAnchorScroll = null
|
|
||||||
}
|
|
||||||
if (detachScrollIntentListeners) {
|
|
||||||
detachScrollIntentListeners()
|
|
||||||
detachScrollIntentListeners = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-details">
|
<div class="tool-call-details">
|
||||||
<Show
|
<Show
|
||||||
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return !current
|
return !current
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
|
||||||
const status = toolState()?.status || ""
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return <Hourglass class="w-4 h-4" />
|
|
||||||
case "running":
|
|
||||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
|
||||||
case "completed":
|
|
||||||
return <Check class="w-4 h-4" />
|
|
||||||
case "error":
|
|
||||||
return <XCircle class="w-4 h-4" />
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = () => {
|
const statusClass = () => {
|
||||||
const status = toolState()?.status || "pending"
|
const status = toolState()?.status || "pending"
|
||||||
return `tool-call-status-${status}`
|
return `tool-call-status-${status}`
|
||||||
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<span class="tool-call-header-status" aria-hidden="true">
|
<ToolStatusIndicator status={status} />
|
||||||
{statusIcon()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/text-render-utils"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
@@ -11,6 +11,97 @@ type CacheHandle = {
|
|||||||
set(value: unknown): void
|
set(value: unknown): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StableAnsiStreamUpdater {
|
||||||
|
update: (element: HTMLElement, content: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
|
||||||
|
const renderer = createAnsiStreamRenderer()
|
||||||
|
let previousContent = ""
|
||||||
|
let ansiActive = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(element: HTMLElement, content: string) {
|
||||||
|
const resetStreaming = !previousContent || !content.startsWith(previousContent)
|
||||||
|
|
||||||
|
if (resetStreaming) {
|
||||||
|
ansiActive = hasAnsi(content)
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = content.slice(previousContent.length)
|
||||||
|
if (delta.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ansiActive && hasAnsi(delta)) {
|
||||||
|
ansiActive = true
|
||||||
|
renderer.reset()
|
||||||
|
element.innerHTML = renderer.render(content)
|
||||||
|
previousContent = content
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ansiActive) {
|
||||||
|
const htmlChunk = renderer.render(delta)
|
||||||
|
if (htmlChunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", htmlChunk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const escapedDelta = escapeHtml(delta)
|
||||||
|
if (escapedDelta.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", escapedDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousContent = content
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
previousContent = ""
|
||||||
|
ansiActive = false
|
||||||
|
renderer.reset()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingAnsiContent(props: {
|
||||||
|
html: string
|
||||||
|
htmlChunk?: string
|
||||||
|
updateMode: "replace" | "append" | "noop"
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
if (props.updateMode === "noop") return
|
||||||
|
if (props.updateMode === "append") {
|
||||||
|
if (element.innerHTML.length === 0) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunk = props.htmlChunk ?? ""
|
||||||
|
if (chunk.length > 0) {
|
||||||
|
element.insertAdjacentHTML("beforeend", chunk)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (element.innerHTML !== props.html) {
|
||||||
|
element.innerHTML = props.html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
}
|
||||||
|
|
||||||
export function createAnsiContentRenderer(params: {
|
export function createAnsiContentRenderer(params: {
|
||||||
ansiRunningCache: CacheHandle
|
ansiRunningCache: CacheHandle
|
||||||
ansiFinalCache: CacheHandle
|
ansiFinalCache: CacheHandle
|
||||||
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const isRunningVariant = options.variant === "running"
|
const isRunningVariant = options.variant === "running"
|
||||||
const disableScrollTracking = !isRunningVariant
|
const disableScrollTracking = !isRunningVariant
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
let updateMode: "replace" | "append" | "noop" = "replace"
|
||||||
|
let htmlChunk = ""
|
||||||
|
|
||||||
let nextCache: AnsiRenderCache
|
let nextCache: AnsiRenderCache
|
||||||
|
|
||||||
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||||
|
|
||||||
if (resetStreaming) {
|
if (resetStreaming) {
|
||||||
|
updateMode = "replace"
|
||||||
const detectedAnsi = hasAnsi(content)
|
const detectedAnsi = hasAnsi(content)
|
||||||
if (detectedAnsi) {
|
if (detectedAnsi) {
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
|
|||||||
} else {
|
} else {
|
||||||
const delta = content.slice(cached.text.length)
|
const delta = content.slice(cached.text.length)
|
||||||
if (delta.length === 0) {
|
if (delta.length === 0) {
|
||||||
|
updateMode = "noop"
|
||||||
nextCache = { ...cached, mode }
|
nextCache = { ...cached, mode }
|
||||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||||
|
updateMode = "replace"
|
||||||
runningAnsiRenderer.reset()
|
runningAnsiRenderer.reset()
|
||||||
const html = runningAnsiRenderer.render(content)
|
const html = runningAnsiRenderer.render(content)
|
||||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||||
} else if (cached.hasAnsi) {
|
} else if (cached.hasAnsi) {
|
||||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
const appendedHtml = runningAnsiRenderer.render(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
updateMode = "append"
|
||||||
|
htmlChunk = appendedHtml
|
||||||
|
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
|
||||||
} else {
|
} else {
|
||||||
|
updateMode = "append"
|
||||||
|
htmlChunk = escapeHtml(delta)
|
||||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
params.handleScrollRendered()
|
||||||
params.handleScrollRendered()
|
|
||||||
}
|
|
||||||
params.onContentRendered?.()
|
params.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,107 @@
|
|||||||
import type { ToolRenderer } from "../types"
|
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { ToolRenderer, ToolScrollHelpers } from "../types"
|
||||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||||
import { tGlobal } from "../../../lib/i18n"
|
import { tGlobal } from "../../../lib/i18n"
|
||||||
|
import { createStableAnsiStreamUpdater } from "../ansi-render"
|
||||||
|
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
|
||||||
|
|
||||||
|
function RunningBashOutput(props: {
|
||||||
|
content: Accessor<string>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
|
const updater = createStableAnsiStreamUpdater()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = preRef
|
||||||
|
if (!element) return
|
||||||
|
updater.update(element, props.content())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
preRef = undefined
|
||||||
|
updater.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="message-text tool-call-markdown"
|
||||||
|
ref={props.scrollHelpers?.registerContainer}
|
||||||
|
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||||
|
>
|
||||||
|
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||||
|
{props.scrollHelpers?.renderSentinel?.()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BashToolBody(props: {
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
|
||||||
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
}) {
|
||||||
|
const state = createMemo(() => props.toolState())
|
||||||
|
|
||||||
|
const joinedContent = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
if (!current || current.status === "pending") return ""
|
||||||
|
|
||||||
|
const { input, metadata } = readToolStatePayload(current)
|
||||||
|
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||||
|
const outputResult = formatUnknown(
|
||||||
|
isToolStateCompleted(current)
|
||||||
|
? current.output
|
||||||
|
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
|
||||||
|
? metadata.output
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
return [command, outputResult?.text].filter(Boolean).join("\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalMarkdown = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ensureMarkdownContent(content, "bash", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalAnsiHtml = createMemo(() => {
|
||||||
|
const current = state()
|
||||||
|
const content = joinedContent()
|
||||||
|
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!hasAnsi(content)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ansiToHtml(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={state() && joinedContent().length > 0}>
|
||||||
|
<Show
|
||||||
|
when={state()?.status === "running"}
|
||||||
|
fallback={
|
||||||
|
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
|
||||||
|
{(html) => (
|
||||||
|
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
|
||||||
|
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const bashRenderer: ToolRenderer = {
|
export const bashRenderer: ToolRenderer = {
|
||||||
tools: ["bash"],
|
tools: ["bash"],
|
||||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
|||||||
const timeoutLabel = `${timeout}ms`
|
const timeoutLabel = `${timeout}ms`
|
||||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||||
const state = toolState()
|
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||||
if (!state || state.status === "pending") return null
|
|
||||||
|
|
||||||
const { input, metadata } = readToolStatePayload(state)
|
|
||||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
|
||||||
const outputResult = formatUnknown(
|
|
||||||
isToolStateCompleted(state)
|
|
||||||
? state.output
|
|
||||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
|
||||||
? metadata.output
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
const parts = [command, outputResult?.text].filter(Boolean)
|
|
||||||
if (parts.length === 0) return null
|
|
||||||
|
|
||||||
const joined = parts.join("\n")
|
|
||||||
if (state.status === "running") {
|
|
||||||
return renderAnsi({ content: joined, variant: "running" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
|
||||||
if (ansiBody) {
|
|
||||||
return ansiBody
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = ensureMarkdownContent(joined, "bash", true)
|
|
||||||
if (!content) return null
|
|
||||||
|
|
||||||
return renderMarkdown({ content })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
return describeTaskTitle(input)
|
||||||
},
|
},
|
||||||
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
|
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
|
||||||
|
|
||||||
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const childCount = childToolKeys().length
|
||||||
|
const legacyCount = legacyItems().length
|
||||||
|
if (childCount === 0 && legacyCount === 0) return
|
||||||
|
scrollHelpers?.restoreAfterRender()
|
||||||
|
onContentRendered?.()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-task-sections">
|
<div class="tool-call-task-sections">
|
||||||
<Show when={promptContent()}>
|
<Show when={promptContent()}>
|
||||||
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={childToolKeys()}>
|
<Index each={childToolKeys()}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<Show when={renderToolCall}>
|
<Show when={renderToolCall}>
|
||||||
{(render) => (
|
{(render) => (
|
||||||
<TaskToolCallRow
|
<TaskToolCallRow
|
||||||
toolKey={key}
|
toolKey={key()}
|
||||||
store={store}
|
store={store}
|
||||||
sessionId={childSessionId()}
|
sessionId={childSessionId()}
|
||||||
renderToolCall={render()}
|
renderToolCall={render()}
|
||||||
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
|
|||||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||||
|
restoreAfterRender(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRendererContext {
|
export interface ToolRendererContext {
|
||||||
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
|
|||||||
forceCollapsed?: boolean
|
forceCollapsed?: boolean
|
||||||
}) => JSXElement | null
|
}) => JSXElement | null
|
||||||
scrollHelpers?: ToolScrollHelpers
|
scrollHelpers?: ToolScrollHelpers
|
||||||
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRenderer {
|
export interface ToolRenderer {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
|
|||||||
mode?: "mention" | "command"
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onSubmitWithoutSelection?: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
commands?: SDKCommand[]
|
commands?: SDKCommand[]
|
||||||
instanceClient: OpencodeClient | null
|
instanceClient: OpencodeClient | null
|
||||||
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||||
props.onSelect(selected, action)
|
props.onSelect(selected, action)
|
||||||
|
} else if (e.key === "Enter" && mode() === "mention") {
|
||||||
|
props.onSubmitWithoutSelection?.()
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
|
|||||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
@@ -85,6 +86,28 @@ export interface VirtualFollowListProps<T> {
|
|||||||
*/
|
*/
|
||||||
followToken?: Accessor<string | number>
|
followToken?: Accessor<string | number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional item key whose geometry can temporarily hold auto-follow when the
|
||||||
|
* rendered item grows taller than the viewport and reaches the top edge.
|
||||||
|
*/
|
||||||
|
autoPinHoldTargetKey?: Accessor<string | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional resolver for the specific element inside an item wrapper that
|
||||||
|
* should be measured for hold-target geometry.
|
||||||
|
*/
|
||||||
|
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-edge threshold for the hold target in pixels.
|
||||||
|
*/
|
||||||
|
autoPinHoldTopThresholdPx?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
|
||||||
|
*/
|
||||||
|
suspendAutoPinToBottom?: Accessor<boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional hooks to render content inside the scroll container.
|
* Optional hooks to render content inside the scroll container.
|
||||||
* Useful for empty/loading states that should scroll with the list.
|
* Useful for empty/loading states that should scroll with the list.
|
||||||
@@ -130,13 +153,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||||
|
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
|
||||||
|
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
|
||||||
|
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
|
||||||
|
|
||||||
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||||
|
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
|
||||||
|
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
|
||||||
|
|
||||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||||
|
const itemElements = new Map<string, HTMLDivElement>()
|
||||||
|
|
||||||
let userScrollIntentUntil = 0
|
let userScrollIntentUntil = 0
|
||||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||||
@@ -144,6 +173,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
let lastResetKey: string | number | undefined
|
let lastResetKey: string | number | undefined
|
||||||
let suppressAutoScrollOnce = false
|
let suppressAutoScrollOnce = false
|
||||||
let pendingInitialScroll = true
|
let pendingInitialScroll = true
|
||||||
|
let lastObservedScrollOffset = 0
|
||||||
|
let lastObservedPinnedAtBottom = false
|
||||||
|
|
||||||
const state: VirtualFollowListState = {
|
const state: VirtualFollowListState = {
|
||||||
autoScroll,
|
autoScroll,
|
||||||
@@ -209,23 +240,42 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
if (!handle || !element) return
|
if (!handle || !element) return
|
||||||
|
|
||||||
const offset = handle.scrollOffset
|
const offset = handle.scrollOffset
|
||||||
|
const scrolledUp = offset < lastObservedScrollOffset - 1
|
||||||
|
const wasPinnedAtBottom = lastObservedPinnedAtBottom
|
||||||
const scrollHeight = handle.scrollSize
|
const scrollHeight = handle.scrollSize
|
||||||
const clientHeight = element.clientHeight
|
const clientHeight = element.clientHeight
|
||||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||||
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||||
|
lastObservedScrollOffset = offset
|
||||||
|
|
||||||
const hasItems = props.items().length > 0
|
const hasItems = props.items().length > 0
|
||||||
setShowScrollBottomButton(hasItems && !atBottom)
|
setShowScrollBottomButton(hasItems && !atBottom)
|
||||||
setShowScrollTopButton(hasItems && !atTop)
|
setShowScrollTopButton(hasItems && !atTop)
|
||||||
|
|
||||||
|
// Keyboard/PageUp scrolls can move the viewport without ever hitting our
|
||||||
|
// local key intent listeners (for example after dragging the native
|
||||||
|
// scrollbar). If follow mode stays enabled, the next render notification
|
||||||
|
// snaps the list straight back to bottom. A real upward viewport move away
|
||||||
|
// from bottom should always break follow unless a hold target is active.
|
||||||
|
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
lastObservedPinnedAtBottom = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
if (hasUserScrollIntent()) {
|
if (hasUserScrollIntent()) {
|
||||||
|
if (atBottom && heldItemCount() !== null) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
}
|
||||||
if (atBottom && !autoScroll()) {
|
if (atBottom && !autoScroll()) {
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
} else if (!atBottom && autoScroll()) {
|
} else if (!atBottom && autoScroll()) {
|
||||||
setAutoScroll(false)
|
setAutoScroll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastObservedPinnedAtBottom = autoScroll() && atBottom
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
@@ -253,6 +303,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateScrollButtons()
|
updateScrollButtons()
|
||||||
|
updateAutoPinHold()
|
||||||
props.onScroll?.()
|
props.onScroll?.()
|
||||||
|
|
||||||
// Find active key (roughly the first visible item)
|
// Find active key (roughly the first visible item)
|
||||||
@@ -270,6 +321,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
|
||||||
|
if (!element) {
|
||||||
|
itemElements.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemElements.set(key, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnchorIdForKey(key: string) {
|
||||||
|
return props.getAnchorId ? props.getAnchorId(key) : key
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAutoPinHold() {
|
||||||
|
const element = scrollElement()
|
||||||
|
const itemCount = props.items().length
|
||||||
|
const heldCount = heldItemCount()
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
if (heldCount !== null) {
|
||||||
|
if (itemCount > heldCount) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
if (autoScroll()) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
scrollToBottom(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemCount < heldCount) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoScroll()) return
|
||||||
|
if (externalSuspendAutoPinToBottom()) return
|
||||||
|
|
||||||
|
const targetKey = holdTargetKey()
|
||||||
|
if (!targetKey) return
|
||||||
|
|
||||||
|
const itemWrapper = itemElements.get(targetKey)
|
||||||
|
if (!itemWrapper) return
|
||||||
|
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
|
||||||
|
|
||||||
|
const containerRect = element.getBoundingClientRect()
|
||||||
|
const targetRect = target.getBoundingClientRect()
|
||||||
|
const relativeTop = targetRect.top - containerRect.top
|
||||||
|
const exceedsViewport = targetRect.height > element.clientHeight
|
||||||
|
|
||||||
|
if (exceedsViewport && relativeTop < 0) {
|
||||||
|
const alignDelta = relativeTop - holdTargetTopThresholdPx()
|
||||||
|
if (Math.abs(alignDelta) > 1) {
|
||||||
|
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
|
||||||
|
}
|
||||||
|
setHeldItemCount(itemCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const api: VirtualFollowListApi = {
|
const api: VirtualFollowListApi = {
|
||||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||||
@@ -281,7 +394,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||||
},
|
},
|
||||||
notifyContentRendered: () => {
|
notifyContentRendered: () => {
|
||||||
if (autoScroll()) {
|
updateAutoPinHold()
|
||||||
|
if (heldItemCount() !== null) return
|
||||||
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -294,9 +409,17 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
createEffect(() => props.registerApi?.(api))
|
createEffect(() => props.registerApi?.(api))
|
||||||
createEffect(() => props.registerState?.(state))
|
createEffect(() => props.registerState?.(state))
|
||||||
|
|
||||||
|
createEffect(on(() => props.resetKey?.(), () => {
|
||||||
|
itemElements.clear()
|
||||||
|
setHeldItemCount(null)
|
||||||
|
lastObservedScrollOffset = 0
|
||||||
|
lastObservedPinnedAtBottom = false
|
||||||
|
}))
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
// Handle autoScroll (Follow) on items change
|
||||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
updateAutoPinHold()
|
||||||
|
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
||||||
requestAnimationFrame(() => scrollToBottom(true))
|
requestAnimationFrame(() => scrollToBottom(true))
|
||||||
}
|
}
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
@@ -304,11 +427,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
// Handle followToken change
|
// Handle followToken change
|
||||||
createEffect(on(() => props.followToken?.(), () => {
|
createEffect(on(() => props.followToken?.(), () => {
|
||||||
if (autoScroll()) {
|
updateAutoPinHold()
|
||||||
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
}, { defer: true }))
|
}, { defer: true }))
|
||||||
|
|
||||||
|
createEffect(on(() => holdTargetKey(), () => {
|
||||||
|
updateAutoPinHold()
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
// Reset state on resetKey change
|
// Reset state on resetKey change
|
||||||
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||||
if (nextKey === lastResetKey) return
|
if (nextKey === lastResetKey) return
|
||||||
@@ -331,6 +459,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handleResize = () => updateAutoPinHold()
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||||
setShellElement(shellElement)
|
setShellElement(shellElement)
|
||||||
@@ -356,7 +491,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
bufferSize={props.overscanPx ?? 400}
|
bufferSize={props.overscanPx ?? 400}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{(item, index) => props.renderItem(item, index())}
|
{(item, index) => {
|
||||||
|
const key = props.getKey(item, index())
|
||||||
|
const anchorId = getAnchorIdForKey(key)
|
||||||
|
return (
|
||||||
|
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
|
||||||
|
{props.renderItem(item, index())}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ type WorktreeOption =
|
|||||||
| { kind: "action"; key: "__create__"; label: string }
|
| { kind: "action"; key: "__create__"; label: string }
|
||||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||||
|
|
||||||
|
type DeleteErrorKind = "localChanges" | "inUse" | "notFound" | "permissionDenied" | "unknown"
|
||||||
|
|
||||||
|
type DeleteErrorDetails = {
|
||||||
|
summary: string
|
||||||
|
causeLabel: string
|
||||||
|
nextStep: string
|
||||||
|
}
|
||||||
|
|
||||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||||
// Prevent Select.Item from treating this as a selection.
|
// Prevent Select.Item from treating this as a selection.
|
||||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||||
@@ -64,6 +72,57 @@ function relativePath(fromDir: string, toDir: string): string {
|
|||||||
return relParts.join("/") || "."
|
return relParts.join("/") || "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDeleteErrorMessage(input: string): string {
|
||||||
|
const trimmed = (input ?? "").trim()
|
||||||
|
if (!trimmed) return ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as { error?: unknown }
|
||||||
|
if (typeof parsed?.error === "string" && parsed.error.trim()) {
|
||||||
|
return parsed.error.trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the raw string when the backend returned plain text.
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyDeleteError(message: string): DeleteErrorKind {
|
||||||
|
const normalized = message.toLowerCase()
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("modified or untracked files") ||
|
||||||
|
normalized.includes("contains modified") ||
|
||||||
|
normalized.includes("contains untracked") ||
|
||||||
|
normalized.includes("use --force to delete it")
|
||||||
|
) {
|
||||||
|
return "localChanges"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("in use") ||
|
||||||
|
normalized.includes("resource busy") ||
|
||||||
|
normalized.includes("device or resource busy") ||
|
||||||
|
normalized.includes("ebusy") ||
|
||||||
|
normalized.includes("file is being used") ||
|
||||||
|
normalized.includes("process cannot access the file") ||
|
||||||
|
normalized.includes("directory not empty")
|
||||||
|
) {
|
||||||
|
return "inUse"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("not found") || normalized.includes("no such file") || normalized.includes("cannot find")) {
|
||||||
|
return "notFound"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("permission denied") || normalized.includes("access is denied") || normalized.includes("eperm")) {
|
||||||
|
return "permissionDenied"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
interface WorktreeSelectorProps {
|
interface WorktreeSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -80,6 +139,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||||
const [forceDelete, setForceDelete] = createSignal(false)
|
const [forceDelete, setForceDelete] = createSignal(false)
|
||||||
const [isDeleting, setIsDeleting] = createSignal(false)
|
const [isDeleting, setIsDeleting] = createSignal(false)
|
||||||
|
const [deleteError, setDeleteError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||||
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||||
@@ -114,10 +174,16 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||||
if (opt.slug === "root") return
|
if (opt.slug === "root") return
|
||||||
setForceDelete(false)
|
setForceDelete(false)
|
||||||
|
setDeleteError(null)
|
||||||
setDeleteTarget(opt)
|
setDeleteTarget(opt)
|
||||||
setDeleteOpen(true)
|
setDeleteOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeDeleteDialog = () => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
setDeleteError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const repoRoot = createMemo(() => {
|
const repoRoot = createMemo(() => {
|
||||||
const list = getWorktrees(props.instanceId)
|
const list = getWorktrees(props.instanceId)
|
||||||
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
||||||
@@ -139,6 +205,89 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeDeleteError = (input: string) => {
|
||||||
|
let sanitized = (input ?? "").trim()
|
||||||
|
if (!sanitized) {
|
||||||
|
return t("instanceShell.worktree.delete.error.fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/[A-Za-z]:[\\/][^\r\n"']+/g, "[path]")
|
||||||
|
sanitized = sanitized.replace(/\\Users\\[^\\/\r\n]+/gi, "\\Users\\[user]")
|
||||||
|
sanitized = sanitized.replace(/\/Users\/[^/\r\n]+/g, "/Users/[user]")
|
||||||
|
sanitized = sanitized.replace(/\/home\/[^/\r\n]+/g, "/home/[user]")
|
||||||
|
sanitized = sanitized.replace(/([A-Za-z]:[\\/])?Users[\\/][^\\/\r\n]+/gi, "$1Users/[user]")
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyDeleteError = async (mode: "raw" | "sanitized") => {
|
||||||
|
const raw = deleteError()
|
||||||
|
if (!raw) return
|
||||||
|
const text = mode === "sanitized" ? sanitizeDeleteError(raw) : raw
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await copyToClipboard(text)
|
||||||
|
showToastNotification({
|
||||||
|
message: ok
|
||||||
|
? t(mode === "sanitized" ? "instanceShell.worktree.delete.error.copySanitizedSuccess" : "instanceShell.worktree.delete.error.copySuccess")
|
||||||
|
: t("instanceShell.worktree.delete.error.copyFailure"),
|
||||||
|
variant: ok ? "success" : "error",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to copy delete worktree error", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: t("instanceShell.worktree.delete.error.copyFailure"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteErrorDetails = createMemo<DeleteErrorDetails | null>(() => {
|
||||||
|
const raw = deleteError()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const parsed = extractDeleteErrorMessage(raw)
|
||||||
|
const kind = classifyDeleteError(parsed)
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case "localChanges":
|
||||||
|
return {
|
||||||
|
summary: t("instanceShell.worktree.delete.error.summary.localChanges"),
|
||||||
|
causeLabel: t("instanceShell.worktree.delete.error.cause.localChanges"),
|
||||||
|
nextStep: t("instanceShell.worktree.delete.error.nextStep.localChanges"),
|
||||||
|
}
|
||||||
|
case "inUse":
|
||||||
|
return {
|
||||||
|
summary: t("instanceShell.worktree.delete.error.summary.inUse"),
|
||||||
|
causeLabel: t("instanceShell.worktree.delete.error.cause.inUse"),
|
||||||
|
nextStep: t("instanceShell.worktree.delete.error.nextStep.inUse"),
|
||||||
|
}
|
||||||
|
case "notFound":
|
||||||
|
return {
|
||||||
|
summary: t("instanceShell.worktree.delete.error.summary.notFound"),
|
||||||
|
causeLabel: t("instanceShell.worktree.delete.error.cause.notFound"),
|
||||||
|
nextStep: t("instanceShell.worktree.delete.error.nextStep.notFound"),
|
||||||
|
}
|
||||||
|
case "permissionDenied":
|
||||||
|
return {
|
||||||
|
summary: t("instanceShell.worktree.delete.error.summary.permissionDenied"),
|
||||||
|
causeLabel: t("instanceShell.worktree.delete.error.cause.permissionDenied"),
|
||||||
|
nextStep: t("instanceShell.worktree.delete.error.nextStep.permissionDenied"),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
summary: t("instanceShell.worktree.delete.error.summary.unknown"),
|
||||||
|
causeLabel: t("instanceShell.worktree.delete.error.cause.unknown"),
|
||||||
|
nextStep: t("instanceShell.worktree.delete.error.nextStep.unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayDeleteError = createMemo(() => {
|
||||||
|
const raw = deleteError()
|
||||||
|
if (!raw) return null
|
||||||
|
return extractDeleteErrorMessage(raw)
|
||||||
|
})
|
||||||
|
|
||||||
const handleChange = async (value: WorktreeOption | null) => {
|
const handleChange = async (value: WorktreeOption | null) => {
|
||||||
if (worktreesUnavailable()) return
|
if (worktreesUnavailable()) return
|
||||||
if (!value) return
|
if (!value) return
|
||||||
@@ -343,22 +492,23 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && closeDeleteDialog()}>
|
||||||
<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-3 md:p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
<Dialog.Content class="modal-surface w-[clamp(640px,45vw,960px)] max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] overflow-y-auto p-4 flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
<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>
|
<Dialog.Description class="text-sm text-secondary mt-1">Deletes this branch worktree and its local folder.</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={deleteTarget()}>
|
<Show when={deleteTarget()}>
|
||||||
{(target) => (
|
{(target) => (
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<div class="rounded-lg border border-base bg-surface-secondary px-3 py-2">
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
<p class="text-sm text-primary">
|
||||||
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
Worktree <span class="font-semibold font-mono">"{target().slug}"</span>
|
||||||
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
</p>
|
||||||
|
<p class="text-[11px] text-secondary break-all font-mono leading-5">{target().directory}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -377,7 +527,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={() => setDeleteOpen(false)}
|
onClick={closeDeleteDialog}
|
||||||
disabled={isDeleting()}
|
disabled={isDeleting()}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -389,12 +539,13 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const target = deleteTarget()
|
const target = deleteTarget()
|
||||||
if (!target) {
|
if (!target) {
|
||||||
setDeleteOpen(false)
|
closeDeleteDialog()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
|
setDeleteError(null)
|
||||||
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||||
await reloadWorktrees(props.instanceId)
|
await reloadWorktrees(props.instanceId)
|
||||||
await reloadWorktreeMap(props.instanceId)
|
await reloadWorktreeMap(props.instanceId)
|
||||||
@@ -403,15 +554,12 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeleteOpen(false)
|
closeDeleteDialog()
|
||||||
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||||
})()
|
})()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
log.warn("Failed to delete worktree", error)
|
log.warn("Failed to delete worktree", error)
|
||||||
showToastNotification({
|
setDeleteError(error instanceof Error ? error.message : t("instanceShell.worktree.delete.error.fallback"))
|
||||||
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -421,6 +569,56 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
{isDeleting() ? "Deleting..." : "Delete"}
|
{isDeleting() ? "Deleting..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={displayDeleteError()}>
|
||||||
|
{(message) => (
|
||||||
|
<div class="rounded-lg border border-danger bg-danger/10 p-3 flex flex-col gap-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<p class="text-xs font-medium text-danger uppercase tracking-wide">
|
||||||
|
{t("instanceShell.worktree.delete.error.title")}
|
||||||
|
</p>
|
||||||
|
<Show when={deleteErrorDetails()}>
|
||||||
|
{(details) => (
|
||||||
|
<>
|
||||||
|
<p class="text-sm text-primary font-medium">{details().summary}</p>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.causeLabel")}</span>{" "}
|
||||||
|
{details().causeLabel}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.nextStepLabel")}</span>{" "}
|
||||||
|
{details().nextStep}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre class="max-h-[40vh] overflow-auto whitespace-pre-wrap break-all rounded border border-danger/30 bg-surface-primary px-3 py-2 text-xs text-primary select-text leading-5">{message()}</pre>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyDeleteError("raw")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("instanceShell.worktree.delete.error.copyRaw")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyDeleteError("sanitized")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("instanceShell.worktree.delete.error.copySanitized")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ import type {
|
|||||||
SpeechCapabilitiesResponse,
|
SpeechCapabilitiesResponse,
|
||||||
SpeechSynthesisResponse,
|
SpeechSynthesisResponse,
|
||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
|
SideCar,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
RemoteServerProbeRequest,
|
||||||
|
RemoteServerProbeResponse,
|
||||||
VoiceModeStateResponse,
|
VoiceModeStateResponse,
|
||||||
|
WorktreeGitCommitRequest,
|
||||||
|
WorktreeGitCommitResponse,
|
||||||
|
WorktreeGitDiffRequest,
|
||||||
|
WorktreeGitMutationResponse,
|
||||||
|
WorktreeGitPathsRequest,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
WorkspaceFileResponse,
|
WorkspaceFileResponse,
|
||||||
@@ -23,6 +31,8 @@ import type {
|
|||||||
WorktreeListResponse,
|
WorktreeListResponse,
|
||||||
WorktreeMap,
|
WorktreeMap,
|
||||||
WorktreeCreateRequest,
|
WorktreeCreateRequest,
|
||||||
|
WorktreeGitDiffResponse,
|
||||||
|
WorktreeGitStatusResponse,
|
||||||
} from "../../../server/src/api-types"
|
} from "../../../server/src/api-types"
|
||||||
import { getClientIdentity } from "./client-identity"
|
import { getClientIdentity } from "./client-identity"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
@@ -95,6 +105,25 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
|||||||
httpLogger.info(message)
|
httpLogger.info(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response): Promise<string> {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) return `Request failed with ${response.status}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }
|
||||||
|
if (typeof parsed?.error === "string" && parsed.error.trim()) {
|
||||||
|
return parsed.error
|
||||||
|
}
|
||||||
|
if (typeof parsed?.message === "string" && parsed.message.trim()) {
|
||||||
|
return parsed.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the original body for plain-text responses.
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||||
const headers = normalizeHeaders(init?.headers)
|
const headers = normalizeHeaders(init?.headers)
|
||||||
@@ -109,7 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text()
|
const message = await readErrorMessage(response)
|
||||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
}
|
}
|
||||||
@@ -138,7 +167,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
|
|||||||
|
|
||||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text()
|
const message = await readErrorMessage(response)
|
||||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
}
|
}
|
||||||
@@ -191,9 +220,42 @@ export const serverApi = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
fetchSidecars(): Promise<{ sidecars: SideCar[] }> {
|
||||||
|
return request<{ sidecars: SideCar[] }>("/api/sidecars")
|
||||||
|
},
|
||||||
|
createSidecar(payload: {
|
||||||
|
kind: "port"
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: "strip" | "preserve"
|
||||||
|
}): Promise<SideCar> {
|
||||||
|
return request<SideCar>("/api/sidecars", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateSidecar(
|
||||||
|
id: string,
|
||||||
|
payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>,
|
||||||
|
): Promise<SideCar> {
|
||||||
|
return request<SideCar>(`/api/sidecars/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteSidecar(id: string): Promise<void> {
|
||||||
|
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
fetchServerMeta(): Promise<ServerMeta> {
|
fetchServerMeta(): Promise<ServerMeta> {
|
||||||
return request<ServerMeta>("/api/meta")
|
return request<ServerMeta>("/api/meta")
|
||||||
},
|
},
|
||||||
|
probeRemoteServer(payload: RemoteServerProbeRequest): Promise<RemoteServerProbeResponse> {
|
||||||
|
return request<RemoteServerProbeResponse>("/api/remote-servers/probe", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||||
},
|
},
|
||||||
@@ -246,6 +308,47 @@ export const serverApi = {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
fetchWorktreeGitStatus(id: string, slug: string): Promise<WorktreeGitStatusResponse> {
|
||||||
|
return request<WorktreeGitStatusResponse>(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise<WorktreeGitDiffResponse> {
|
||||||
|
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
|
||||||
|
if (requestPayload.originalPath) {
|
||||||
|
params.set("originalPath", requestPayload.originalPath)
|
||||||
|
}
|
||||||
|
return request<WorktreeGitDiffResponse>(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||||
|
return request<WorktreeGitMutationResponse>(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||||
|
return request<WorktreeGitMutationResponse>(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise<WorktreeGitCommitResponse> {
|
||||||
|
return request<WorktreeGitCommitResponse>(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||||
@@ -430,4 +533,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string
|
|||||||
return `${url.pathname}${url.search}`
|
return `${url.pathname}${url.search}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
|
|||||||
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
|
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
|
||||||
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
|
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
|
||||||
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
|
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
|
||||||
|
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
||||||
|
|
||||||
function stripCodeFence(value: string): string {
|
function stripCodeFence(value: string): string {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
|
|||||||
if (!normalized) return false
|
if (!normalized) return false
|
||||||
return HUNK_PATTERN.test(normalized)
|
return HUNK_PATTERN.test(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } {
|
||||||
|
if (!patch || patch.trim().length === 0) {
|
||||||
|
return { before: "", after: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = patch.replace(/\r\n/g, "\n").split("\n")
|
||||||
|
const beforeLines: string[] = []
|
||||||
|
const afterLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (HUNK_HEADER_PATTERN.test(line)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith("-") && !line.startsWith("---")) {
|
||||||
|
beforeLines.push(line.slice(1))
|
||||||
|
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||||
|
afterLines.push(line.slice(1))
|
||||||
|
} else if (line.startsWith(" ")) {
|
||||||
|
beforeLines.push(line.slice(1))
|
||||||
|
afterLines.push(line.slice(1))
|
||||||
|
} else if (line === "") {
|
||||||
|
beforeLines.push("")
|
||||||
|
afterLines.push("")
|
||||||
|
} else {
|
||||||
|
beforeLines.push(line)
|
||||||
|
afterLines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") {
|
||||||
|
beforeLines.pop()
|
||||||
|
}
|
||||||
|
while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") {
|
||||||
|
afterLines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: beforeLines.join("\n"),
|
||||||
|
after: afterLines.join("\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
262
packages/ui/src/lib/follow-scroll.tsx
Normal file
262
packages/ui/src/lib/follow-scroll.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||||
|
|
||||||
|
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
|
interface FollowScrollOptions {
|
||||||
|
getScrollTopSnapshot: Accessor<number>
|
||||||
|
setScrollTopSnapshot: (next: number) => void
|
||||||
|
sentinelMarginPx: number
|
||||||
|
sentinelClassName: string
|
||||||
|
intentWindowMs?: number
|
||||||
|
intentKeys?: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowScrollHelpers {
|
||||||
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||||
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||||
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||||
|
restoreAfterRender: () => void
|
||||||
|
autoScroll: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
||||||
|
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||||
|
|
||||||
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
|
||||||
|
let pendingScrollFrame: number | null = null
|
||||||
|
let pendingAnchorScroll: number | null = null
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
let pointerInteractionActive = false
|
||||||
|
let suppressNextScrollHandling = false
|
||||||
|
|
||||||
|
function restoreScrollPosition(forceBottom = false) {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!container) return
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
if (forceBottom) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
} else {
|
||||||
|
container.scrollTop = lastKnownScrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistScrollSnapshot(element?: HTMLElement | null) {
|
||||||
|
if (!element) return
|
||||||
|
lastKnownScrollTop = element.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
if (pointerInteractionActive) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||||
|
const handlePointerIntent = () => {
|
||||||
|
pointerInteractionActive = true
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
const clearPointerIntent = () => {
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (intentKeys.has(event.key)) {
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.addEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.addEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.addEventListener("touchend", clearPointerIntent)
|
||||||
|
window.addEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handlePointerIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("pointerup", clearPointerIntent)
|
||||||
|
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||||
|
window.removeEventListener("mouseup", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchend", clearPointerIntent)
|
||||||
|
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||||
|
}
|
||||||
|
pointerInteractionActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAnchorScroll(immediate = false) {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (!sentinel || !container) return
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const sentinelRect = sentinel.getBoundingClientRect()
|
||||||
|
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
||||||
|
if (Math.abs(delta) > 1) {
|
||||||
|
suppressNextScrollHandling = true
|
||||||
|
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||||
|
}
|
||||||
|
lastKnownScrollTop = container.scrollTop
|
||||||
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtBottom(container: HTMLDivElement) {
|
||||||
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||||
|
const container = containerOverride ?? scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
if (suppressNextScrollHandling) {
|
||||||
|
suppressNextScrollHandling = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
const atBottomFromScroll = isAtBottom(container)
|
||||||
|
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||||
|
|
||||||
|
if (isUserScroll || !atBottom) {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
updateFollowModeFromScroll(event.currentTarget)
|
||||||
|
persistScrollSnapshot(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||||
|
const next = element || undefined
|
||||||
|
if (next === scrollContainerRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollContainerRef = next
|
||||||
|
setScrollContainer(scrollContainerRef)
|
||||||
|
if (scrollContainerRef) {
|
||||||
|
lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||||
|
restoreScrollPosition(autoScroll())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
||||||
|
if (config?.disableTracking) return null
|
||||||
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreAfterRender = () => {
|
||||||
|
const container = scrollContainerRef
|
||||||
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||||
|
if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(false)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never let a render-time caller force follow mode back on after the user
|
||||||
|
// has already escaped it. Staying pinned should depend on the current
|
||||||
|
// follow state, not on a caller opting into forceBottom.
|
||||||
|
const shouldFollow = autoScroll()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition(shouldFollow)
|
||||||
|
if (shouldFollow) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
attachScrollIntentListeners(container)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollContainer()
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
if (!container || !sentinel) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.target === sentinel) {
|
||||||
|
setBottomSentinelVisible(entry.isIntersecting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
pendingScrollFrame = null
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerContainer,
|
||||||
|
handleScroll,
|
||||||
|
renderSentinel,
|
||||||
|
restoreAfterRender,
|
||||||
|
autoScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const log = getLogger("actions")
|
|||||||
interface UseAppLifecycleOptions {
|
interface UseAppLifecycleOptions {
|
||||||
setEscapeInDebounce: (value: boolean) => void
|
setEscapeInDebounce: (value: boolean) => void
|
||||||
handleNewInstanceRequest: () => void
|
handleNewInstanceRequest: () => void
|
||||||
|
handleCloseActiveTab: () => Promise<void>
|
||||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||||
handleNewSession: (instanceId: string) => Promise<void>
|
handleNewSession: (instanceId: string) => Promise<void>
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||||
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
|||||||
|
|
||||||
setupTabKeyboardShortcuts(
|
setupTabKeyboardShortcuts(
|
||||||
options.handleNewInstanceRequest,
|
options.handleNewInstanceRequest,
|
||||||
options.handleCloseInstance,
|
options.handleCloseActiveTab,
|
||||||
options.handleNewSession,
|
options.handleNewSession,
|
||||||
options.handleCloseSession,
|
options.handleCloseSession,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
|
|||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
||||||
import { createCommandRegistry, type Command } from "../commands"
|
import { createCommandRegistry, type Command } from "../commands"
|
||||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
import { activeInstanceId } from "../../stores/instances"
|
||||||
|
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
|
||||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
|
|||||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||||
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||||
handleNewInstanceRequest: () => void
|
handleNewInstanceRequest: () => void
|
||||||
|
handleCloseActiveTab: () => Promise<void>
|
||||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||||
handleNewSession: (instanceId: string) => Promise<void>
|
handleNewSession: (instanceId: string) => Promise<void>
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||||
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||||
shortcut: { key: "W", meta: true },
|
shortcut: { key: "W", meta: true },
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
await options.handleCloseActiveTab()
|
||||||
if (!instance) return
|
|
||||||
await options.handleCloseInstance(instance.id)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||||
shortcut: { key: "]", meta: true },
|
shortcut: { key: "]", meta: true },
|
||||||
action: () => {
|
action: () => selectNextAppTab(),
|
||||||
const ids = Array.from(instances().keys())
|
|
||||||
if (ids.length <= 1) return
|
|
||||||
const current = ids.indexOf(activeInstanceId() || "")
|
|
||||||
const next = (current + 1) % ids.length
|
|
||||||
if (ids[next]) setActiveInstanceId(ids[next])
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||||
shortcut: { key: "[", meta: true },
|
shortcut: { key: "[", meta: true },
|
||||||
action: () => {
|
action: () => selectPreviousAppTab(),
|
||||||
const ids = Array.from(instances().keys())
|
|
||||||
if (ids.length <= 1) return
|
|
||||||
const current = ids.indexOf(activeInstanceId() || "")
|
|
||||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
|
||||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Open folder picker to create new instance",
|
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||||
"commands.newInstance.keywords": "folder, project, workspace",
|
"commands.newInstance.keywords": "folder, project, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Close Instance",
|
"commands.closeInstance.label": "Close Tab",
|
||||||
"commands.closeInstance.description": "Stop current instance's server",
|
"commands.closeInstance.description": "Close the current top-level tab",
|
||||||
"commands.closeInstance.keywords": "stop, quit, close",
|
"commands.closeInstance.keywords": "stop, quit, close, tab",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Next Instance",
|
"commands.nextInstance.label": "Next Tab",
|
||||||
"commands.nextInstance.description": "Cycle to next instance tab",
|
"commands.nextInstance.description": "Cycle to the next top-level tab",
|
||||||
"commands.nextInstance.keywords": "switch, navigate",
|
"commands.nextInstance.keywords": "switch, navigate, tab",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Previous Instance",
|
"commands.previousInstance.label": "Previous Tab",
|
||||||
"commands.previousInstance.description": "Cycle to previous instance tab",
|
"commands.previousInstance.description": "Cycle to the previous top-level tab",
|
||||||
"commands.previousInstance.keywords": "switch, navigate",
|
"commands.previousInstance.keywords": "switch, navigate, tab",
|
||||||
|
|
||||||
"commands.newSession.label": "New Session",
|
"commands.newSession.label": "New Session",
|
||||||
"commands.newSession.description": "Create a new parent session",
|
"commands.newSession.description": "Create a new parent session",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
||||||
"folderSelection.browse.button": "Browse Folders",
|
"folderSelection.browse.button": "Browse Folders",
|
||||||
"folderSelection.browse.buttonOpening": "Opening...",
|
"folderSelection.browse.buttonOpening": "Opening...",
|
||||||
|
"folderSelection.actions.title": "Open Folder or Connect Server",
|
||||||
|
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
|
||||||
|
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Advanced Settings",
|
"folderSelection.advancedSettings": "Advanced Settings",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Select Workspace",
|
"folderSelection.dialog.title": "Select Workspace",
|
||||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "Local Folders",
|
||||||
|
"folderSelection.tabs.servers": "Servers",
|
||||||
|
"folderSelection.servers.title": "Saved Servers",
|
||||||
|
"folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window",
|
||||||
|
"folderSelection.servers.count": "{count} Servers",
|
||||||
|
"folderSelection.servers.empty.title": "No Saved Servers",
|
||||||
|
"folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
|
||||||
|
"folderSelection.servers.connectTitle": "Connect to Server",
|
||||||
|
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
|
||||||
|
"folderSelection.servers.connectButton": "Connect to Server",
|
||||||
|
"folderSelection.servers.remove": "Remove saved server",
|
||||||
|
"folderSelection.servers.skipTls": "Self-signed TLS",
|
||||||
|
"folderSelection.servers.errorTitle": "Remote Connection Failed",
|
||||||
|
"folderSelection.servers.dialog.title": "Connect to Server",
|
||||||
|
"folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.",
|
||||||
|
"folderSelection.servers.dialog.name": "Server name",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "Production Server",
|
||||||
|
"folderSelection.servers.dialog.url": "Server URL",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "Cancel",
|
||||||
|
"folderSelection.servers.dialog.save": "Save",
|
||||||
|
"folderSelection.servers.dialog.connect": "Connect",
|
||||||
|
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -131,6 +131,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||||
"instanceShell.gitChanges.deleted": "Deleted",
|
"instanceShell.gitChanges.deleted": "Deleted",
|
||||||
|
"instanceShell.gitChanges.binaryViewer": "Binary file cannot be displayed",
|
||||||
|
"instanceShell.gitChanges.sections.staged": "Staged Changes",
|
||||||
|
"instanceShell.gitChanges.sections.unstaged": "Changes",
|
||||||
|
"instanceShell.gitChanges.actions.insertContext": "Add to prompt",
|
||||||
|
"instanceShell.gitChanges.actions.stage": "Stage file",
|
||||||
|
"instanceShell.gitChanges.actions.unstage": "Unstage file",
|
||||||
|
"instanceShell.gitChanges.commit.placeholder": "Enter commit message",
|
||||||
|
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||||
|
"instanceShell.gitChanges.commit.submitting": "Committing...",
|
||||||
|
"instanceShell.gitChanges.commit.success": "Commit created successfully",
|
||||||
|
"instanceShell.gitChanges.commit.error": "Failed to create commit",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "File list",
|
"instanceShell.filesShell.fileListTitle": "File list",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||||
@@ -147,6 +158,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.diff.enableWordWrap": "Enable word wrap",
|
"instanceShell.diff.enableWordWrap": "Enable word wrap",
|
||||||
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
||||||
"instanceShell.worktree.create": "+ Create worktree",
|
"instanceShell.worktree.create": "+ Create worktree",
|
||||||
|
"instanceShell.worktree.delete.error.title": "Delete failed",
|
||||||
|
"instanceShell.worktree.delete.error.fallback": "Failed to delete worktree",
|
||||||
|
"instanceShell.worktree.delete.error.causeLabel": "Likely cause:",
|
||||||
|
"instanceShell.worktree.delete.error.nextStepLabel": "Suggested next step:",
|
||||||
|
"instanceShell.worktree.delete.error.summary.localChanges": "Git refused to delete this worktree because it has modified or untracked files.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad could not delete this worktree because something is still using files in the directory.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad could not delete this worktree because the directory or worktree record was not found.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad could not delete this worktree because access to the directory was denied.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad could not delete this worktree.",
|
||||||
|
"instanceShell.worktree.delete.error.cause.localChanges": "Local changes",
|
||||||
|
"instanceShell.worktree.delete.error.cause.inUse": "Another process is using this worktree",
|
||||||
|
"instanceShell.worktree.delete.error.cause.notFound": "The worktree directory or record is missing",
|
||||||
|
"instanceShell.worktree.delete.error.cause.permissionDenied": "Insufficient filesystem permissions",
|
||||||
|
"instanceShell.worktree.delete.error.cause.unknown": "The backend returned an unclassified delete error",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.localChanges": "Enable Force delete if you want to discard local changes, or clean the worktree and try again.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.inUse": "Close terminals, editors, watchers, or background processes using this worktree and try again.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.notFound": "Refresh worktrees and try again. If it still fails, inspect the worktree path on disk.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Check filesystem permissions and close applications that may be locking this directory, then try again.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.unknown": "Review the raw error below for details, then retry after addressing the reported problem.",
|
||||||
|
"instanceShell.worktree.delete.error.copyRaw": "Copy error",
|
||||||
|
"instanceShell.worktree.delete.error.copySanitized": "Copy sanitized",
|
||||||
|
"instanceShell.worktree.delete.error.copySuccess": "Copied delete error",
|
||||||
|
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Copied sanitized delete error",
|
||||||
|
"instanceShell.worktree.delete.error.copyFailure": "Failed to copy delete error",
|
||||||
|
|
||||||
"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.",
|
||||||
@@ -160,6 +195,8 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
||||||
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
||||||
|
"instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled",
|
||||||
|
"instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled",
|
||||||
"instanceShell.backgroundProcesses.actions.output": "Output",
|
"instanceShell.backgroundProcesses.actions.output": "Output",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "Stop",
|
"instanceShell.backgroundProcesses.actions.stop": "Stop",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",
|
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "Loading messages...",
|
"messageSection.loading.messages": "Loading messages...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
|
||||||
"messageSection.quote.addAsQuote": "Add as quote",
|
"messageSection.quote.addAsQuote": "Add as quote",
|
||||||
"messageSection.quote.addAsCode": "Add as code",
|
"messageSection.quote.addAsCode": "Add as code",
|
||||||
"messageSection.quote.copy": "Copy",
|
"messageSection.quote.copy": "Copy",
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "OpenCode Log Level",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Default log level",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Debug",
|
||||||
|
"settings.opencode.logLevel.option.info": "Info",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Warn",
|
||||||
|
"settings.opencode.logLevel.option.error": "Error",
|
||||||
|
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaction",
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
||||||
@@ -186,4 +195,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Saved",
|
"settings.speech.save.saved": "Saved",
|
||||||
"settings.speech.save.unsaved": "Unsaved changes",
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
"settings.speech.save.error": "Save failed",
|
"settings.speech.save.error": "Save failed",
|
||||||
|
"settings.nav.sidecars": "SideCars",
|
||||||
|
"settings.section.sidecars.eyebrow": "Server services",
|
||||||
|
"settings.section.sidecars.title": "SideCars",
|
||||||
|
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||||
|
"sidecars.form.name": "Name",
|
||||||
|
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||||
|
"sidecars.form.port": "Port",
|
||||||
|
"sidecars.form.insecure": "Use HTTP",
|
||||||
|
"sidecars.form.protocol": "Protocol",
|
||||||
|
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||||
|
"sidecars.form.protocol.https": "HTTPS",
|
||||||
|
"sidecars.form.protocol.http": "HTTP",
|
||||||
|
"sidecars.form.prefixMode": "Prefix mode",
|
||||||
|
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||||
|
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||||
|
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||||
|
"sidecars.form.add": "Add SideCar",
|
||||||
|
"sidecars.kind.port": "Port",
|
||||||
|
"sidecars.status.running": "Running",
|
||||||
|
"sidecars.status.stopped": "Stopped",
|
||||||
|
"sidecars.basePath": "Base path",
|
||||||
|
"sidecars.settings.listTitle": "Configured SideCars",
|
||||||
|
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||||
|
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||||
|
"sidecars.picker.title": "Open SideCar",
|
||||||
|
"sidecars.picker.loading": "Loading SideCars...",
|
||||||
|
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||||
|
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||||
|
"sidecars.picker.close": "Close",
|
||||||
|
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||||
|
"sidecars.open.notFound": "SideCar not found.",
|
||||||
|
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||||
|
"sidecars.back": "Back",
|
||||||
|
"sidecars.refresh": "Refresh",
|
||||||
|
"sidecars.path": "Path",
|
||||||
|
"sidecars.go": "Go",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
||||||
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Cerrar instancia",
|
"commands.closeInstance.label": "Cerrar pestaña",
|
||||||
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
|
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
|
||||||
"commands.closeInstance.keywords": "detener, salir, cerrar",
|
"commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Siguiente instancia",
|
"commands.nextInstance.label": "Siguiente pestaña",
|
||||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
|
"commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
|
||||||
"commands.nextInstance.keywords": "cambiar, navegar",
|
"commands.nextInstance.keywords": "cambiar, navegar, pestaña",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Instancia anterior",
|
"commands.previousInstance.label": "Pestaña anterior",
|
||||||
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
|
"commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
|
||||||
"commands.previousInstance.keywords": "cambiar, navegar",
|
"commands.previousInstance.keywords": "cambiar, navegar, pestaña",
|
||||||
|
|
||||||
"commands.newSession.label": "Nueva sesión",
|
"commands.newSession.label": "Nueva sesión",
|
||||||
"commands.newSession.description": "Crear una nueva sesión principal",
|
"commands.newSession.description": "Crear una nueva sesión principal",
|
||||||
|
|||||||
@@ -2,35 +2,38 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.language.ariaLabel": "Idioma",
|
"folderSelection.language.ariaLabel": "Idioma",
|
||||||
|
|
||||||
"folderSelection.logoAlt": "Logo de CodeNomad",
|
"folderSelection.logoAlt": "Logo de CodeNomad",
|
||||||
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
|
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
|
||||||
|
|
||||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||||
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub",
|
"folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
|
||||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||||
|
|
||||||
"folderSelection.empty.title": "No hay carpetas recientes",
|
"folderSelection.empty.title": "No hay carpetas recientes",
|
||||||
"folderSelection.empty.description": "Explora una carpeta para comenzar",
|
"folderSelection.empty.description": "Busca una carpeta para comenzar",
|
||||||
|
|
||||||
"folderSelection.recent.title": "Carpetas recientes",
|
"folderSelection.recent.title": "Carpetas recientes",
|
||||||
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
||||||
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
||||||
"folderSelection.recent.remove": "Quitar de recientes",
|
"folderSelection.recent.remove": "Eliminar de recientes",
|
||||||
|
|
||||||
"folderSelection.browse.title": "Explorar carpetas",
|
"folderSelection.browse.title": "Buscar carpeta",
|
||||||
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
||||||
"folderSelection.browse.button": "Explorar carpetas",
|
"folderSelection.browse.button": "Buscar carpetas",
|
||||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||||
|
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
|
||||||
|
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Configuración avanzada",
|
"folderSelection.advancedSettings": "Configuración avanzada",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Navegar",
|
"folderSelection.hints.navigate": "Navegar",
|
||||||
"folderSelection.hints.select": "Seleccionar",
|
"folderSelection.hints.select": "Seleccionar",
|
||||||
"folderSelection.hints.remove": "Quitar",
|
"folderSelection.hints.remove": "Eliminar",
|
||||||
"folderSelection.hints.browse": "Explorar",
|
"folderSelection.hints.browse": "Buscar",
|
||||||
|
|
||||||
"folderSelection.loading.title": "Iniciando instancia...",
|
"folderSelection.loading.title": "Iniciando instancia...",
|
||||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
"folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.",
|
||||||
|
|
||||||
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "Carpetas locales",
|
||||||
|
"folderSelection.tabs.servers": "Servidores",
|
||||||
|
"folderSelection.servers.title": "Servidores guardados",
|
||||||
|
"folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva",
|
||||||
|
"folderSelection.servers.count": "{count} servidores",
|
||||||
|
"folderSelection.servers.empty.title": "No hay servidores guardados",
|
||||||
|
"folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo",
|
||||||
|
"folderSelection.servers.connectTitle": "Conectar a un servidor",
|
||||||
|
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
|
||||||
|
"folderSelection.servers.connectButton": "Conectar a un servidor",
|
||||||
|
"folderSelection.servers.remove": "Eliminar servidor guardado",
|
||||||
|
"folderSelection.servers.skipTls": "TLS autofirmado",
|
||||||
|
"folderSelection.servers.errorTitle": "Falló la conexión remota",
|
||||||
|
"folderSelection.servers.dialog.title": "Conectar a un servidor",
|
||||||
|
"folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.",
|
||||||
|
"folderSelection.servers.dialog.name": "Nombre del servidor",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "Servidor de producción",
|
||||||
|
"folderSelection.servers.dialog.url": "URL del servidor",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "Cancelar",
|
||||||
|
"folderSelection.servers.dialog.save": "Guardar",
|
||||||
|
"folderSelection.servers.dialog.connect": "Conectar",
|
||||||
|
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||||
|
"instanceShell.gitChanges.binaryViewer": "No se puede mostrar un archivo binario",
|
||||||
|
"instanceShell.gitChanges.sections.staged": "Cambios preparados",
|
||||||
|
"instanceShell.gitChanges.sections.unstaged": "Cambios",
|
||||||
|
"instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
|
||||||
|
"instanceShell.gitChanges.actions.stage": "Preparar archivo",
|
||||||
|
"instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
|
||||||
|
"instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
|
||||||
|
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||||
|
"instanceShell.gitChanges.commit.submitting": "Confirmando...",
|
||||||
|
"instanceShell.gitChanges.commit.success": "Commit creado correctamente",
|
||||||
|
"instanceShell.gitChanges.commit.error": "No se pudo crear el commit",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||||
@@ -150,9 +161,35 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
||||||
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
||||||
|
"instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada",
|
||||||
|
"instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada",
|
||||||
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
|
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
|
||||||
|
"instanceShell.worktree.delete.error.title": "Error al eliminar",
|
||||||
|
"instanceShell.worktree.delete.error.fallback": "Error al eliminar el worktree",
|
||||||
|
"instanceShell.worktree.delete.error.causeLabel": "Causa probable:",
|
||||||
|
"instanceShell.worktree.delete.error.nextStepLabel": "Siguiente paso sugerido:",
|
||||||
|
"instanceShell.worktree.delete.error.summary.localChanges": "Git rechazo la eliminacion de este worktree porque contiene archivos modificados o sin seguimiento.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad no pudo eliminar este worktree porque algo sigue usando archivos dentro del directorio.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad no pudo eliminar este worktree porque no se encontro el directorio o el registro del worktree.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad no pudo eliminar este worktree porque se denego el acceso al directorio.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad no pudo eliminar este worktree.",
|
||||||
|
"instanceShell.worktree.delete.error.cause.localChanges": "Cambios locales",
|
||||||
|
"instanceShell.worktree.delete.error.cause.inUse": "Otro proceso esta usando este worktree",
|
||||||
|
"instanceShell.worktree.delete.error.cause.notFound": "Falta el directorio o el registro del worktree",
|
||||||
|
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permisos insuficientes del sistema de archivos",
|
||||||
|
"instanceShell.worktree.delete.error.cause.unknown": "El backend devolvio un error de eliminacion sin clasificar",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activa Forzar eliminacion si quieres descartar los cambios locales, o limpia el worktree e intentalo de nuevo.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.inUse": "Cierra terminales, editores, observadores o procesos en segundo plano que usen este worktree y vuelve a intentarlo.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.notFound": "Recarga los worktrees y vuelve a intentarlo. Si sigue fallando, inspecciona la ruta del worktree en disco.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Revisa los permisos del sistema de archivos y cierra aplicaciones que puedan estar bloqueando este directorio, luego vuelve a intentarlo.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.unknown": "Revisa el error sin procesar de abajo para ver los detalles y vuelve a intentarlo despues de corregir el problema indicado.",
|
||||||
|
"instanceShell.worktree.delete.error.copyRaw": "Copiar error",
|
||||||
|
"instanceShell.worktree.delete.error.copySanitized": "Copiar saneado",
|
||||||
|
"instanceShell.worktree.delete.error.copySuccess": "Error de eliminacion copiado",
|
||||||
|
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Error de eliminacion saneado copiado",
|
||||||
|
"instanceShell.worktree.delete.error.copyFailure": "No se pudo copiar el error de eliminacion",
|
||||||
|
|
||||||
"versionPill.appWithVersion": "App {version}",
|
"versionPill.appWithVersion": "App {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "Cargando mensajes...",
|
"messageSection.loading.messages": "Cargando mensajes...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
|
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
|
||||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||||
"messageSection.quote.addAsCode": "Añadir como código",
|
"messageSection.quote.addAsCode": "Añadir como código",
|
||||||
"messageSection.quote.copy": "Copiar",
|
"messageSection.quote.copy": "Copiar",
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Depuracion",
|
||||||
|
"settings.opencode.logLevel.option.info": "Informacion",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Advertencia",
|
||||||
|
"settings.opencode.logLevel.option.error": "Error",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaccion",
|
"settings.appearance.behavior.title": "Interaccion",
|
||||||
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
||||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Guardado",
|
"settings.speech.save.saved": "Guardado",
|
||||||
"settings.speech.save.unsaved": "Cambios sin guardar",
|
"settings.speech.save.unsaved": "Cambios sin guardar",
|
||||||
"settings.speech.save.error": "Error al guardar",
|
"settings.speech.save.error": "Error al guardar",
|
||||||
|
"settings.nav.sidecars": "SideCars",
|
||||||
|
"settings.section.sidecars.eyebrow": "Server services",
|
||||||
|
"settings.section.sidecars.title": "SideCars",
|
||||||
|
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||||
|
"sidecars.form.name": "Name",
|
||||||
|
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||||
|
"sidecars.form.port": "Port",
|
||||||
|
"sidecars.form.insecure": "Use HTTP",
|
||||||
|
"sidecars.form.protocol": "Protocol",
|
||||||
|
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||||
|
"sidecars.form.protocol.https": "HTTPS",
|
||||||
|
"sidecars.form.protocol.http": "HTTP",
|
||||||
|
"sidecars.form.prefixMode": "Prefix mode",
|
||||||
|
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||||
|
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||||
|
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||||
|
"sidecars.form.add": "Add SideCar",
|
||||||
|
"sidecars.kind.port": "Port",
|
||||||
|
"sidecars.status.running": "Running",
|
||||||
|
"sidecars.status.stopped": "Stopped",
|
||||||
|
"sidecars.basePath": "Base path",
|
||||||
|
"sidecars.settings.listTitle": "Configured SideCars",
|
||||||
|
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||||
|
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||||
|
"sidecars.picker.title": "Open SideCar",
|
||||||
|
"sidecars.picker.loading": "Loading SideCars...",
|
||||||
|
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||||
|
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||||
|
"sidecars.picker.close": "Close",
|
||||||
|
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||||
|
"sidecars.open.notFound": "SideCar not found.",
|
||||||
|
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||||
|
"sidecars.back": "Back",
|
||||||
|
"sidecars.refresh": "Refresh",
|
||||||
|
"sidecars.path": "Path",
|
||||||
|
"sidecars.go": "Go",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Fermer l'instance",
|
"commands.closeInstance.label": "Fermer l'onglet",
|
||||||
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
|
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
|
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Instance suivante",
|
"commands.nextInstance.label": "Onglet suivant",
|
||||||
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
|
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
|
||||||
"commands.nextInstance.keywords": "changer, naviguer, suivant",
|
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Instance précédente",
|
"commands.previousInstance.label": "Onglet précédent",
|
||||||
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
|
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
|
||||||
"commands.previousInstance.keywords": "changer, naviguer, précédent",
|
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
|
||||||
|
|
||||||
"commands.newSession.label": "Nouvelle session",
|
"commands.newSession.label": "Nouvelle session",
|
||||||
"commands.newSession.description": "Créer une nouvelle session parente",
|
"commands.newSession.description": "Créer une nouvelle session parente",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
||||||
|
|
||||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||||
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad",
|
"folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
|
||||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||||
|
|
||||||
"folderSelection.empty.title": "Aucun dossier récent",
|
"folderSelection.empty.title": "Aucun dossier récent",
|
||||||
@@ -16,10 +16,13 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
||||||
"folderSelection.recent.remove": "Retirer des récents",
|
"folderSelection.recent.remove": "Retirer des récents",
|
||||||
|
|
||||||
"folderSelection.browse.title": "Parcourir les dossiers",
|
"folderSelection.browse.title": "Parcourir un dossier",
|
||||||
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||||
|
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
|
||||||
|
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "Dossiers locaux",
|
||||||
|
"folderSelection.tabs.servers": "Serveurs",
|
||||||
|
"folderSelection.servers.title": "Serveurs enregistrés",
|
||||||
|
"folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
|
||||||
|
"folderSelection.servers.count": "{count} serveurs",
|
||||||
|
"folderSelection.servers.empty.title": "Aucun serveur enregistré",
|
||||||
|
"folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
|
||||||
|
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
|
||||||
|
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
|
||||||
|
"folderSelection.servers.connectButton": "Se connecter à un serveur",
|
||||||
|
"folderSelection.servers.remove": "Supprimer le serveur enregistré",
|
||||||
|
"folderSelection.servers.skipTls": "TLS auto-signé",
|
||||||
|
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
|
||||||
|
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
|
||||||
|
"folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.",
|
||||||
|
"folderSelection.servers.dialog.name": "Nom du serveur",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "Serveur de production",
|
||||||
|
"folderSelection.servers.dialog.url": "URL du serveur",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "Annuler",
|
||||||
|
"folderSelection.servers.dialog.save": "Enregistrer",
|
||||||
|
"folderSelection.servers.dialog.connect": "Se connecter",
|
||||||
|
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||||
|
"instanceShell.gitChanges.binaryViewer": "Impossible d'afficher un fichier binaire",
|
||||||
|
"instanceShell.gitChanges.sections.staged": "Changements indexés",
|
||||||
|
"instanceShell.gitChanges.sections.unstaged": "Changements",
|
||||||
|
"instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
|
||||||
|
"instanceShell.gitChanges.actions.stage": "Indexer le fichier",
|
||||||
|
"instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
|
||||||
|
"instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
|
||||||
|
"instanceShell.gitChanges.commit.submit": "Valider",
|
||||||
|
"instanceShell.gitChanges.commit.submitting": "Validation...",
|
||||||
|
"instanceShell.gitChanges.commit.success": "Commit créé avec succès",
|
||||||
|
"instanceShell.gitChanges.commit.error": "Impossible de créer le commit",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||||
@@ -150,9 +161,35 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
||||||
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
||||||
|
"instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee",
|
||||||
|
"instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee",
|
||||||
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
|
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
|
||||||
|
"instanceShell.worktree.delete.error.title": "Echec de suppression",
|
||||||
|
"instanceShell.worktree.delete.error.fallback": "Impossible de supprimer le worktree",
|
||||||
|
"instanceShell.worktree.delete.error.causeLabel": "Cause probable :",
|
||||||
|
"instanceShell.worktree.delete.error.nextStepLabel": "Etape suivante suggeree :",
|
||||||
|
"instanceShell.worktree.delete.error.summary.localChanges": "Git a refuse de supprimer ce worktree car il contient des fichiers modifies ou non suivis.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad n'a pas pu supprimer ce worktree car quelque chose utilise encore des fichiers dans ce dossier.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad n'a pas pu supprimer ce worktree car le dossier ou l'enregistrement du worktree est introuvable.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad n'a pas pu supprimer ce worktree car l'acces au dossier a ete refuse.",
|
||||||
|
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad n'a pas pu supprimer ce worktree.",
|
||||||
|
"instanceShell.worktree.delete.error.cause.localChanges": "Modifications locales",
|
||||||
|
"instanceShell.worktree.delete.error.cause.inUse": "Un autre processus utilise ce worktree",
|
||||||
|
"instanceShell.worktree.delete.error.cause.notFound": "Le dossier ou l'enregistrement du worktree est manquant",
|
||||||
|
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permissions du systeme de fichiers insuffisantes",
|
||||||
|
"instanceShell.worktree.delete.error.cause.unknown": "Le backend a renvoye une erreur de suppression non classee",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activez la suppression forcee si vous voulez jeter les modifications locales, ou nettoyez le worktree puis reessayez.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.inUse": "Fermez les terminaux, editeurs, observateurs ou processus en arrière-plan qui utilisent ce worktree puis reessayez.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.notFound": "Rechargez les worktrees puis reessayez. Si cela echoue encore, inspectez le chemin du worktree sur le disque.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Verifiez les permissions du systeme de fichiers et fermez les applications qui peuvent verrouiller ce dossier, puis reessayez.",
|
||||||
|
"instanceShell.worktree.delete.error.nextStep.unknown": "Consultez l'erreur brute ci-dessous pour les details, puis reessayez apres avoir corrige le probleme signale.",
|
||||||
|
"instanceShell.worktree.delete.error.copyRaw": "Copier l'erreur",
|
||||||
|
"instanceShell.worktree.delete.error.copySanitized": "Copier la version nettoyee",
|
||||||
|
"instanceShell.worktree.delete.error.copySuccess": "Erreur de suppression copiee",
|
||||||
|
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Erreur de suppression nettoyee copiee",
|
||||||
|
"instanceShell.worktree.delete.error.copyFailure": "Impossible de copier l'erreur de suppression",
|
||||||
|
|
||||||
"versionPill.appWithVersion": "Appli {version}",
|
"versionPill.appWithVersion": "Appli {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user