Files
web-terminal/docs/mockups/project-detail-git.html
Yaojia Wang 8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
    "I checked, ignore this"; getting it wrong is lying to the user.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: the remote is derived
    server-side and no remote or refspec is ever read from the body, so a
    client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00

440 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project detail — Git 面板设计稿</title>
<style>
/* ── tokens: 沿用 project-manager-final.html 的 amber 主题,不另起炉灶 ── */
:root{
--bg:#100f0d; --panel:#181613; --card:#1b1916; --sunk:#0c0b09;
--border:rgba(255,255,255,.07); --border-strong:rgba(255,255,255,.14);
--text:#ece9e3; --dim:#a8a299; --faint:#6f6a61;
--accent:#e3a64a; --accent-2:#c9892f; --soft:rgba(227,166,74,.14); --line:rgba(227,166,74,.36);
--ink:#1a1305;
/* 语义色 —— 与 accent 分开,全部取自项目既有调色板 */
--dirty:#e0975a; /* 未提交 */
--warn:#cf6b4f; /* 可能在说谎 / 无 upstream(取自 clay 主题) */
--ok:#7d9b76; /* 已同步 */
--glow:rgba(227,166,74,.10);
--radius:9px; --radius-lg:14px;
--ui:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
/* 文档外壳(随浏览器主题走);内部 mock 画布恒为暗色 —— 它是一张产品截图 */
--doc-bg:#f6f4f0; --doc-text:#221f1a; --doc-dim:#6b655c; --doc-rule:rgba(0,0,0,.10);
--doc-card:#fffefb;
}
@media (prefers-color-scheme: dark){
:root{ --doc-bg:#0b0a09; --doc-text:#e8e5df; --doc-dim:#98928a;
--doc-rule:rgba(255,255,255,.10); --doc-card:#141310; }
}
:root[data-theme="dark"]{ --doc-bg:#0b0a09; --doc-text:#e8e5df; --doc-dim:#98928a;
--doc-rule:rgba(255,255,255,.10); --doc-card:#141310; }
:root[data-theme="light"]{ --doc-bg:#f6f4f0; --doc-text:#221f1a; --doc-dim:#6b655c;
--doc-rule:rgba(0,0,0,.10); --doc-card:#fffefb; }
*{box-sizing:border-box}
body{margin:0;background:var(--doc-bg);color:var(--doc-text);font-family:var(--ui);
-webkit-font-smoothing:antialiased;line-height:1.55}
.wrap{max-width:1000px;margin:0 auto;padding:56px 24px 96px;display:flex;flex-direction:column;gap:52px}
/* ── 文档头 ── */
.doc-head{display:flex;flex-direction:column;gap:10px}
.eyebrow{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--doc-dim);
font-family:var(--mono)}
h1{font-size:30px;line-height:1.2;margin:0;font-weight:640;text-wrap:balance;letter-spacing:-.01em}
.lede{margin:0;max-width:64ch;color:var(--doc-dim);font-size:15px}
.facts{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
.fact{font-family:var(--mono);font-size:12px;color:var(--doc-dim);
border:1px solid var(--doc-rule);border-radius:999px;padding:4px 11px;
font-variant-numeric:tabular-nums}
.fact b{color:var(--doc-text);font-weight:600}
/* ── 每个提案块 ── */
.block{display:flex;flex-direction:column;gap:14px}
.block-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;
border-bottom:1px solid var(--doc-rule);padding-bottom:10px}
.ord{font-family:var(--mono);font-size:12px;color:var(--accent-2);letter-spacing:.08em;
border:1px solid var(--line);border-radius:5px;padding:2px 7px;flex:none}
h2{font-size:19px;margin:0;font-weight:620;letter-spacing:-.005em}
.tag{font-size:11px;font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;
border-radius:999px;padding:3px 9px;flex:none}
.tag.new{color:var(--accent);background:var(--soft);border:1px solid var(--line)}
.tag.wire{color:var(--ok);background:rgba(125,155,118,.13);border:1px solid rgba(125,155,118,.34)}
.note{margin:0;color:var(--doc-dim);font-size:14.5px;max-width:70ch}
.note code{font-family:var(--mono);font-size:12.5px;background:var(--doc-rule);
padding:1px 5px;border-radius:4px;color:var(--doc-text)}
/* ── mock 画布:恒暗,模拟真实 app ── */
.mock{background:radial-gradient(760px 300px at 78% -30%, var(--glow), transparent 62%),var(--bg);
border:1px solid var(--border-strong);border-radius:var(--radius-lg);
padding:22px;color:var(--text);font-size:14px;overflow-x:auto;
box-shadow:0 14px 40px rgba(0,0,0,.34)}
.mock *{font-variant-numeric:tabular-nums}
.label{font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--faint);
margin-bottom:9px;display:flex;align-items:center;gap:10px}
.label .count{color:var(--dim);letter-spacing:.04em}
/* ── ① 同步状态条 ── */
.proj-line{display:flex;align-items:center;gap:11px;flex-wrap:wrap;margin-bottom:3px}
.proj-name{font-size:21px;font-weight:650;letter-spacing:-.01em}
.chip{font-family:var(--mono);font-size:11.5px;border-radius:6px;padding:3px 9px;
display:inline-flex;align-items:center;gap:6px;flex:none;white-space:nowrap}
.chip.branch{background:var(--soft);color:var(--accent);border:1px solid var(--line)}
.chip.dirty{background:rgba(224,151,90,.13);color:var(--dirty);border:1px solid rgba(224,151,90,.32)}
.chip.ok{background:rgba(125,155,118,.12);color:var(--ok);border:1px solid rgba(125,155,118,.30)}
.chip.warn{background:rgba(207,107,79,.14);color:var(--warn);border:1px solid rgba(207,107,79,.36)}
.chip.mute{background:var(--sunk);color:var(--faint);border:1px solid var(--border)}
.path{font-family:var(--mono);font-size:12px;color:var(--faint);margin-bottom:16px}
.sync{display:flex;align-items:stretch;gap:0;background:var(--sunk);
border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.sync-cell{padding:12px 18px;display:flex;flex-direction:column;gap:3px;
border-right:1px solid var(--border);min-width:0}
.sync-cell:last-child{border-right:0;margin-left:auto;justify-content:center}
.sync-k{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint)}
.sync-v{font-family:var(--mono);font-size:15px;display:flex;align-items:center;gap:7px}
.sync-v .big{font-size:19px;font-weight:600;letter-spacing:-.02em}
.up{color:var(--accent)} .down{color:var(--dim)} .zero{color:var(--faint)}
.sub{font-family:var(--mono);font-size:11px;color:var(--faint)}
.sub.bad{color:var(--warn)}
.btn{all:unset;cursor:pointer;font-family:var(--ui);font-size:12.5px;font-weight:560;
padding:7px 15px;border-radius:7px;background:var(--accent);color:var(--ink);
text-align:center;white-space:nowrap}
.btn:focus-visible{outline:2px solid var(--text);outline-offset:2px}
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--border-strong)}
/* ── ② commits + push 分界 ── */
.commits{display:flex;flex-direction:column}
.c-row{display:grid;grid-template-columns:22px 74px 52px 1fr;align-items:baseline;
gap:10px;padding:4px 8px;border-radius:5px;position:relative}
.c-row.unpushed{background:linear-gradient(90deg,var(--soft),transparent 55%)}
.c-row.unpushed::before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;
background:var(--accent);border-radius:2px}
.c-mark{font-family:var(--mono);font-size:12px;color:var(--accent);text-align:center}
.c-sha{font-family:var(--mono);font-size:12.5px;color:var(--accent-2)}
.c-age{font-family:var(--mono);font-size:11.5px;color:var(--faint);text-align:right}
.c-msg{font-size:13.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.c-row:not(.unpushed) .c-msg{color:var(--dim)}
.boundary{display:flex;align-items:center;gap:12px;margin:9px 0 7px;padding-left:8px}
.boundary .rule{flex:1;height:1px;background:var(--border-strong)}
.boundary .who{font-family:var(--mono);font-size:11px;color:var(--dim);letter-spacing:.04em;
background:var(--sunk);border:1px solid var(--border);border-radius:999px;padding:3px 11px}
.more{font-family:var(--mono);font-size:11.5px;color:var(--faint);padding:9px 8px 0}
/* ── ③ worktrees ── */
.wt{display:flex;flex-direction:column;gap:8px}
.wt-row{display:grid;grid-template-columns:1fr auto;gap:14px;align-items:center;
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:12px 15px}
.wt-row.current{border-color:var(--line);background:linear-gradient(90deg,var(--soft),var(--card) 46%)}
.wt-main{display:flex;flex-direction:column;gap:5px;min-width:0}
.wt-top{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.wt-branch{font-family:var(--mono);font-size:13.5px;font-weight:600;color:var(--text)}
.wt-path{font-family:var(--mono);font-size:11.5px;color:var(--faint);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wt-right{display:flex;align-items:center;gap:8px;flex:none}
.sess{font-size:11.5px;color:var(--dim);font-family:var(--mono);display:flex;align-items:center;gap:5px}
.sess .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:none}
.sess .dot.idle{background:var(--faint)}
/* ── ④ 状态变体 ── */
.states{display:grid;grid-template-columns:repeat(auto-fit,minmax(232px,1fr));gap:11px}
.state{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:13px 15px;display:flex;flex-direction:column;gap:8px}
.state-k{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint)}
.state-d{font-size:12px;color:var(--dim);line-height:1.45}
/* ── ⑤ 成本表 ── */
.tbl-wrap{overflow-x:auto;border:1px solid var(--doc-rule);border-radius:var(--radius)}
table{border-collapse:collapse;width:100%;font-size:13.5px;background:var(--doc-card);min-width:640px}
th,td{text-align:left;padding:11px 15px;border-bottom:1px solid var(--doc-rule);vertical-align:top}
th{font-size:11px;letter-spacing:.13em;text-transform:uppercase;color:var(--doc-dim);font-weight:600}
tr:last-child td{border-bottom:0}
td code{font-family:var(--mono);font-size:12px;color:var(--doc-text)}
td.status{white-space:nowrap;font-family:var(--mono);font-size:12px}
.yes{color:var(--ok)} .no{color:var(--warn)}
.foot{border-top:1px solid var(--doc-rule);padding-top:20px;color:var(--doc-dim);font-size:13.5px;
max-width:70ch}
.foot strong{color:var(--doc-text)}
@media (prefers-reduced-motion: reduce){ *{animation:none!important;transition:none!important} }
@media (max-width:720px){
.wrap{padding:36px 16px 64px;gap:40px}
.sync{flex-direction:column}
.sync-cell{border-right:0;border-bottom:1px solid var(--border)}
.sync-cell:last-child{margin-left:0}
.c-row{grid-template-columns:18px 66px 1fr;row-gap:0}
.c-age{display:none}
}
</style>
</head>
<body>
<div class="wrap">
<header class="doc-head">
<div class="eyebrow">web-terminal · 设计稿</div>
<h1>项目详情页 · Git 面板</h1>
<p class="lede">把「有没有 commit 没推」「现在在哪个 worktree」变成不用问就能看见的信息。
下面每一块都用 <code style="font-family:var(--mono);font-size:13px">web-terminal</code> 此刻的真实状态渲染 —— 包括那个正在说谎的 ↓0。</p>
<div class="facts">
<span class="fact">分支 <b>develop</b></span>
<span class="fact">未推送 <b>9</b></span>
<span class="fact">未提交 <b>3</b></span>
<span class="fact">上次 fetch <b>19 天前</b></span>
<span class="fact">worktree <b>2</b></span>
</div>
</header>
<!-- ───────────────── ① 同步状态条 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">01</span>
<h2>同步状态条</h2>
<span class="tag wire">接线即可</span>
</div>
<p class="note">替换现在标题行里那个孤零零的圆点。四个格子回答四个问题:我在哪个分支、有多少要推、有多少要拉、<b>这两个数字还能不能信</b></p>
<div class="mock">
<div class="proj-line">
<span class="proj-name">web-terminal</span>
<span class="chip branch">develop</span>
<span class="chip dirty">● 3 未提交</span>
</div>
<div class="path">/Users/yiukai/Documents/git/web-terminal</div>
<div class="sync">
<div class="sync-cell">
<span class="sync-k">Upstream</span>
<span class="sync-v"><span style="color:var(--dim)">origin/develop</span></span>
</div>
<div class="sync-cell">
<span class="sync-k">待推送</span>
<span class="sync-v up"><span class="big">↑ 9</span></span>
<span class="sub">9 个 commit 只在本地</span>
</div>
<div class="sync-cell">
<span class="sync-k">待拉取</span>
<span class="sync-v zero"><span class="big">↓ 0</span>
<span class="chip warn">存疑</span></span>
<span class="sub bad">上次 fetch 在 19 天前 — 这个 0 不可信</span>
</div>
<div class="sync-cell">
<button class="btn" type="button">Fetch</button>
</div>
</div>
</div>
<p class="note">「存疑」标记是这块最重要的设计。<code>ahead/behind</code> 比的是本地缓存的
<code>@{u}</code>,不 fetch 就永远不会变 —— 没有这个标记,面板会<b>自信地告诉你已经同步</b>,而这正是它此刻在做的事。
规则:<code>FETCH_HEAD</code> 的 mtime 超过阈值(建议 1 小时)就给 ↓ 挂上存疑,并把 Fetch 按钮点亮。
Fetch 是只读的,不 pull、不 merge —— 符合写侧一贯的克制。</p>
</section>
<!-- ───────────────── ② commits + push 分界线 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">02</span>
<h2>提交列表 · 推送分界线</h2>
<span class="tag new">新增</span>
</div>
<p class="note">你要的不是「有 9 个没推」这个数字,而是<b>哪 9 个</b>。复用现有的 commit 列表,给未推送的加左侧竖线和 ↑,在边界上画出 upstream 的位置 —— 一眼看完,零点击。</p>
<div class="mock">
<div class="label">Recent commits <span class="count">↑ 9 未推送</span></div>
<div class="commits">
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">553a00c</span><span class="c-age">8m</span><span class="c-msg">docs(progress): log the three leftover fixes + live verification</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">d92caed</span><span class="c-age">22m</span><span class="c-msg">Merge fix-tunnel-leftovers: gitignore-swallowed source, host identifiers in logs</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">2a602d5</span><span class="c-age">31m</span><span class="c-msg">fix(tunnel): close the three leftovers from the renewal-deadlock fix</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">befe677</span><span class="c-age">1h</span><span class="c-msg">Merge fix-cert-renew-deadlock: break the expired-leaf renewal deadlock</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">7064a39</span><span class="c-age">1h</span><span class="c-msg">chore: drop accidentally committed node_modules symlinks</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">0970c62</span><span class="c-age">1h</span><span class="c-msg">docs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">5509c81</span><span class="c-age">2h</span><span class="c-msg">fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">f3f4d8b</span><span class="c-age">2h</span><span class="c-msg">fix(tunnel): break the expired-leaf renewal deadlock</span></div>
<div class="c-row unpushed"><span class="c-mark"></span><span class="c-sha">b1bc50c</span><span class="c-age">3h</span><span class="c-msg">fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK</span></div>
<div class="boundary"><span class="who">origin/develop</span><span class="rule"></span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">1dbed54</span><span class="c-age">6d</span><span class="c-msg">docs(progress): log the zero-touch enrollment + control-panel session</span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">675de77</span><span class="c-age">9d</span><span class="c-msg">feat(control-panel): web admin UI for the zero-touch tunnel</span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">7c1d433</span><span class="c-age">10d</span><span class="c-msg">Merge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)</span></div>
<div class="c-row"><span class="c-mark"></span><span class="c-sha">0b35dc0</span><span class="c-age">10d</span><span class="c-msg">feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)</span></div>
</div>
<div class="more">显示最近 20 条</div>
</div>
<p class="note">分界线不是装饰 —— 它是 <code>origin/develop</code> 在时间轴上的真实位置。分支没有 upstream 时不画线,改在顶部显示「无 upstream」(见 ④)。</p>
</section>
<!-- ───────────────── ③ worktrees ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">03</span>
<h2>Worktree 面板</h2>
<span class="tag new">重做</span>
</div>
<p class="note">现在这块叫 BRANCH、只有一行。按「每个 session 一个 worktree」的规矩,它很快会变成 45 行,而且每一行要能独立回答:哪个分支、在哪、干净不干净、推没推、里面跑着谁。<b>这是编辑器给不了的视角</b> —— Cursor 一次只看得见一个 worktree。</p>
<div class="mock">
<div class="label">Worktrees <span class="count">2</span></div>
<div class="wt">
<div class="wt-row current">
<div class="wt-main">
<div class="wt-top">
<span class="wt-branch">develop</span>
<span class="chip mute">当前</span>
<span class="chip dirty">● 3</span>
<span class="chip branch">↑ 9</span>
</div>
<div class="wt-path">/Users/yiukai/Documents/git/web-terminal</div>
</div>
<div class="wt-right">
<span class="sess"><span class="dot"></span>2 sessions</span>
<button class="btn ghost" type="button">打开</button>
</div>
</div>
<div class="wt-row">
<div class="wt-main">
<div class="wt-top">
<span class="wt-branch">worktree-project-detail-git-mockup</span>
<span class="chip ok">干净</span>
<span class="chip warn">无 upstream</span>
</div>
<div class="wt-path">.claude/worktrees/project-detail-git-mockup</div>
</div>
<div class="wt-right">
<span class="sess"><span class="dot"></span>1 session</span>
<button class="btn ghost" type="button">打开</button>
</div>
</div>
</div>
</div>
<p class="note">「无 upstream」必须显式标出。新建的 worktree 分支天然没有 upstream,<code>ahead/behind</code> 全是
<code>undefined</code> —— 绝不能因为「没有数字」就渲染成绿色的「已同步」。这是这套 UI 最容易犯的错。</p>
</section>
<!-- ───────────────── ④ 状态变体 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">04</span>
<h2>状态变体</h2>
<span class="tag new">规格</span>
</div>
<p class="note">同步条的全部形态。做设计稿的意义一半在这里 —— 边界情况不定死,实现时就会各写各的。</p>
<div class="mock">
<div class="states">
<div class="state">
<span class="state-k">已同步 · 刚 fetch</span>
<div><span class="chip ok">✓ 已同步</span></div>
<span class="state-d">↑0 ↓0 且 fetch 在阈值内。唯一可以显示绿色的情况。</span>
</div>
<div class="state">
<span class="state-k">待推送</span>
<div><span class="chip branch">↑ 9</span></div>
<span class="state-d">主状态。数字用 accent,提交列表同步高亮。</span>
</div>
<div class="state">
<span class="state-k">待拉取</span>
<div><span class="chip mute">↓ 3</span></div>
<span class="state-d">用中性色而非警告色 —— 落后不是错误,只是信息。</span>
</div>
<div class="state">
<span class="state-k">数据陈旧</span>
<div><span class="chip warn">存疑</span></div>
<span class="state-d">FETCH_HEAD 超过 1 小时。挂在 ↓ 上,不挂在 ↑ 上 —— ↑ 不依赖 fetch,永远准。</span>
</div>
<div class="state">
<span class="state-k">无 upstream</span>
<div><span class="chip warn">无 upstream</span></div>
<span class="state-d">新 worktree 分支的默认状态。不显示 ↑↓,不显示绿色。</span>
</div>
<div class="state">
<span class="state-k">HEAD 游离</span>
<div><span class="chip warn">detached HEAD</span></div>
<span class="state-d">不显示分支名和 ↑↓,只显示短 sha。push 按钮禁用。</span>
</div>
</div>
</div>
<p class="note"><b>只有一个状态配用绿色:↑0 ↓0 且刚 fetch 过。</b>其余一律中性或警示 —— 绿色在这里的含义是「我确认过了,你不用管」,给错了就是骗人。</p>
</section>
<!-- ───────────────── ⑤ 数据来源与成本 ───────────────── -->
<section class="block">
<div class="block-head">
<span class="ord">05</span>
<h2>数据来源与成本</h2>
</div>
<p class="note">编号 = 建议实现顺序。01 和 02 共用同一份数据,应该一个 PR 一起做。</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>元素</th><th>数据来源</th><th>后端现状</th></tr>
</thead>
<tbody>
<tr>
<td>分支名</td>
<td><code>.git/HEAD</code>(不 spawn 进程)</td>
<td class="status yes">✓ 已有</td>
</tr>
<tr>
<td>↑ ahead / ↓ behind</td>
<td><code>git rev-list --count --left-right @{u}...HEAD</code></td>
<td class="status no">助手已有,<code>ProjectDetail</code> 没接</td>
</tr>
<tr>
<td>upstream 名字</td>
<td><code>git rev-parse --abbrev-ref @{u}</code></td>
<td class="status no"></td>
</tr>
<tr>
<td>未推送的 sha 集合</td>
<td><code>git log @{u}..HEAD --format=%H</code></td>
<td class="status no"></td>
</tr>
<tr>
<td>上次 fetch 时间</td>
<td><code>.git/FETCH_HEAD</code> 的 mtime(不 spawn)</td>
<td class="status no"></td>
</tr>
<tr>
<td>Fetch 按钮</td>
<td><code>git fetch --no-tags</code>,只读</td>
<td class="status no">缺(需新路由)</td>
</tr>
<tr>
<td>未提交数量</td>
<td><code>git status --porcelain</code> 的行数</td>
<td class="status no">只有布尔 <code>dirty</code>,没有计数</td>
</tr>
<tr>
<td>worktree 列表</td>
<td><code>git worktree list --porcelain</code></td>
<td class="status yes">✓ 已有</td>
</tr>
<tr>
<td>每个 worktree 的 dirty / ↑↓</td>
<td>同上,逐 worktree 跑一次</td>
<td class="status no">缺 — 成本最高的一项</td>
</tr>
</tbody>
</table>
</div>
<p class="note"><b>成本控制。</b>只有分支名和 fetch 时间是读文件、免费的;其余每项都要 spawn 一次 <code>git</code>,而这台机器同时在跑 Claude Code 和构建。三条便宜的对策:按仓库去重(N 个 tab 同一个 repo 只查一次)、盯
<code>.git/HEAD</code> / <code>.git/index</code> / <code>.git/refs</code> 的 mtime 变了才查、页面隐藏时暂停。逐 worktree 的 ↑↓ 建议懒加载 —— 展开那一行才算。</p>
</section>
<p class="foot"><strong>范围外。</strong>这一页不加 reset、不加切分支 checkout、不加 clean、不加 force-push。
stage / commit / push 已经在 <code style="font-family:var(--mono);font-size:12.5px">diff.ts</code> 里,维持现状。
这块面板的职责是<strong>告诉你发生了什么</strong>,不是替代编辑器去处理它。</p>
</div>
</body>
</html>