-
Notifications
You must be signed in to change notification settings - Fork 243
feat(desktop): show the running build on the sidebar footer #3430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| /** | ||
| * 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 `<root>/.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 resolves through its gitdir pointer', async () => { | ||
| // `.git` is a FILE here, holding `gitdir: <path>`. This is the case the | ||
| // resolver used to miss entirely. | ||
| const root = await makeRoot(); | ||
| const real = join(root, 'real-git-dir'); | ||
| await seedGitDir(real, { head: 'ref: refs/heads/feature', refs: { 'refs/heads/feature': SHA } }); | ||
| await writeFile(join(root, '.git'), `gitdir: ${real}\n`); | ||
| assert.deepEqual(resolveBuildInfo(false, root), { 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<string> { | ||
| const root = await mkdtemp(join(tmpdir(), 'maka-build-info-')); | ||
| roots.push(root); | ||
| return root; | ||
| } | ||
|
|
||
| async function seedGitDir( | ||
| gitDir: string, | ||
| input: { head: string; refs: Readonly<Record<string, string>> }, | ||
| ): Promise<void> { | ||
| 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`); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SidebarBuildStamp | undefined>(undefined); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
| window.maka.app | ||
| .info() | ||
| .then((info) => { | ||
| if (cancelled) return; | ||
| setStamp({ version: info.appVersion, commit: info.buildCommit }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Thanks for wiring the footer to the existing |
||
| }) | ||
| .catch(() => undefined); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); | ||
|
|
||
| return stamp; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -166,7 +166,10 @@ | |
| } | ||
|
|
||
| .maka-sidebar-footer-row-primary { | ||
| flex: 1; | ||
| /* `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. */ | ||
| flex: 1 1 auto; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] Let the build stamp yield before the Settings label This is much better than the previous fixed-width stamp. At the supported 180px width with an update reminder present, however, both the Settings row and the stamp can still shrink, so the Settings label may retain a slight ellipsis. Preserving the Settings row's minimum content width and letting only the stamp truncate would make the intended priority deterministic. The icon, action, and accessible name remain intact, so this is only polish. |
||
| min-width: 0; | ||
| } | ||
|
|
||
|
|
@@ -177,6 +180,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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <LocaleProvider locale="en"> | ||
| <SessionSidebarFooter | ||
| buildStamp={buildStamp} | ||
| updateReminder={updateReminder} | ||
| onOpenSettings={() => undefined} | ||
| onOpenUpdate={() => undefined} | ||
| /> | ||
| </LocaleProvider>, | ||
| ); | ||
| } | ||
|
|
||
| function stampText(markup: string): string | null { | ||
| const match = /class="maka-sidebar-build-stamp"[^>]*>([^<]*)</.exec(markup); | ||
| return match?.[1] ?? null; | ||
| } | ||
|
|
||
| test('a release build shows the version alone', () => { | ||
| // `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'); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Follow the worktree
commondirwhen resolving branch refsThanks for moving the fix into the existing
build-infoauthority. The.git → gitdirhop is now handled, but a normal branch-linked worktree has one more layer: its private admin directory containsHEADand acommondirfile, while the loose refs andpacked-refslive in the common Git directory.The current resolver still looks for the branch ref inside the private worktree admin directory, so real branch worktrees return
commit: null. The new fixture passes because it placesrefs/heads/featurein that private directory, which Git normally does not do.The clean fix is to keep reading
HEADfrom the worktree admin directory, resolvecommondir, and read loose or packed refs from the resulting common Git directory. Updating the existing fixture to match that real layout should be sufficient; no additional renderer or IPC path is needed.