fix(projects): close the last four git-panel design gaps
The low-severity leftovers from the panel audit, plus the tooling trap that made reviewing this work harder than it needed to be. .gitattributes (new) — `public/projects.ts diff` That file embeds literal NUL bytes on purpose: the namespace sentinels '\0active' / '\0other' are prefixed so they cannot collide with a real `First.Second` key. git therefore classified it as binary and printed "Binary files differ" for every diff, and `git diff --stat` reported "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively unreviewable. The `diff` attribute makes git diff it as text without setting `text`, so no line endings are normalised and the bytes on disk are untouched. (Same trap in the shell: grep prints only "Binary file ... matches", which reads as a no-match. The comment in the file says to use grep -a.) Detached HEAD now shows its short sha The Head cell held the "detached HEAD" chip and nothing else: it said what was wrong and nothing about where you were. SyncState gains an optional `head`, populated from the .git/HEAD text readSyncState ALREADY reads for the detached test — so this costs no extra I/O and no extra git call. Guarded by a hex regex and rendered as textContent. One geometry for the ghost button role The design defines two button roles. The outline one had drifted to three different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at 12.5px/7px 15px/7px) and both used full-strength --text instead of the muted label. All four outline buttons are now declared once, together, at the design's geometry; the duplicate .proj-diff-toggle block further down the file is gone, since a second block at equal specificity is exactly how the role drifted in the first place. Header branch chip joins the chip language .proj-branch was the only chip with no border and no mono face, and 1px tighter all round, so the detail header's branch did not match the branch chips in the worktree list right below it. "Changes" heading over the View Diff toggle Every other block on the page is introduced by a section heading. Without one the toggle hung 4px off the bottom of the sync band and read as part of it. Tests: 2167 unit (4 new — detached sha present/absent on both the server and the panel) and 27 integration, twice.
This commit is contained in:
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# public/projects.ts embeds literal NUL bytes on purpose: the namespace-group
|
||||||
|
# sentinels ACTIVE_GROUP_KEY = '\0active' and OTHER_GROUP_KEY = '\0other' are
|
||||||
|
# prefixed with NUL so they can never collide with a real `First.Second` key.
|
||||||
|
#
|
||||||
|
# git classifies any file containing NUL as binary, so `git diff`/`git show`
|
||||||
|
# print "Binary files differ" and review of this file is impossible. The `diff`
|
||||||
|
# attribute tells git to diff it as text anyway. It does NOT set `text`, so
|
||||||
|
# nothing is line-ending-normalised and the bytes on disk are untouched.
|
||||||
|
#
|
||||||
|
# Same trap in the shell: `grep` prints only "Binary file ... matches" and looks
|
||||||
|
# like a no-match. Use `grep -a` on this file.
|
||||||
|
public/projects.ts diff
|
||||||
Binary file not shown.
@@ -1541,13 +1541,17 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Branch chip */
|
/* Branch chip — same geometry and border as every other chip in the panel. It was
|
||||||
|
the odd one out: no border, no mono face, and 1px tighter all round, so the
|
||||||
|
detail header's branch did not match the branch chips in the worktree list. */
|
||||||
.proj-branch {
|
.proj-branch {
|
||||||
font-size: 11px;
|
font-family: var(--mono-font);
|
||||||
|
font-size: 11.5px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
border-radius: 5px;
|
border: 1px solid rgba(227, 166, 74, 0.36);
|
||||||
padding: 2px 7px;
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
@@ -1806,18 +1810,22 @@ body {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The design has exactly two button roles: filled accent for the action that
|
/* Role 2 of 2 — the ghost. The design has exactly two button roles: filled accent
|
||||||
commits a form (Fetch, Create Worktree), and this ghost for anything that only
|
for the action that commits a form (Fetch, Create Worktree), and this outline for
|
||||||
opens or reveals. Everything in a worktree's action rail is a ghost — including
|
anything that only opens, reveals or toggles. ONE geometry for every one of them:
|
||||||
the ✕, which previously had no rule at all and rendered as a raw grey OS button. */
|
the role had drifted to three different sizes across the panel, all at
|
||||||
|
full-strength --text instead of the muted label the design asks for.
|
||||||
|
Every outline button in the panel is listed here, and nowhere else, so a new one
|
||||||
|
cannot quietly pick its own size again. */
|
||||||
.proj-wt-open,
|
.proj-wt-open,
|
||||||
.proj-wt-remove,
|
.proj-wt-remove,
|
||||||
.proj-wt-prune {
|
.proj-wt-prune,
|
||||||
|
.proj-diff-toggle {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 11.5px;
|
font-size: 12.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 12px;
|
padding: 7px 15px;
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1825,24 +1833,27 @@ body {
|
|||||||
|
|
||||||
.proj-wt-open:hover,
|
.proj-wt-open:hover,
|
||||||
.proj-wt-remove:hover,
|
.proj-wt-remove:hover,
|
||||||
.proj-wt-prune:hover {
|
.proj-wt-prune:hover,
|
||||||
|
.proj-diff-toggle:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.proj-wt-open:focus-visible,
|
.proj-wt-open:focus-visible,
|
||||||
.proj-wt-remove:focus-visible,
|
.proj-wt-remove:focus-visible,
|
||||||
.proj-wt-prune:focus-visible {
|
.proj-wt-prune:focus-visible,
|
||||||
|
.proj-diff-toggle:focus-visible {
|
||||||
outline: 2px solid var(--text);
|
outline: 2px solid var(--text);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Square icon variant — same role, just not a word. Height matches Open so the
|
/* Square icon variant — same role, just not a word. Keeps the shared height
|
||||||
two sit flush in the rail. */
|
(12.5px line + 7px padding twice) so it sits flush beside Open in the rail
|
||||||
|
rather than stretching the row. */
|
||||||
.proj-wt-remove {
|
.proj-wt-remove {
|
||||||
width: 26px;
|
width: 33px;
|
||||||
padding: 0;
|
padding: 7px 0;
|
||||||
line-height: 24px;
|
line-height: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
@@ -2513,26 +2524,12 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Role 2 — ghost, for anything that only reveals or toggles. */
|
/* Role 2 (the ghost, incl. .proj-diff-toggle) is defined once, with the worktree
|
||||||
.proj-diff-toggle {
|
action rail — see "Role 2 of 2" above. Nothing for it here on purpose: a second
|
||||||
font: inherit;
|
block at equal specificity is how the role drifted to three sizes before. */
|
||||||
font-size: 12.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 7px 15px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proj-diff-toggle:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proj-wt-submit:focus-visible,
|
.proj-wt-submit:focus-visible,
|
||||||
.proj-fanout-submit:focus-visible,
|
.proj-fanout-submit:focus-visible {
|
||||||
.proj-diff-toggle:focus-visible {
|
|
||||||
outline: 2px solid var(--text);
|
outline: 2px solid var(--text);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,7 +209,14 @@ async function readSyncState(repoPath: string): Promise<SyncState> {
|
|||||||
if (counts.ahead !== undefined) state.ahead = counts.ahead
|
if (counts.ahead !== undefined) state.ahead = counts.ahead
|
||||||
if (counts.behind !== undefined) state.behind = counts.behind
|
if (counts.behind !== undefined) state.behind = counts.behind
|
||||||
if (lastFetchMs !== undefined) state.lastFetchMs = lastFetchMs
|
if (lastFetchMs !== undefined) state.lastFetchMs = lastFetchMs
|
||||||
if (headText !== null && parseGitHead(headText) === null) state.detached = true
|
if (headText !== null && parseGitHead(headText) === null) {
|
||||||
|
state.detached = true
|
||||||
|
// A detached HEAD file holds the raw object id and nothing else. It is the
|
||||||
|
// only identity the cell can show — there is no branch name — and it costs
|
||||||
|
// no extra I/O, since the file is already read above for the detached test.
|
||||||
|
const sha = headText.trim()
|
||||||
|
if (/^[0-9a-f]{7,40}$/i.test(sha)) state.head = sha.slice(0, 7)
|
||||||
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ export interface SyncState {
|
|||||||
behind?: number; // commits on @{u} not on HEAD — trust only with a fresh lastFetchMs
|
behind?: number; // commits on @{u} not on HEAD — trust only with a fresh lastFetchMs
|
||||||
lastFetchMs?: number; // FETCH_HEAD mtime; undefined ⇒ never fetched
|
lastFetchMs?: number; // FETCH_HEAD mtime; undefined ⇒ never fetched
|
||||||
detached?: boolean; // HEAD is not on a branch ⇒ no branch, no ahead/behind
|
detached?: boolean; // HEAD is not on a branch ⇒ no branch, no ahead/behind
|
||||||
|
head?: string; // short sha, ONLY when detached — the cell's sole identity
|
||||||
}
|
}
|
||||||
|
|
||||||
/** w6/G7: the git state of ONE worktree, fetched lazily per row.
|
/** w6/G7: the git state of ONE worktree, fetched lazily per row.
|
||||||
|
|||||||
@@ -799,6 +799,18 @@ describe('makeSyncBand (w6 G3)', () => {
|
|||||||
expect(btn.disabled).toBe(true)
|
expect(btn.disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the short sha on a detached HEAD — it is the only identity left', () => {
|
||||||
|
const b = band({ detached: true, head: 'deadbee' })!
|
||||||
|
expect(b.querySelector('.proj-sync-upstream')!.textContent).toBe('deadbee')
|
||||||
|
expect(b.querySelector('.proj-sync-detached')!.textContent).toBe('detached HEAD')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still renders the detached chip when the sha could not be read', () => {
|
||||||
|
const b = band({ detached: true })!
|
||||||
|
expect(b.querySelector('.proj-sync-upstream')).toBeNull()
|
||||||
|
expect(b.querySelector('.proj-sync-detached')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('names the upstream so the arrows are not floating numbers', () => {
|
it('names the upstream so the arrows are not floating numbers', () => {
|
||||||
const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })!
|
const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })!
|
||||||
expect(b.textContent).toContain('origin/develop')
|
expect(b.textContent).toContain('origin/develop')
|
||||||
|
|||||||
@@ -662,6 +662,21 @@ describe('buildProjectDetail — sync state (w6 G1)', { timeout: 30_000 }, () =>
|
|||||||
expect(d!.branch).toBeUndefined()
|
expect(d!.branch).toBeUndefined()
|
||||||
expect(d!.sync!.ahead).toBeUndefined()
|
expect(d!.sync!.ahead).toBeUndefined()
|
||||||
expect(d!.sync!.behind).toBeUndefined()
|
expect(d!.sync!.behind).toBeUndefined()
|
||||||
|
// With no branch name the short sha is the only identity the panel can show.
|
||||||
|
// Read out of the .git/HEAD text already loaded for the detached test — no
|
||||||
|
// extra git call.
|
||||||
|
expect(d!.sync!.head).toBe(stdout.trim().slice(0, 7))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not report a head sha when HEAD is on a branch', async () => {
|
||||||
|
if (!(await gitAvailable())) return
|
||||||
|
const repo = path.join(tmp, 'g1-attached-head')
|
||||||
|
await initRepo(repo)
|
||||||
|
await commit(repo, 'a.txt', 'only')
|
||||||
|
|
||||||
|
const d = await buildProjectDetail(makeCfg({}), repo, [])
|
||||||
|
expect(d!.sync!.detached).not.toBe(true)
|
||||||
|
expect(d!.sync!.head).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts dirty files instead of only flagging them', async () => {
|
it('counts dirty files instead of only flagging them', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user