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:
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js'
|
||||
import { el, relTime } from './preview-grid.js'
|
||||
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
|
||||
|
||||
/* ── Constants ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -24,8 +25,8 @@ const REFRESH_MS = 5000
|
||||
/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */
|
||||
|
||||
export interface ProjectsHooks {
|
||||
/** Spawn a new claude session in the given repo. */
|
||||
onOpenProject: (repoPath: string, repoName: string) => void
|
||||
/** Spawn a new terminal session in the repo running `cmd` (default: claude). */
|
||||
onOpenProject: (repoPath: string, repoName: string, cmd?: string) => void
|
||||
/** Enter an already-running session by id. */
|
||||
onEnterSession: (id: string) => void
|
||||
}
|
||||
@@ -169,7 +170,88 @@ function makeSessionRow(
|
||||
return row
|
||||
}
|
||||
|
||||
function makeProjectCard(
|
||||
/** Ask the host to open this project in the editor (POST /open-in-editor → `code <path>`). */
|
||||
async function openProjectInEditor(repoPath: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/open-in-editor', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: repoPath }),
|
||||
})
|
||||
if (!res.ok) console.error('[projects] open in editor failed:', res.status)
|
||||
} catch (err) {
|
||||
console.error('[projects] open in editor error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
interface LauncherOpts {
|
||||
/** Highlight the button (a matching session is running). */
|
||||
active?: boolean
|
||||
/** Optional count badge (only shown when > 1). */
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** One launcher button: brand logo (currentColor) + caption; colour set via CSS. */
|
||||
function makeLauncher(
|
||||
logo: string,
|
||||
label: string,
|
||||
cls: string,
|
||||
title: string,
|
||||
onClick: () => void,
|
||||
opts?: LauncherOpts,
|
||||
): HTMLElement {
|
||||
const active = opts?.active === true
|
||||
const btn = el('button', `proj-launch ${cls}${active ? ' proj-launch-active' : ''}`)
|
||||
btn.title = title
|
||||
btn.setAttribute('aria-label', title)
|
||||
btn.innerHTML = logo // static, trusted brand SVG — no user data
|
||||
btn.append(el('span', 'proj-launch-cap', label))
|
||||
if (active && opts?.count !== undefined && opts.count > 1) {
|
||||
btn.append(el('span', 'proj-launch-badge', String(opts.count)))
|
||||
}
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
})
|
||||
return btn
|
||||
}
|
||||
|
||||
/** Count running Claude sessions in a project. A session counts as "Claude" once
|
||||
* Claude Code hooks report a status (working/waiting/idle); plain shells and
|
||||
* codex sessions stay 'unknown'. Used to highlight the Claude launcher. */
|
||||
function activeClaudeCount(project: ProjectInfo): number {
|
||||
return project.sessions.filter((s) => !s.exited && s.status !== 'unknown').length
|
||||
}
|
||||
|
||||
/** Launcher row for a card: Claude · Codex (new terminal sessions) · VS Code (host editor). */
|
||||
function makeLauncherRow(project: ProjectInfo, hooks: ProjectsHooks): HTMLElement {
|
||||
const claude = activeClaudeCount(project)
|
||||
const claudeTitle =
|
||||
claude > 0
|
||||
? `${claude} active Claude session${claude > 1 ? 's' : ''} in ${project.name} — start another`
|
||||
: `Start Claude in ${project.name}`
|
||||
|
||||
const row = el('div', 'proj-launchers')
|
||||
row.append(
|
||||
makeLauncher(
|
||||
CLAUDE_LOGO,
|
||||
'Claude',
|
||||
'proj-launch-claude',
|
||||
claudeTitle,
|
||||
() => hooks.onOpenProject(project.path, project.name, 'claude\r'),
|
||||
{ active: claude > 0, count: claude },
|
||||
),
|
||||
makeLauncher(CODEX_LOGO, 'Codex', 'proj-launch-codex', `Start Codex in ${project.name}`, () =>
|
||||
hooks.onOpenProject(project.path, project.name, 'codex\r'),
|
||||
),
|
||||
makeLauncher(VSCODE_LOGO, 'Code', 'proj-launch-vscode', `Open ${project.name} in VS Code`, () =>
|
||||
void openProjectInEditor(project.path),
|
||||
),
|
||||
)
|
||||
return row
|
||||
}
|
||||
|
||||
export function makeProjectCard(
|
||||
project: ProjectInfo,
|
||||
favs: ReadonlySet<string>,
|
||||
hooks: ProjectsHooks,
|
||||
@@ -212,27 +294,19 @@ function makeProjectCard(
|
||||
card.append(meta)
|
||||
}
|
||||
|
||||
// ── Sessions area (1:N) ───────────────────────────────────────────────────
|
||||
// ── Sessions area (1:N) — list any running sessions above the launchers ─────
|
||||
const runningSessions = project.sessions.filter((s) => !s.exited)
|
||||
|
||||
if (runningSessions.length === 0) {
|
||||
// 0 running sessions: single primary "start claude here" action
|
||||
const newBtn = el('button', 'proj-new proj-new-primary', '+ start claude here')
|
||||
newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name))
|
||||
card.append(newBtn)
|
||||
} else {
|
||||
// 1+ running sessions: list session rows + always-present "+ New" button
|
||||
if (runningSessions.length > 0) {
|
||||
const sessionsList = el('div', 'proj-sessions-list')
|
||||
for (const sess of runningSessions) {
|
||||
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession))
|
||||
}
|
||||
card.append(sessionsList)
|
||||
|
||||
const newBtn = el('button', 'proj-new', '+ New')
|
||||
newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name))
|
||||
card.append(newBtn)
|
||||
}
|
||||
|
||||
// ── Launchers (Claude · Codex · VS Code) — always available ─────────────────
|
||||
card.append(makeLauncherRow(project, hooks))
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user