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:
@@ -146,6 +146,22 @@
|
|||||||
- **commit**: <hash 或 N/A>
|
- **commit**: <hash 或 N/A>
|
||||||
======================================================= -->
|
======================================================= -->
|
||||||
|
|
||||||
|
### 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 <path>`(文件在主机,故在主机开;不分查看设备)。主机已确认装有 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 打磨 — 首页段控重设计 + 选择器隐藏键栏
|
### 2026-06-30 · v0.6 UI 打磨 — 首页段控重设计 + 选择器隐藏键栏
|
||||||
|
|
||||||
- **状态**: `[x]` DONE。415 测试全绿 · 双 typecheck 干净 · `build:web` OK · 真浏览器验证通过。
|
- **状态**: `[x]` DONE。415 测试全绿 · 双 typecheck 干净 · `build:web` OK · 真浏览器验证通过。
|
||||||
|
|||||||
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 type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js'
|
||||||
import { el, relTime } from './preview-grid.js'
|
import { el, relTime } from './preview-grid.js'
|
||||||
|
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
|
||||||
|
|
||||||
/* ── Constants ───────────────────────────────────────────────────────────── */
|
/* ── Constants ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ const REFRESH_MS = 5000
|
|||||||
/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */
|
/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */
|
||||||
|
|
||||||
export interface ProjectsHooks {
|
export interface ProjectsHooks {
|
||||||
/** Spawn a new claude session in the given repo. */
|
/** Spawn a new terminal session in the repo running `cmd` (default: claude). */
|
||||||
onOpenProject: (repoPath: string, repoName: string) => void
|
onOpenProject: (repoPath: string, repoName: string, cmd?: string) => void
|
||||||
/** Enter an already-running session by id. */
|
/** Enter an already-running session by id. */
|
||||||
onEnterSession: (id: string) => void
|
onEnterSession: (id: string) => void
|
||||||
}
|
}
|
||||||
@@ -169,7 +170,88 @@ function makeSessionRow(
|
|||||||
return row
|
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,
|
project: ProjectInfo,
|
||||||
favs: ReadonlySet<string>,
|
favs: ReadonlySet<string>,
|
||||||
hooks: ProjectsHooks,
|
hooks: ProjectsHooks,
|
||||||
@@ -212,27 +294,19 @@ function makeProjectCard(
|
|||||||
card.append(meta)
|
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)
|
const runningSessions = project.sessions.filter((s) => !s.exited)
|
||||||
|
if (runningSessions.length > 0) {
|
||||||
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
|
|
||||||
const sessionsList = el('div', 'proj-sessions-list')
|
const sessionsList = el('div', 'proj-sessions-list')
|
||||||
for (const sess of runningSessions) {
|
for (const sess of runningSessions) {
|
||||||
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession))
|
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession))
|
||||||
}
|
}
|
||||||
card.append(sessionsList)
|
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
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1077,35 +1077,83 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* "+ New" / "+ start claude here" action button */
|
/* "+ New" / "+ start claude here" action button */
|
||||||
.proj-new {
|
/* Launcher row — Claude · Codex · VS Code, each a brand-logo icon button. */
|
||||||
background: var(--surface-3);
|
.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);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-dim);
|
border-radius: 9px;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
color: var(--text-dim); /* drives the logo colour (currentColor) by default */
|
||||||
text-align: center;
|
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
|
||||||
transition: background 0.1s ease, color 0.1s ease, border-color 0.1s ease;
|
|
||||||
}
|
}
|
||||||
.proj-new:hover {
|
.proj-launch svg {
|
||||||
background: var(--accent-soft);
|
display: block;
|
||||||
color: var(--accent);
|
}
|
||||||
border-color: rgba(227, 166, 74, 0.3);
|
.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 */
|
/* Active state — a matching session is running (Claude logo when a Claude
|
||||||
.proj-new.proj-new-primary {
|
* session is live). Coral ring + faint tint draw the eye without shouting. */
|
||||||
background: var(--accent);
|
.proj-launch-active {
|
||||||
color: var(--on-accent);
|
background: rgba(217, 119, 87, 0.14);
|
||||||
border-color: var(--accent);
|
border-color: #d97757;
|
||||||
font-weight: 600;
|
box-shadow: 0 0 0 1px rgba(217, 119, 87, 0.45) inset;
|
||||||
}
|
}
|
||||||
.proj-new.proj-new-primary:hover {
|
.proj-launch-active .proj-launch-cap {
|
||||||
background: var(--accent-2);
|
color: #e9b9a6;
|
||||||
border-color: var(--accent-2);
|
}
|
||||||
color: var(--on-accent);
|
.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 */
|
/* Empty state */
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class TabApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.projects = mountProjects(this.paneHost, {
|
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),
|
onEnterSession: (id) => this.openSession(id),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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 ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -242,6 +243,7 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
DEFAULT_PROJECT_SCAN_TTL_MS,
|
DEFAULT_PROJECT_SCAN_TTL_MS,
|
||||||
)
|
)
|
||||||
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
|
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
|
||||||
|
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
port,
|
port,
|
||||||
@@ -263,5 +265,6 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
projectScanDepth,
|
projectScanDepth,
|
||||||
projectScanTtlMs,
|
projectScanTtlMs,
|
||||||
projectDirtyCheck,
|
projectDirtyCheck,
|
||||||
|
editorCmd,
|
||||||
} satisfies Config)
|
} satisfies Config)
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/http/editor.ts
Normal file
63
src/http/editor.ts
Normal file
@@ -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 `<cfg.editorCmd> <repoPath>` (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<OpenEditorResult> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import { isOriginAllowed } from './http/origin.js'
|
|||||||
import { parseHookEvent } from './http/hook.js'
|
import { parseHookEvent } from './http/hook.js'
|
||||||
import { listSessions } from './http/history.js'
|
import { listSessions } from './http/history.js'
|
||||||
import { buildProjects } from './http/projects.js'
|
import { buildProjects } from './http/projects.js'
|
||||||
|
import { openInEditor } from './http/editor.js'
|
||||||
import { createSessionManager } from './session/manager.js'
|
import { createSessionManager } from './session/manager.js'
|
||||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||||
import type { Config } from './types.js'
|
import type { Config } from './types.js'
|
||||||
@@ -217,6 +218,21 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
res.status(ok ? 204 : 404).end()
|
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<string, unknown>
|
||||||
|
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) ────────────────────────────────────
|
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
||||||
// Hooks running inside a spawned shell POST status here (loopback only — the
|
// 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.
|
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface Config {
|
|||||||
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
|
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
|
||||||
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
|
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
|
||||||
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
|
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
|
||||||
|
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host
|
||||||
}
|
}
|
||||||
|
|
||||||
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
|
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
|
||||||
|
|||||||
@@ -437,4 +437,11 @@ describe('loadConfig — project discovery (v0.6)', () => {
|
|||||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
||||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).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 }))
|
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
|
||||||
|
|
||||||
// Dynamic import AFTER mock declaration so the factory is in place.
|
// Dynamic import AFTER mock declaration so the factory is in place.
|
||||||
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject } = await import(
|
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } =
|
||||||
'../public/projects.js'
|
await import('../public/projects.js')
|
||||||
)
|
|
||||||
|
|
||||||
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -271,3 +270,86 @@ describe('normalizeProject', () => {
|
|||||||
expect(p?.isGit).toBe(false)
|
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