diff --git a/apps/desktop/src/main/__tests__/build-info.test.ts b/apps/desktop/src/main/__tests__/build-info.test.ts new file mode 100644 index 0000000000..8be020f826 --- /dev/null +++ b/apps/desktop/src/main/__tests__/build-info.test.ts @@ -0,0 +1,128 @@ +/** + * The build stamp exists to tell a locally built tree apart from a release. + * A linked worktree is the checkout where that matters most — it is the one + * whose HEAD differs from the tree a developer thinks they are running — and + * it is also the one whose `.git` is a file rather than a directory, so a + * resolver that only reads `/.git/HEAD` finds nothing there and the + * stamp falls back to a bare version number. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { resolveBuildInfo } from '../build-info.js'; + +const roots: string[] = []; + +after(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); + +const SHA = '4d223d05beea1bfa8af3b4bb4fe1211a9e93acfe'; + +test('a packaged build reports no commit and does not read the filesystem for one', () => { + // `app.isPackaged` is the whole answer: a packaged tree has no repository, + // and a commit resolved from one next to it would describe the wrong thing. + assert.deepEqual(resolveBuildInfo(true, process.cwd()), { mode: 'packaged', commit: null }); +}); + +test('an ordinary clone resolves the commit through a ref', async () => { + const root = await makeRoot(); + await seedGitDir(join(root, '.git'), { head: 'ref: refs/heads/main', refs: { 'refs/heads/main': SHA } }); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a linked worktree reads HEAD privately and the branch ref from commondir', async () => { + // The real layout, reproduced from `git worktree add`: `.git` is a FILE + // holding `gitdir: `; the admin directory holds this worktree's + // own HEAD plus a `commondir` pointer, and NOTHING under `refs/heads`. The + // branch ref lives in the common Git directory shared with the main + // checkout. A resolver that looks for the ref beside HEAD finds nothing. + const root = await makeRoot(); + const commonDir = join(root, 'main-repo', '.git'); + const adminDir = join(commonDir, 'worktrees', 'linked'); + await seedGitDir(commonDir, { head: 'ref: refs/heads/main', refs: { 'refs/heads/main': SHA } }); + await mkdir(adminDir, { recursive: true }); + await writeFile(join(adminDir, 'HEAD'), 'ref: refs/heads/feature\n'); + await writeFile(join(adminDir, 'commondir'), '../..\n'); + // The branch ref exists only in the common directory, as Git writes it. + await mkdir(join(commonDir, 'refs', 'heads'), { recursive: true }); + await writeFile(join(commonDir, 'refs', 'heads', 'feature'), `${SHA}\n`); + const checkout = join(root, 'linked'); + await mkdir(checkout, { recursive: true }); + await writeFile(join(checkout, '.git'), `gitdir: ${adminDir}\n`); + assert.deepEqual(resolveBuildInfo(false, checkout), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a worktree finds a packed branch ref in the common directory', async () => { + // Same split, but the ref is packed. `packed-refs` is shared too. + const root = await makeRoot(); + const commonDir = join(root, 'main-repo', '.git'); + const adminDir = join(commonDir, 'worktrees', 'linked'); + await mkdir(commonDir, { recursive: true }); + await writeFile(join(commonDir, 'packed-refs'), `# pack-refs with: peeled\n${SHA} refs/heads/feature\n`); + await mkdir(adminDir, { recursive: true }); + await writeFile(join(adminDir, 'HEAD'), 'ref: refs/heads/feature\n'); + await writeFile(join(adminDir, 'commondir'), '../..\n'); + const checkout = join(root, 'linked'); + await mkdir(checkout, { recursive: true }); + await writeFile(join(checkout, '.git'), `gitdir: ${adminDir}\n`); + assert.deepEqual(resolveBuildInfo(false, checkout), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a worktree pointer relative to the checkout resolves', async () => { + // Git writes a relative pointer for a worktree created inside the repo. + const root = await makeRoot(); + await seedGitDir(join(root, 'nested', 'git-dir'), { + head: 'ref: refs/heads/main', + refs: { 'refs/heads/main': SHA }, + }); + await writeFile(join(root, '.git'), 'gitdir: nested/git-dir\n'); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a detached HEAD is already the sha', async () => { + const root = await makeRoot(); + await seedGitDir(join(root, '.git'), { head: SHA, refs: {} }); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a packed ref resolves when no loose ref file exists', async () => { + const root = await makeRoot(); + const gitDir = join(root, '.git'); + await seedGitDir(gitDir, { head: 'ref: refs/heads/main', refs: {} }); + await writeFile(join(gitDir, 'packed-refs'), `# pack-refs with: peeled\n${SHA} refs/heads/main\n`); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('an unreadable checkout reports dev with no commit rather than throwing', async () => { + // Absence is a supported answer: the stamp shows the version alone. A throw + // here would fail `app:info`, which carries far more than the commit. + const root = await makeRoot(); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: null }); + + const dangling = await makeRoot(); + await writeFile(join(dangling, '.git'), 'gitdir: /nowhere/that/exists\n'); + assert.deepEqual(resolveBuildInfo(false, dangling), { mode: 'dev', commit: null }); +}); + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-build-info-')); + roots.push(root); + return root; +} + +async function seedGitDir( + gitDir: string, + input: { head: string; refs: Readonly> }, +): Promise { + await mkdir(gitDir, { recursive: true }); + await writeFile(join(gitDir, 'HEAD'), `${input.head}\n`); + for (const [ref, sha] of Object.entries(input.refs)) { + const refPath = join(gitDir, ...ref.split('/')); + await mkdir(join(refPath, '..'), { recursive: true }); + await writeFile(refPath, `${sha}\n`); + } +} diff --git a/apps/desktop/src/main/build-info.ts b/apps/desktop/src/main/build-info.ts index f7c21da692..4d8a2704b6 100644 --- a/apps/desktop/src/main/build-info.ts +++ b/apps/desktop/src/main/build-info.ts @@ -16,8 +16,8 @@ * One-shot — captured at module load and cached. */ -import { readFileSync, existsSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; export interface BuildInfo { readonly mode: 'dev' | 'packaged'; @@ -38,16 +38,74 @@ function findRepoRoot(start: string): string | null { return null; } +/** + * The directory holding this checkout's Git metadata. + * + * `.git` is a directory in an ordinary clone and a **file** in a linked + * worktree, where it holds `gitdir: ` pointing at + * `
/.git/worktrees/`. Reading `/.git/HEAD` therefore finds + * nothing in a worktree — and a worktree is exactly the checkout whose commit + * a developer most needs to see, since it is the one that differs from the + * tree they think they are running. + * + * A relative `gitdir:` is resolved against the checkout, which is how Git + * writes it for a worktree created inside the repository. + */ +function resolveGitDir(repoRoot: string): string | null { + const dotGit = join(repoRoot, '.git'); + try { + if (statSync(dotGit).isDirectory()) return dotGit; + } catch { + return null; + } + try { + const pointer = readFileSync(dotGit, 'utf8').trim(); + if (!pointer.startsWith('gitdir:')) return null; + const target = pointer.slice('gitdir:'.length).trim(); + if (!target) return null; + return isAbsolute(target) ? target : resolve(repoRoot, target); + } catch { + return null; + } +} + +/** + * The Git directory holding refs for this checkout. + * + * A linked worktree splits its Git directory in two: the private admin + * directory named by `.git` holds that worktree's own `HEAD` and index, while + * loose refs and `packed-refs` stay in the *common* directory shared with the + * main checkout. `commondir` inside the admin directory names it, relative to + * the admin directory itself. + * + * Reading a branch ref from the admin directory therefore finds nothing on a + * real branch-linked worktree, and the build stamp loses its commit. + */ +function resolveCommonDir(gitDir: string): string { + try { + const pointer = readFileSync(join(gitDir, 'commondir'), 'utf8').trim(); + if (!pointer) return gitDir; + return isAbsolute(pointer) ? pointer : resolve(gitDir, pointer); + } catch { + // An ordinary clone has no `commondir`; its own directory is the common one. + return gitDir; + } +} + function resolveCommit(repoRoot: string): string | null { + const gitDir = resolveGitDir(repoRoot); + if (!gitDir) return null; + // HEAD is per-worktree; refs are shared. + const commonDir = resolveCommonDir(gitDir); try { - const headPath = join(repoRoot, '.git', 'HEAD'); + const headPath = join(gitDir, 'HEAD'); if (!existsSync(headPath)) return null; const head = readFileSync(headPath, 'utf8').trim(); if (head.startsWith('ref: ')) { - const refPath = join(repoRoot, '.git', head.slice(5).trim()); + const refPath = join(commonDir, head.slice(5).trim()); if (!existsSync(refPath)) { // Packed refs path — read packed-refs and match the ref name. - const packed = join(repoRoot, '.git', 'packed-refs'); + const packed = join(commonDir, 'packed-refs'); if (!existsSync(packed)) return null; const target = head.slice(5).trim(); const lines = readFileSync(packed, 'utf8').split('\n'); diff --git a/apps/desktop/src/renderer/app-shell-build-stamp.ts b/apps/desktop/src/renderer/app-shell-build-stamp.ts new file mode 100644 index 0000000000..f41779db88 --- /dev/null +++ b/apps/desktop/src/renderer/app-shell-build-stamp.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import type { SidebarBuildStamp } from '@maka/ui'; + +/** + * The running build, for the rail's footer. + * + * Read once at mount and never again: neither the version nor the commit can + * change without the process restarting, so a subscription would re-render for + * a value that cannot move. + * + * Failure is silent and the stamp is simply absent. This is an orientation + * label — a rail that shows nothing is strictly better than one that shows an + * error where a version belongs, and the About page reports the same failure + * where a user went looking for it. + */ +export function useBuildStamp(): SidebarBuildStamp | undefined { + const [stamp, setStamp] = useState(undefined); + + useEffect(() => { + let cancelled = false; + window.maka.app + .info() + .then((info) => { + if (cancelled) return; + setStamp({ version: info.appVersion, commit: info.buildCommit }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + return stamp; +} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 4e854995c8..0479e61bea 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -146,6 +146,7 @@ import { modelSetupToastCopy } from './model-connection-errors'; import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { AppShellTopbarActions, AppShellWorkspaceTopActions } from './app-shell-chrome-actions'; import { updateReminderFromStatus } from './app-shell-app-update'; +import { useBuildStamp } from './app-shell-build-stamp'; import { AppShellDetailPanel } from './app-shell-detail-panel'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; @@ -602,6 +603,7 @@ function AppShellContent({ }, []); const updateReminder = updateReminderFromStatus(appUpdateStatus); + const buildStamp = useBuildStamp(); // Dispatches on the task, not on the raw status: the footer is this // callback's only caller and it only renders for the two states above, so // reading the status again here would be the same "who needs the user" list @@ -3031,6 +3033,7 @@ function AppShellContent({ onSelect={setNavSelection} onSelectSession={sessionListSelectSession} onOpenSettings={openSettings} + buildStamp={buildStamp} updateReminder={updateReminder} onOpenUpdate={openUpdateDownload} onNew={createSession} diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 83a8d94a86..842d62b5a6 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -166,8 +166,18 @@ } .maka-sidebar-footer-row-primary { - flex: 1; - min-width: 0; + /* `auto` rather than `1 1 0`: the settings row asks for the width its label + needs and gives back the rest, so the stamp shrinks into what is left + instead of the label being clipped first. + + `min-content`, not `0`: with `min-width: 0` BOTH items are free to shrink, + so at the 180px minimum with an update reminder present the Settings label + could still pick up an ellipsis even though the stamp had room left to + give. Flooring this row at its content width makes the priority + deterministic — the stamp is the only thing that truncates. The stamp + keeps `min-width: 0`, so it can still yield all the way down. */ + flex: 1 1 auto; + min-width: min-content; } /* Stacked, the row stretches its children to the rail's width so the settings @@ -177,6 +187,36 @@ align-self: center; } +/* A label, not a control: it takes no pointer events and no focus, so tabbing + through the rail still goes Settings -> update button as it did before. */ +.maka-sidebar-build-stamp { + /* The stamp yields first. At the 180px minimum width with an update button + present, something has to give, and it should be the label a user is + reading rather than the row they are aiming at — Settings keeps its text + and its hit target, the stamp ellipsises, and its `title` still carries + the full build. */ + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-tertiary); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + white-space: nowrap; + pointer-events: none; + user-select: none; + -webkit-app-region: no-drag; +} + +/* Collapsed, the rail is 48px and the footer stacks. `v0.1.11 · a1b2c3d` does + not fit and would either clip or force the rail wider, so it drops — the + About page still carries it, and a collapsed rail is a deliberate ask for + less. */ +.appFrame[data-sidebar-state="collapsed"] .maka-sidebar-build-stamp { + display: none; +} + .maka-sidebar-update-button { flex: none; /* Astryx has no shape prop — `isIconOnly` is documented as rendering a diff --git a/packages/ui/src/__tests__/sidebar-build-stamp.test.tsx b/packages/ui/src/__tests__/sidebar-build-stamp.test.tsx new file mode 100644 index 0000000000..cc52c37681 --- /dev/null +++ b/packages/ui/src/__tests__/sidebar-build-stamp.test.tsx @@ -0,0 +1,69 @@ +/** + * The rail footer answers "which build is this?" — the question the About + * page already answers two clicks away, which is why it was worth putting + * where the user already looks. + * + * Both halves are asserted. A dev build and a release build render different + * strings, and the stamp keeps its place when the update button appears: the + * button is an action and takes the edge, the stamp is a label and must not + * move under it. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionSidebarFooter, type SidebarBuildStamp } from '../session-sidebar-nav.js'; + +function renderFooter( + buildStamp?: SidebarBuildStamp, + updateReminder?: { state: 'downloaded' | 'error'; latestVersion: string }, +): string { + return renderToStaticMarkup( + + undefined} + onOpenUpdate={() => undefined} + /> + , + ); +} + +function stampText(markup: string): string | null { + const match = /class="maka-sidebar-build-stamp"[^>]*>([^<]*) { + // `build-info.ts` returns `commit: null` once packaged — there is no `.git` + // to read — so the version is the whole identity a release can offer. + assert.equal(stampText(renderFooter({ version: '0.1.11', commit: null })), 'v0.1.11'); +}); + +test('a dev build appends the short commit, which is what distinguishes two of them', () => { + assert.equal( + stampText(renderFooter({ version: '0.1.11', commit: '4d223d05beea1bfa' })), + 'v0.1.11 · 4d223d0', + ); +}); + +test('no stamp renders before the build info resolves', () => { + // The value arrives over async IPC. Rendering a placeholder would put a + // wrong version on screen for the time it takes to be replaced. + assert.equal(stampText(renderFooter(undefined)), null); +}); + +test('the update button takes the edge and the stamp keeps its place', () => { + const markup = renderFooter({ version: '0.1.11', commit: null }, { + state: 'downloaded', + latestVersion: '0.1.12', + }); + const stampAt = markup.indexOf('maka-sidebar-build-stamp'); + const buttonAt = markup.indexOf('maka-sidebar-update-button'); + assert.ok(stampAt > 0, 'the stamp renders alongside an update reminder'); + assert.ok(buttonAt > 0, 'the update button still renders'); + // Order in the flex row is order in the markup: settings, stamp, button. + assert.ok(stampAt < buttonAt, 'the stamp precedes the update button'); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 20e0d7c83e..b5e7ad73dd 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -10,7 +10,7 @@ export type { ModuleHubHeader } from './module-hub-selector.js'; export { SearchModal } from './search-modal.js'; export { SessionListPanel } from './session-list-panel.js'; export type { SessionViewMode } from './session-list-panel.js'; -export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; +export type { SidebarBuildStamp, SidebarUpdateReminder } from './session-sidebar-nav.js'; export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js'; export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js'; diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 6edde2ba01..c14f077cda 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -12,7 +12,12 @@ import { type SessionHistoryGroup, type SessionRowActions, } from './session-history-list.js'; -import { SessionSidebarFooter, SessionSidebarNav, type SidebarUpdateReminder } from './session-sidebar-nav.js'; +import { + SessionSidebarFooter, + SessionSidebarNav, + type SidebarBuildStamp, + type SidebarUpdateReminder, +} from './session-sidebar-nav.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import type { Ref } from 'react'; @@ -49,6 +54,7 @@ export function SessionListPanel(props: { moduleMemory?: NavModuleMemory; onSelect(selection: NavSelection): void; onOpenSettings(): void; + buildStamp?: SidebarBuildStamp; updateReminder?: SidebarUpdateReminder; onOpenUpdate?(): void; onNew(): void; @@ -151,6 +157,7 @@ export function SessionListPanel(props: { } footer={ + {stamp && ( + // Between Settings and the update button, not after it: the update + // button is an action and keeps the edge, where a control is + // reached. The stamp is a label — it reads on the way to that edge + // and never moves when the button appears or goes away. + + {stamp} + + )} {updateAction && ( string; updateDownloaded(version: string): string; updateFailed(version: string): string; pendingTasks(count: number): string; @@ -43,6 +45,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { automations: '定时任务', extensions: '扩展', settings: '设置', + buildStamp: (stamp: string) => `当前版本 ${stamp}`, updateDownloaded: (version: string) => `新版本 ${version} 已下载,重启后安装`, updateFailed: (version: string) => `新版本 ${version} 更新失败,点击重试或手动下载`, pendingTasks: (count: number) => `定时任务,${count} 条进行中`, @@ -74,6 +77,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { automations: 'Scheduled tasks', extensions: 'Extensions', settings: 'Settings', + buildStamp: (stamp: string) => `Current build ${stamp}`, updateDownloaded: (version: string) => `Update ${version} downloaded. Restart to install.`, updateFailed: (version: string) => `Update ${version} failed. Click to retry or download manually.`, pendingTasks: (count: number) => `Scheduled tasks, ${count} active`,