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

68
test/editor.test.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* test/editor.test.ts — openInEditor validation + spawn wiring (v0.6).
*
* Uses editorCmd='true' (the POSIX no-op that exits 0, ignoring args) so the
* success path actually spawns a harmless process — never opening a real editor.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { openInEditor } from '../src/http/editor.js'
import type { Config } from '../src/types.js'
function cfg(editorCmd: string): Config {
// Only editorCmd matters here; the rest is filler to satisfy the type.
return { editorCmd } as unknown as Config
}
let tmpDir: string
let tmpFile: string
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'editor-test-'))
tmpFile = path.join(tmpDir, 'a-file.txt')
await fs.writeFile(tmpFile, 'hi')
})
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('openInEditor — validation', () => {
it('rejects a missing/empty path with 400', async () => {
expect((await openInEditor(cfg('true'), undefined)).status).toBe(400)
expect((await openInEditor(cfg('true'), '')).status).toBe(400)
expect((await openInEditor(cfg('true'), ' ')).status).toBe(400)
})
it('rejects a non-string path with 400', async () => {
expect((await openInEditor(cfg('true'), 123)).status).toBe(400)
expect((await openInEditor(cfg('true'), { path: '/x' })).status).toBe(400)
})
it('rejects a relative path with 400', async () => {
const r = await openInEditor(cfg('true'), 'relative/dir')
expect(r.ok).toBe(false)
expect(r.status).toBe(400)
})
it('returns 404 for a non-existent absolute path', async () => {
const r = await openInEditor(cfg('true'), path.join(tmpDir, 'does-not-exist'))
expect(r.status).toBe(404)
})
it('returns 400 when the path is a file, not a directory', async () => {
const r = await openInEditor(cfg('true'), tmpFile)
expect(r.status).toBe(400)
})
})
describe('openInEditor — launch', () => {
it('spawns the editor for a valid directory (204)', async () => {
const r = await openInEditor(cfg('true'), tmpDir)
expect(r.ok).toBe(true)
expect(r.status).toBe(204)
})
})