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

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) }
}
}