diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 81993df..3417a3f 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -146,6 +146,22 @@ - **commit**: ======================================================= --> +### 2026-06-30 · v0.6 项目卡启动器 — Claude · Codex · VS Code logo 按钮 + +- **状态**: `[x]` DONE。覆盖率 90.6/81.48/88.71/92.71(≥80×4)· 双 typecheck 干净 · `build:web` OK · 真浏览器 + 真 VS Code 端到端验证通过。 +- **需求**(用户): 把卡片上大的"+ start claude here"换成 **Claude logo**;新增 **VS Code logo**(点击在主机打开 VSC)与 **Codex logo** 按钮;logo 编排要"符合应用性"。 +- **设计**: 每张项目卡的动作区改为**启动器行**——3 个品牌 logo 图标按钮(logo 在上、小字标签在下、touch 友好、各带 tooltip),有运行中 session 时 session 行列在启动器上方。Claude=珊瑚色爆发、Codex=OpenAI blossom(近白)、Code=VS Code 蓝丝带;logo 用 `fill=currentColor`,颜色由 CSS 品牌色驱动。 +- **行为**: **Claude**→新终端 tab 跑 `claude`;**Codex**→新终端 tab 跑 `codex`(复用 `openProject(path,name,cmd)`,`onOpenProject` 加 `cmd?` 参数);**VS Code**→`POST /open-in-editor` 让**主机**跑 `code `(文件在主机,故在主机开;不分查看设备)。主机已确认装有 claude/codex/code。 +- **活跃高亮**(用户追加): 项目若有**活跃 Claude session**(运行中且 hook 状态 ≠ `unknown`——纯 shell/codex 保持 unknown 不算),Claude logo 按钮**珊瑚色高亮**(coral ring + 淡底 + 浅色标签);>1 个时角标显示数量。`activeClaudeCount(project)` 判定。 +- **改动**: + - 新增 `public/icons.ts`(simple-icons 的 Claude/OpenAI/VSCode 单路径 SVG,脚本生成避免手抄出错)。 + - `public/projects.ts`: `makeLauncherRow`/`makeLauncher` + `openProjectInEditor`(fetch `/open-in-editor`);`ProjectsHooks.onOpenProject` 加 `cmd?`;导出 `makeProjectCard` 便于测试;删旧 `.proj-new` 用法。`public/tabs.ts` 透传 cmd。`public/style.css` 新增 `.proj-launchers/.proj-launch*`(品牌色),删旧 `.proj-new` 规则。 + - 后端: `src/http/editor.ts` `openInEditor(cfg,path)`(校验绝对路径+存在+目录→`execFile`(无 shell)启动,best-effort,error 事件兜底+unref);`src/server.ts` `POST /open-in-editor`(Origin 守卫,与 DELETE 同档);`Config.editorCmd`(`EDITOR_CMD`,默认 `code`)入 types/config。 + - 测试: 新增 `test/editor.test.ts`(校验 400/404 + `editorCmd='true'` 成功 204);`config.test.ts` 加 editorCmd;`projects-panel.test.ts` 加 makeProjectCard 启动器行(3 按钮/logo/Claude·Codex cmd)。 +- **验证**: `npx tsc`(双)干净;`npx vitest run --coverage` 全绿(≥80×4);`build:web` OK。真端到端:curl `/open-in-editor` 无 Origin→403、缺/相对/不存在 path→400/400/404、合法 dir→204 且 **VS Code(Electron pid)实际打开**;真浏览器 Projects 视图每卡 3 logo 按钮(珊瑚/白/蓝,标签 Claude/Codex/Code,tooltip 正确)。 +- **安全**: `/open-in-editor` 状态变更→Origin 守卫(CSRF);`execFile` argv 传参无 shell 注入面;path 校验为绝对存在目录;不超出"应用本就给主机 shell"的既有威胁模型。命令来自 config 非用户。 +- **commit**: (本次提交) + ### 2026-06-30 · v0.6 UI 打磨 — 首页段控重设计 + 选择器隐藏键栏 - **状态**: `[x]` DONE。415 测试全绿 · 双 typecheck 干净 · `build:web` OK · 真浏览器验证通过。 diff --git a/public/icons.ts b/public/icons.ts new file mode 100644 index 0000000..8dcfd96 --- /dev/null +++ b/public/icons.ts @@ -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 ( + '' + ) +} + +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', +) diff --git a/public/projects.ts b/public/projects.ts index 257e90d..8bf2857 100644 --- a/public/projects.ts +++ b/public/projects.ts @@ -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 `). */ +async function openProjectInEditor(repoPath: string): Promise { + 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, 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 } diff --git a/public/style.css b/public/style.css index 3898fec..24403c9 100644 --- a/public/style.css +++ b/public/style.css @@ -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 */ diff --git a/public/tabs.ts b/public/tabs.ts index 7ec2459..f276032 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -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), }) diff --git a/src/config.ts b/src/config.ts index 1d0ee45..06d13fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,7 @@ const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL +const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host // ── helpers ─────────────────────────────────────────────────────────────────── @@ -242,6 +243,7 @@ export function loadConfig(env: EnvLike): Config { DEFAULT_PROJECT_SCAN_TTL_MS, ) const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true) + const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD return Object.freeze({ port, @@ -263,5 +265,6 @@ export function loadConfig(env: EnvLike): Config { projectScanDepth, projectScanTtlMs, projectDirtyCheck, + editorCmd, } satisfies Config) } diff --git a/src/http/editor.ts b/src/http/editor.ts new file mode 100644 index 0000000..0c98a1a --- /dev/null +++ b/src/http/editor.ts @@ -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 ` ` (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 { + 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) } + } +} diff --git a/src/server.ts b/src/server.ts index dcf8156..9e299a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,7 @@ import { isOriginAllowed } from './http/origin.js' import { parseHookEvent } from './http/hook.js' import { listSessions } from './http/history.js' import { buildProjects } from './http/projects.js' +import { openInEditor } from './http/editor.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' import type { Config } from './types.js' @@ -217,6 +218,21 @@ export function startServer(cfg: Config): { close(): Promise } { res.status(ok ? 204 : 404).end() }) + // Open a project in the host's desktop editor (v0.6 Projects panel — VS Code + // logo). State-changing (spawns a GUI process), so it carries the same Origin + // guard as the DELETE routes. The path is validated + passed via execFile (no + // shell) in openInEditor. + app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => { + if (!requireAllowedOrigin(req, res)) return + const body = (req.body ?? {}) as Record + const result = await openInEditor(cfg, body['path']) + if (result.ok) { + res.status(204).end() + return + } + res.status(result.status).json({ error: result.error }) + }) + // ── Claude Code hook side-channel (H2) ──────────────────────────────────── // Hooks running inside a spawned shell POST status here (loopback only — the // shell runs on the host). sessionId arrives in the X-Webterm-Session header. diff --git a/src/types.ts b/src/types.ts index 8b02ebe..ecaa976 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ export interface Config { readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4 readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache) readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo) + readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches ` ` on the host } /** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */ diff --git a/test/config.test.ts b/test/config.test.ts index 7870896..4a7400c 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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') + }) }) diff --git a/test/editor.test.ts b/test/editor.test.ts new file mode 100644 index 0000000..a96da7c --- /dev/null +++ b/test/editor.test.ts @@ -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) + }) +}) diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index ccbb699..d532990 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -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') + }) +})