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:
63
src/http/editor.ts
Normal file
63
src/http/editor.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user