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:
@@ -437,4 +437,11 @@ describe('loadConfig — project discovery (v0.6)', () => {
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).projectDirtyCheck).toBe(true)
|
||||
})
|
||||
|
||||
it('EDITOR_CMD: defaults to "code", trims, falls back on blank', () => {
|
||||
expect(loadConfig({}).editorCmd).toBe('code')
|
||||
expect(loadConfig({ EDITOR_CMD: 'cursor' }).editorCmd).toBe('cursor')
|
||||
expect(loadConfig({ EDITOR_CMD: ' code-insiders ' }).editorCmd).toBe('code-insiders')
|
||||
expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code')
|
||||
})
|
||||
})
|
||||
|
||||
68
test/editor.test.ts
Normal file
68
test/editor.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -21,9 +21,8 @@ class FakeTerminal {
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
|
||||
|
||||
// Dynamic import AFTER mock declaration so the factory is in place.
|
||||
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject } = await import(
|
||||
'../public/projects.js'
|
||||
)
|
||||
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } =
|
||||
await import('../public/projects.js')
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -271,3 +270,86 @@ describe('normalizeProject', () => {
|
||||
expect(p?.isGit).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */
|
||||
|
||||
describe('makeProjectCard — launcher row', () => {
|
||||
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
|
||||
|
||||
it('renders exactly three brand launcher buttons', () => {
|
||||
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
|
||||
expect(card.querySelector('.proj-launch-claude')).not.toBeNull()
|
||||
expect(card.querySelector('.proj-launch-codex')).not.toBeNull()
|
||||
expect(card.querySelector('.proj-launch-vscode')).not.toBeNull()
|
||||
// The old single-button action is gone.
|
||||
expect(card.querySelector('.proj-new')).toBeNull()
|
||||
})
|
||||
|
||||
it('each launcher embeds an inline SVG logo + caption', () => {
|
||||
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
||||
const claude = card.querySelector('.proj-launch-claude')!
|
||||
expect(claude.querySelector('svg')).not.toBeNull()
|
||||
expect(claude.querySelector('.proj-launch-cap')?.textContent).toBe('Claude')
|
||||
})
|
||||
|
||||
it('Claude launcher opens the project with the claude command (with CR)', () => {
|
||||
const hooks = noopHooks()
|
||||
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
|
||||
;(card.querySelector('.proj-launch-claude') as HTMLButtonElement).click()
|
||||
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'claude\r')
|
||||
})
|
||||
|
||||
it('Codex launcher opens the project with the codex command (with CR)', () => {
|
||||
const hooks = noopHooks()
|
||||
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
|
||||
;(card.querySelector('.proj-launch-codex') as HTMLButtonElement).click()
|
||||
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'codex\r')
|
||||
})
|
||||
|
||||
it('still lists running sessions above the launcher row', () => {
|
||||
const p = makeProject({
|
||||
sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }],
|
||||
})
|
||||
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelector('.proj-sessions-list')).not.toBeNull()
|
||||
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('highlights the Claude launcher when a Claude session (known status) is live', () => {
|
||||
const p = makeProject({
|
||||
sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }],
|
||||
})
|
||||
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(true)
|
||||
// Codex / VS Code are not highlighted by a Claude session.
|
||||
expect(card.querySelector('.proj-launch-codex')!.classList.contains('proj-launch-active')).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT highlight Claude for an unknown-status session (plain shell / codex)', () => {
|
||||
const p = makeProject({
|
||||
sessions: [{ id: 's1', status: 'unknown', clientCount: 0, createdAt: 1, exited: false }],
|
||||
})
|
||||
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT highlight Claude for an exited session', () => {
|
||||
const p = makeProject({
|
||||
sessions: [{ id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: true }],
|
||||
})
|
||||
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a count badge when more than one Claude session is live', () => {
|
||||
const p = makeProject({
|
||||
sessions: [
|
||||
{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false },
|
||||
{ id: 's2', status: 'idle', clientCount: 0, createdAt: 2, exited: false },
|
||||
],
|
||||
})
|
||||
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user