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:
27
public/icons.ts
Normal file
27
public/icons.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* public/icons.ts — brand logos for the project launcher buttons (v0.6).
|
||||
*
|
||||
* Single-path SVGs (viewBox 0 0 24 24, from simple-icons), rendered with
|
||||
* fill="currentColor" so each launcher button sets its own brand colour via CSS.
|
||||
* Strings are static + trusted (no user data) — safe to inject via innerHTML.
|
||||
*/
|
||||
|
||||
function logoSvg(path: string): string {
|
||||
return (
|
||||
'<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">' +
|
||||
'<path d="' + path + '"/></svg>'
|
||||
)
|
||||
}
|
||||
|
||||
export const CLAUDE_LOGO = logoSvg(
|
||||
'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z',
|
||||
)
|
||||
|
||||
// OpenAI mark — used for the Codex launcher.
|
||||
export const CODEX_LOGO = logoSvg(
|
||||
'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
|
||||
)
|
||||
|
||||
export const VSCODE_LOGO = logoSvg(
|
||||
'M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z',
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1077,35 +1077,83 @@ body {
|
||||
}
|
||||
|
||||
/* "+ New" / "+ start claude here" action button */
|
||||
.proj-new {
|
||||
background: var(--surface-3);
|
||||
/* Launcher row — Claude · Codex · VS Code, each a brand-logo icon button. */
|
||||
.proj-launchers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.proj-launch {
|
||||
position: relative; /* for the active count badge */
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 4px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
transition: background 0.1s ease, color 0.1s ease, border-color 0.1s ease;
|
||||
color: var(--text-dim); /* drives the logo colour (currentColor) by default */
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
|
||||
}
|
||||
.proj-new:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: rgba(227, 166, 74, 0.3);
|
||||
.proj-launch svg {
|
||||
display: block;
|
||||
}
|
||||
.proj-launch-cap {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.proj-launch:hover {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.proj-launch:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.proj-launch:hover .proj-launch-cap {
|
||||
color: var(--text);
|
||||
}
|
||||
/* Brand colours on the logos (currentColor) */
|
||||
.proj-launch-claude {
|
||||
color: #d97757; /* Claude coral */
|
||||
}
|
||||
.proj-launch-codex {
|
||||
color: #e7e8ec; /* OpenAI mark — near-white on dark */
|
||||
}
|
||||
.proj-launch-vscode {
|
||||
color: #3aa0e3; /* VS Code blue */
|
||||
}
|
||||
|
||||
/* Primary action — no sessions yet; full Amber accent button */
|
||||
.proj-new.proj-new-primary {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
/* Active state — a matching session is running (Claude logo when a Claude
|
||||
* session is live). Coral ring + faint tint draw the eye without shouting. */
|
||||
.proj-launch-active {
|
||||
background: rgba(217, 119, 87, 0.14);
|
||||
border-color: #d97757;
|
||||
box-shadow: 0 0 0 1px rgba(217, 119, 87, 0.45) inset;
|
||||
}
|
||||
.proj-new.proj-new-primary:hover {
|
||||
background: var(--accent-2);
|
||||
border-color: var(--accent-2);
|
||||
color: var(--on-accent);
|
||||
.proj-launch-active .proj-launch-cap {
|
||||
color: #e9b9a6;
|
||||
}
|
||||
.proj-launch-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: #d97757;
|
||||
color: #1a1408;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
|
||||
@@ -87,7 +87,7 @@ export class TabApp {
|
||||
}
|
||||
|
||||
this.projects = mountProjects(this.paneHost, {
|
||||
onOpenProject: (repoPath, repoName) => this.openProject(repoPath, repoName),
|
||||
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
|
||||
onEnterSession: (id) => this.openSession(id),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user