feat(v0.6): project card launchers — Claude/Codex/VS Code logos + active highlight

Replace each card's "+ start claude here" with a row of three brand-logo
launcher buttons (logo + caption, touch-friendly, tooltips):

- Claude (coral burst)  → new terminal tab running `claude`
- Codex  (OpenAI mark)  → new terminal tab running `codex`
- VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST

VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default
`code`) on the host via execFile (no shell); path validated as an existing
absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added.

Active-session highlight (user request): when a project has a running Claude
session (Claude-hook status != unknown — plain shells/codex stay unknown), the
Claude logo gets a coral ring + tint, with a count badge when more than one.

New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts.
onOpenProject gains an optional cmd; makeProjectCard exported for tests.

Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher
row (3 buttons, claude/codex commands), and the active-Claude highlight + badge.
Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3
logos render with brand colours, and POST /open-in-editor actually launched
VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
This commit is contained in:
Yaojia Wang
2026-06-30 12:46:12 +02:00
parent 3323bc81c0
commit a390130205
12 changed files with 447 additions and 42 deletions

View File

@@ -34,6 +34,7 @@ const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git
const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL
const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -242,6 +243,7 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_PROJECT_SCAN_TTL_MS,
)
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD
return Object.freeze({
port,
@@ -263,5 +265,6 @@ export function loadConfig(env: EnvLike): Config {
projectScanDepth,
projectScanTtlMs,
projectDirtyCheck,
editorCmd,
} satisfies Config)
}

63
src/http/editor.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* src/http/editor.ts (v0.6) — launch a desktop editor on the HOST for a project.
*
* POST /open-in-editor runs `<cfg.editorCmd> <repoPath>` (default `code`) so the
* Projects panel's VS Code button opens the repo where the files actually live —
* the host machine, regardless of which device clicked. This is a state-changing
* route, so server.ts guards it with the Origin whitelist (CSRF defence).
*
* Safety: execFile (NOT a shell) — the path is passed as an argv element, never
* interpolated into a command line, so no shell-injection surface. The path is
* validated to be an existing absolute directory before spawning. This adds no
* privilege beyond what the app already grants (a full shell on the host).
*/
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Config } from '../types.js'
export interface OpenEditorResult {
ok: boolean
status: number // HTTP status to return
error?: string // user-facing reason on failure
}
/**
* Validate `rawPath` (must be an absolute, existing directory) then launch the
* configured editor on it (fire-and-forget; the GUI process is detached). Never
* throws — returns a structured result the route maps to an HTTP response.
*/
export async function openInEditor(cfg: Config, rawPath: unknown): Promise<OpenEditorResult> {
if (typeof rawPath !== 'string' || rawPath.trim() === '') {
return { ok: false, status: 400, error: 'path is required' }
}
if (!path.isAbsolute(rawPath)) {
return { ok: false, status: 400, error: 'path must be absolute' }
}
let stat
try {
stat = await fs.stat(rawPath)
} catch {
return { ok: false, status: 404, error: 'path not found' }
}
if (!stat.isDirectory()) {
return { ok: false, status: 400, error: 'path is not a directory' }
}
try {
// No shell: argv array, so the path can never be interpreted as a command.
const child = execFile(cfg.editorCmd, [rawPath], { windowsHide: true })
// A ChildProcess 'error' (e.g. editor binary not in PATH) is emitted async;
// handle it so it never bubbles to an uncaughtException, and don't keep the
// server alive waiting on the GUI process.
child.on('error', (err) => {
console.error('[editor] failed to launch', JSON.stringify(cfg.editorCmd), '-', err.message)
})
child.unref()
return { ok: true, status: 204 }
} catch (err) {
return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
}
}

View File

@@ -40,6 +40,7 @@ import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { buildProjects } from './http/projects.js'
import { openInEditor } from './http/editor.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import type { Config } from './types.js'
@@ -217,6 +218,21 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(ok ? 204 : 404).end()
})
// Open a project in the host's desktop editor (v0.6 Projects panel — VS Code
// logo). State-changing (spawns a GUI process), so it carries the same Origin
// guard as the DELETE routes. The path is validated + passed via execFile (no
// shell) in openInEditor.
app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const body = (req.body ?? {}) as Record<string, unknown>
const result = await openInEditor(cfg, body['path'])
if (result.ok) {
res.status(204).end()
return
}
res.status(result.status).json({ error: result.error })
})
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
// Hooks running inside a spawned shell POST status here (loopback only — the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.

View File

@@ -39,6 +39,7 @@ export interface Config {
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host
}
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */