From eb6ca60a165882ff0b0d1ec931a46489cda34616 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 20:42:03 +0800 Subject: [PATCH 1/4] docs: spec for UI/UX improvements (commit labels, click-checkout, reset) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-28-ui-ux-improvements-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md diff --git a/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md b/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md new file mode 100644 index 0000000..120963a --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md @@ -0,0 +1,226 @@ +# UI/UX Improvements — Design Spec + +**Date:** 2026-05-28 +**Branch:** `feat/ui-ux-improvements` + +## Goal + +Three UI/UX improvements to make the Gitverse sandbox easier to read and operate: + +1. **Friendly commit labels** — display commits as `C1`, `C2`, … instead of hashes, everywhere they appear. +2. **Click-to-checkout** — clicking a commit node in the graph checks it out. +3. **Reset button** — a one-click way to wipe the sandbox back to a clean slate. + +## Non-Goals + +- No change to the underlying object model — commits keep their real FNV-1a hashes internally; C-labels are a presentation/addressing layer on top. +- No remote operations, no new git commands. +- No renumbering of existing commits when new ones are created (labels are stable). + +--- + +## Feature 1: Commit labels (C1, C2, …) + +### Numbering rule + +A commit's label is `C{n}` where `n` is its **1-based position in creation order**. + +- Creation order = `ObjectStore` commit insertion order. This order survives + save/load: `serialize()` writes `engine.allCommits()` (insertion order) and + `deserialize()` restores them in that same order via `_restoreCommit`. +- A commit's label is **permanent** — making new commits never renumbers + existing ones. +- Orphaned commits (e.g. left behind by rebase) keep their ordinal. This can + leave gaps in the visible sequence (e.g. C1, C2, C5), which is acceptable + because orphaned commits are not drawn. +- After a **Reset**, a fresh `GitEngine` is created, so numbering restarts at C1. + +### Source of truth + +Add to `src/engine/objects.ts`: + +```ts +/** 1-based creation-order index for a commit hash, or null if unknown. */ +commitOrdinal(hash: string): number | null +``` + +Implementation: index of `hash` within `[...this.commits.keys()]`, plus 1. Returns +`null` if not present. + +Add to `src/engine/index.ts` (GitEngine): + +```ts +/** Friendly label for a commit hash, e.g. "C3". Falls back to short hash. */ +commitLabel(hash: string): string +/** Resolve a label like "C3" (case-insensitive) to a commit hash, or null. */ +resolveCommitLabel(label: string): string | null +``` + +- `commitLabel(hash)` → `C${ordinal}` when the ordinal is known; otherwise the + 7-char short hash (defensive fallback only — should not happen for real commits). +- `resolveCommitLabel(label)` → matches `/^C(\d+)$/i`, finds the commit whose + ordinal equals the captured number, returns its hash or `null`. + +### "Everywhere" — where labels appear + +| Location | Before | After | +| --- | --- | --- | +| Graph node circle | `node.hash.slice(0, 4)` | `C3` | +| Detail panel headline | `node.hash.slice(0, 7)` | `C3` (with short hash shown small for reference) | +| Detail panel parents | `parent.slice(0, 7)` | `C1, C2` | +| `git log` (full) | `commit ` | `commit C3` | +| `git log --oneline` | ` ` | `C3 ` | +| `commit` message | `[main 1a2b3c4] msg` | `[main C3] msg` | +| `checkout` message | `HEAD is now at 1a2b3c4 …` | `HEAD is now at C3 …` | +| `reset` message | short hash | `C3` | +| `revert` / `cherry-pick` / `merge` messages | short hash | `C3` | + +The graph node and detail headline need the label without an engine round-trip +per render. The `Graph.svelte` layout builder will attach a `label` field to each +`GraphNode` (computed once via `eng.commitLabel(c.hash)` while mapping commits), +and pass it through to `CommitDetail`. + +### Input coherence — accept C-labels as refs + +Because the terminal now shows `C3` instead of a hash, the user must be able to +type `git checkout C3`, `git reset C3`, etc. The engine resolves C-labels to +hashes **only at commit-ish argument positions**, so it never hijacks +new-name arguments (e.g. `git branch C3` still creates a branch named `C3` if the +user really wants — branch/tag *creation* names are not label-expanded). + +Approach: a single helper `GitEngine.expandCommitish(token: string): string` that +returns the resolved hash if `token` is a C-label that resolves, else returns +`token` unchanged. It is applied inside the commands that consume a commit-ish: + +- `checkout` / `switch` — the checkout target (not the `-b ` value). +- `reset` — the target. +- `merge` — the branch/commit to merge. +- `cherry-pick` — the commit(s). +- `revert` — the commit(s). +- `log` — a start ref argument, if present. +- `branch ` / `tag ` — the *start-point/target* + arg only, never the new name. + +Real short/long hashes continue to work everywhere they did before; label +expansion is additive. + +### Tests + +- `objects.test`: `commitOrdinal` returns stable 1-based order; survives a + serialize→deserialize round-trip; returns `null` for unknown hash. +- `engine`/labels: `commitLabel` / `resolveCommitLabel` round-trip; case + insensitivity; unknown label → null. +- Command tests updated to expect `C{n}` in output (log, checkout, reset, etc.). +- New tests: `git checkout C2` and `git reset C1` resolve correctly; `git branch + C3` is **not** label-expanded (creates branch named `C3`). + +--- + +## Feature 2: Click-to-checkout (smart, keeps detail open) + +### Behavior + +Clicking a commit node: + +1. Determines the checkout target: + - If one or more branches point at that commit → check out a branch + (`git checkout `), so HEAD attaches. Pick the node's first branch + (graph already lists `node.branches`; prefer the HEAD branch if present, + else the first). + - Otherwise → `git checkout C{n}` (detached HEAD at the commit). +2. Runs the checkout **through `executeCommand`** (in `store/engine.ts`), so the + action is logged to the terminal, persisted via autosave, and bumps + `engineVersion` — identical to typing it. +3. Keeps/opens the detail popup for that node so the user sees what they checked + out. (Today click toggles the popup; new behavior: click always selects + + checks out + shows detail.) +4. Dirty-working-tree errors are produced by the existing checkout command and + appear in the terminal as normal — no special handling. + +### Implementation notes + +- `Graph.svelte` `selectNode(node)` becomes: set `selectedHash = node.hash`, then + build the checkout command string and call `executeCommand(cmd)`. +- Skip checkout for `phantom` nodes (no commit yet) and for clicking the node + that is already current HEAD (avoid a redundant no-op command in the terminal — + still show its detail). +- Keyboard: Enter on a focused node does the same as click. + +### Tests + +- E2E (Playwright): create a couple of commits + a branch, click an older node → + terminal shows a checkout line and HEAD glow moves. Clicking a branch-tip node + attaches (prompt shows branch name); clicking a non-tip node detaches. + +--- + +## Feature 3: Reset button (full wipe + confirm) + +### Behavior + +A small button in the **top-left** corner (the logo occupies top-right on desktop +/ top-center on mobile, so top-left is free). + +1. Click → inline styled confirmation replaces the button label: + `Reset sandbox? this wipes everything` with `Yes` / `Cancel`. (Inline confirm, + not native `window.confirm`, to match the app's look.) +2. `Cancel` → revert to the normal button. +3. `Yes` → call `resetSession()`: + - Replace the engine store value with a `new GitEngine()`. + - `engineVersion += 1`. + - Reset `terminalLines` to the welcome banner (`createInitialLines()`). + - Reset command `history` to a fresh `CommandHistory()`. + - Clear IndexedDB: `clearAutoSave()` + `clearHistory()`. + +### Implementation notes + +- `src/persistence/storage.ts`: add `clearAutoSave()` (`del(AUTOSAVE_KEY)`) and + `clearHistory()` (`del(HISTORY_KEY)`), each error-swallowing like the existing + helpers. +- `src/store/engine.ts`: add `resetSession(): void`. Reuses `createInitialLines()` + (extract `lineIdCounter` reset as needed so a fresh banner id is issued). +- New component `src/ui/ResetButton.svelte` holding the confirm toggle state, + mounted from `Layout.svelte` at top-left (`absolute top-3 left-4 z-20`). + +### Tests + +- E2E: run a few commands, click Reset → Yes, confirm graph shows "No commits + yet" / welcome banner returns and a page reload stays clean (IndexedDB cleared). +- Unit: `clearAutoSave` / `clearHistory` remove their keys. + +--- + +## File change summary + +| File | Change | +| --- | --- | +| `src/engine/objects.ts` | `commitOrdinal(hash)` | +| `src/engine/index.ts` | `commitLabel`, `resolveCommitLabel`, `expandCommitish`; apply expansion in dispatch/commands | +| `src/engine/commands/log.ts` | headline uses label | +| `src/engine/commands/checkout.ts` | message uses label; expand target | +| `src/engine/commands/reset.ts` | message uses label; expand target | +| `src/engine/commands/commit.ts` | message uses label (if it prints a hash) | +| `src/engine/commands/merge.ts` | message + expand target | +| `src/engine/commands/cherry-pick.ts` | message + expand target | +| `src/engine/commands/revert.ts` | message + expand target | +| `src/engine/commands/branch.ts` / `tag.ts` | expand start-point/target arg only | +| `src/graph/types.ts` | `GraphNode.label?: string` | +| `src/ui/Graph.svelte` | render label in node; click → checkout via `executeCommand`; pass label to detail | +| `src/ui/CommitDetail.svelte` | show label headline + parents as labels; keep short hash small | +| `src/ui/ResetButton.svelte` | **new** — confirm + reset | +| `src/ui/Layout.svelte` | mount `ResetButton` top-left | +| `src/store/engine.ts` | `resetSession()` | +| `src/persistence/storage.ts` | `clearAutoSave()`, `clearHistory()` | + +## Risks / tradeoffs + +- **Realism:** `git log` showing `commit C3` instead of a real hash diverges from + real git output. This is the explicit "easier to understand" tradeoff the user + chose. Real hashes remain valid input, and the detail panel still surfaces the + short hash, so the learning value of "commits are content-addressed" is not + fully lost. +- **Label expansion scope:** expansion is applied only at commit-ish positions to + avoid clobbering new-name args. The one deliberate carve-out is that + branch/tag *names* are never expanded. +- **Orphan gaps:** rebase can leave numbering gaps in the visible graph. Accepted + as low-impact for a sandbox. From 5e00c9bbe6f5559cf0c76ef1b69585ca89b6ff38 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 23:19:58 +0800 Subject: [PATCH 2/4] feat: commit labels, click-checkout, reset button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-labels (C1, C2…) replace hashes everywhere; accepted as refs at commit-ish positions, real branch/tag names always win. Click commit node checks it out (branch if tip, else detached) via executeCommand. Reset button top-left: inline confirm wipes engine + history + IndexedDB. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/engine/commands/checkout.ts | 15 +++- src/engine/commands/cherry-pick.ts | 8 +- src/engine/commands/commit.ts | 5 +- src/engine/commands/log.ts | 8 +- src/engine/commands/merge.ts | 8 +- src/engine/commands/reset.ts | 9 ++- src/engine/commands/revert.ts | 8 +- src/engine/commands/tag.ts | 7 +- src/engine/commands/types.ts | 6 ++ src/engine/index.ts | 86 +++++++++++++++++--- src/engine/objects.ts | 10 +++ src/graph/types.ts | 2 + src/persistence/storage.ts | 22 ++++++ src/store/engine.ts | 22 +++++- src/ui/CommitDetail.svelte | 21 +++-- src/ui/Graph.svelte | 46 +++++++++-- src/ui/Layout.svelte | 6 ++ src/ui/ResetButton.svelte | 34 ++++++++ tests/engine/labels.test.ts | 114 +++++++++++++++++++++++++++ tests/engine/log-diff.test.ts | 21 +++-- tests/engine/objects.test.ts | 34 ++++++++ tests/engine/reset-stash-tag.test.ts | 4 +- tests/persistence/storage.test.ts | 32 ++++++++ tests/store/engine.test.ts | 38 ++++++++- 24 files changed, 503 insertions(+), 63 deletions(-) create mode 100644 src/ui/ResetButton.svelte create mode 100644 tests/engine/labels.test.ts create mode 100644 tests/persistence/storage.test.ts diff --git a/src/engine/commands/checkout.ts b/src/engine/commands/checkout.ts index 47690ca..2958003 100644 --- a/src/engine/commands/checkout.ts +++ b/src/engine/commands/checkout.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, ExpandFn, LabelFn } from './types'; import type { RefStore } from '../refs'; import type { ObjectStore } from '../objects'; import type { VirtualFileSystem } from '../vfs'; @@ -67,6 +67,8 @@ export function cmdCheckout( objects: ObjectStore, vfs: VirtualFileSystem, index: Map, + expand: ExpandFn, + label: LabelFn, ): CommandResult { const createAndSwitch = opts.has('-b'); @@ -77,9 +79,14 @@ export function cmdCheckout( }; } - // Determine target name + // Determine target name. C-labels are expanded only for the checkout target, + // never for the new branch name created via -b. const bFlag = opts.get('-b'); - const targetName = createAndSwitch ? (bFlag && bFlag.length > 0 ? bFlag[0] : args[0]) : args[0]; + const targetName = createAndSwitch + ? bFlag && bFlag.length > 0 + ? bFlag[0] + : args[0] + : expand(args[0]); if (!targetName) { return { @@ -145,7 +152,7 @@ export function cmdCheckout( refs.detachHEAD(targetName); return { - output: `HEAD is now at ${targetName.slice(0, 7)} (detached HEAD)`, + output: `HEAD is now at ${label(targetName)} (detached HEAD)`, exitCode: 0, }; } diff --git a/src/engine/commands/cherry-pick.ts b/src/engine/commands/cherry-pick.ts index 9d04ccd..4215868 100644 --- a/src/engine/commands/cherry-pick.ts +++ b/src/engine/commands/cherry-pick.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, ExpandFn, LabelFn } from './types'; import type { RefStore } from '../refs'; import type { ObjectStore } from '../objects'; import type { VirtualFileSystem } from '../vfs'; @@ -63,6 +63,8 @@ export function cmdCherryPick( objects: ObjectStore, vfs: VirtualFileSystem, index: Map, + expand: ExpandFn, + label: LabelFn, ): CommandResult { if (args.length === 0) { return { @@ -71,7 +73,7 @@ export function cmdCherryPick( }; } - const targetHash = args[0]; + const targetHash = expand(args[0]); if (!objects.hasCommit(targetHash)) { return { @@ -121,7 +123,7 @@ export function cmdCherryPick( restoreWorkingDirectory(newCommitHash, objects, vfs, index); return { - output: `[${head.attached ? head.target : 'HEAD detached'} ${newCommitHash.slice(0, 7)}] ${targetCommit.message}`, + output: `[${head.attached ? head.target : 'HEAD detached'} ${label(newCommitHash)}] ${targetCommit.message}`, exitCode: 0, }; } diff --git a/src/engine/commands/commit.ts b/src/engine/commands/commit.ts index 96c8c45..717f9ba 100644 --- a/src/engine/commands/commit.ts +++ b/src/engine/commands/commit.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, LabelFn } from './types'; import type { ObjectStore } from '../objects'; import type { RefStore } from '../refs'; @@ -15,6 +15,7 @@ export function cmdCommit( refs: RefStore, index: Map, getCommittedTree: () => Map, + label: LabelFn, ): CommandResult { // Require -m flag const messageArgs = opts.get('-m'); @@ -79,7 +80,7 @@ export function cmdCommit( } return { - output: `[${head.attached ? head.target : 'HEAD detached'} ${commitHash.slice(0, 7)}] ${message}`, + output: `[${head.attached ? head.target : 'HEAD detached'} ${label(commitHash)}] ${message}`, exitCode: 0, }; } diff --git a/src/engine/commands/log.ts b/src/engine/commands/log.ts index 50cc3e4..b925104 100644 --- a/src/engine/commands/log.ts +++ b/src/engine/commands/log.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, LabelFn } from './types'; import type { Commit } from '../objects'; import type { RefStore } from '../refs'; @@ -20,6 +20,7 @@ export function cmdLog( opts: Map, commits: Commit[], refs: RefStore, + label: LabelFn, ): CommandResult { if (commits.length === 0) { return { @@ -99,15 +100,14 @@ export function cmdLog( if (oneline) { for (const commit of commits) { - const shortHash = commit.hash.slice(0, 7); const decoration = buildDecoration(commit.hash); - lines.push(`${shortHash}${decoration} ${commit.message}`); + lines.push(`${label(commit.hash)}${decoration} ${commit.message}`); } } else { for (let i = 0; i < commits.length; i++) { const commit = commits[i]; const decoration = buildDecoration(commit.hash); - lines.push(`commit ${commit.hash}${decoration}`); + lines.push(`commit ${label(commit.hash)}${decoration}`); lines.push(commit.message); if (i < commits.length - 1) { lines.push(''); diff --git a/src/engine/commands/merge.ts b/src/engine/commands/merge.ts index b11b376..7272dfb 100644 --- a/src/engine/commands/merge.ts +++ b/src/engine/commands/merge.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, ExpandFn, LabelFn } from './types'; import type { RefStore } from '../refs'; import type { ObjectStore } from '../objects'; import type { VirtualFileSystem } from '../vfs'; @@ -101,6 +101,8 @@ export function cmdMerge( objects: ObjectStore, vfs: VirtualFileSystem, index: Map, + expand: ExpandFn, + label: LabelFn, ): CommandResult { // Require exactly one positional argument if (args.length === 0) { @@ -110,7 +112,7 @@ export function cmdMerge( }; } - const targetName = args[0]; + const targetName = expand(args[0]); // Resolve target branch if (!refs.hasBranch(targetName)) { @@ -157,7 +159,7 @@ export function cmdMerge( restoreWorkingDirectory(targetHash, objects, vfs, index); return { - output: `Updating ${headHash.slice(0, 7)}..${targetHash.slice(0, 7)}\nFast-forward\n Merged branch '${targetName}'`, + output: `Updating ${label(headHash)}..${label(targetHash)}\nFast-forward\n Merged branch '${targetName}'`, exitCode: 0, }; } diff --git a/src/engine/commands/reset.ts b/src/engine/commands/reset.ts index 74b97af..a519cc5 100644 --- a/src/engine/commands/reset.ts +++ b/src/engine/commands/reset.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, ExpandFn, LabelFn } from './types'; import type { RefStore } from '../refs'; import type { ObjectStore } from '../objects'; import type { VirtualFileSystem, FileEntry } from '../vfs'; @@ -17,6 +17,8 @@ export function cmdReset( objects: ObjectStore, vfs: VirtualFileSystem, index: Map, + expand: ExpandFn, + label: LabelFn, ): CommandResult { // Determine mode const isSoft = opts.has('--soft'); @@ -46,6 +48,9 @@ export function cmdReset( }; } + // Accept a C-label at the commit-ish position. + target = expand(target); + // Resolve the target commit hash let commitHash: string; if (objects.hasCommit(target)) { @@ -129,7 +134,7 @@ export function cmdReset( vfs.restore(snap); return { - output: `HEAD is now at ${commitHash.slice(0, 7)}`, + output: `HEAD is now at ${label(commitHash)}`, exitCode: 0, }; } diff --git a/src/engine/commands/revert.ts b/src/engine/commands/revert.ts index e13d476..453a926 100644 --- a/src/engine/commands/revert.ts +++ b/src/engine/commands/revert.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, ExpandFn, LabelFn } from './types'; import type { RefStore } from '../refs'; import type { ObjectStore } from '../objects'; import type { VirtualFileSystem } from '../vfs'; @@ -66,6 +66,8 @@ export function cmdRevert( objects: ObjectStore, vfs: VirtualFileSystem, index: Map, + expand: ExpandFn, + label: LabelFn, ): CommandResult { if (args.length === 0) { return { @@ -74,7 +76,7 @@ export function cmdRevert( }; } - const targetHash = args[0]; + const targetHash = expand(args[0]); if (!objects.hasCommit(targetHash)) { return { @@ -167,7 +169,7 @@ export function cmdRevert( restoreWorkingDirectory(revertCommitHash, objects, vfs, index); return { - output: `[${head.attached ? head.target : 'HEAD detached'} ${revertCommitHash.slice(0, 7)}] ${revertMessage}`, + output: `[${head.attached ? head.target : 'HEAD detached'} ${label(revertCommitHash)}] ${revertMessage}`, exitCode: 0, }; } diff --git a/src/engine/commands/tag.ts b/src/engine/commands/tag.ts index 4090740..30b5a9b 100644 --- a/src/engine/commands/tag.ts +++ b/src/engine/commands/tag.ts @@ -1,4 +1,4 @@ -import type { CommandResult } from './types'; +import type { CommandResult, ExpandFn } from './types'; import type { RefStore } from '../refs'; import type { ObjectStore } from '../objects'; @@ -12,6 +12,7 @@ export function cmdTag( _opts: Map, refs: RefStore, objects: ObjectStore, + expand: ExpandFn, ): CommandResult { // List tags if (args.length === 0) { @@ -26,8 +27,8 @@ export function cmdTag( let targetHash: string; if (args.length >= 2) { - // Tag at specific commit / ref - const ref = args[1]; + // Tag at specific commit / ref (the target, not the tag name, may be a C-label) + const ref = expand(args[1]); if (objects.hasCommit(ref)) { targetHash = ref; } else { diff --git a/src/engine/commands/types.ts b/src/engine/commands/types.ts index da2a3e7..cf4487c 100644 --- a/src/engine/commands/types.ts +++ b/src/engine/commands/types.ts @@ -2,3 +2,9 @@ export type CommandResult = { output: string; exitCode: number; }; + +/** Maps a commit hash to its friendly label (e.g. "C3"), or a short hash fallback. */ +export type LabelFn = (hash: string) => string; + +/** Resolves a C-label at a commit-ish position to a hash; returns the token unchanged otherwise. */ +export type ExpandFn = (token: string) => string; diff --git a/src/engine/index.ts b/src/engine/index.ts index 6f0825a..2f57319 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -176,6 +176,11 @@ export class GitEngine { let result: CommandResult; + // Presentation/addressing helpers handed to commands so they can render + // C-labels in output and accept them at commit-ish argument positions. + const label = (hash: string) => this.commitLabel(hash); + const expand = (token: string) => this.expandCommitish(token); + switch (command) { case 'init': result = cmdInit(this); @@ -189,8 +194,14 @@ export class GitEngine { break; case 'commit': - result = cmdCommit(args, opts, this.objects, this.refs, this.index, () => - this.getCommittedTree(), + result = cmdCommit( + args, + opts, + this.objects, + this.refs, + this.index, + () => this.getCommittedTree(), + label, ); break; @@ -207,7 +218,7 @@ export class GitEngine { break; case 'log': - result = cmdLog(args, opts, this.log(), this.refs); + result = cmdLog(args, opts, this.log(), this.refs, label); break; case 'diff': @@ -220,15 +231,24 @@ export class GitEngine { case 'checkout': case 'switch': - result = cmdCheckout(args, opts, this.refs, this.objects, this.vfs, this.index); + result = cmdCheckout( + args, + opts, + this.refs, + this.objects, + this.vfs, + this.index, + expand, + label, + ); break; case 'merge': - result = cmdMerge(args, opts, this.refs, this.objects, this.vfs, this.index); + result = cmdMerge(args, opts, this.refs, this.objects, this.vfs, this.index, expand, label); break; case 'reset': - result = cmdReset(args, opts, this.refs, this.objects, this.vfs, this.index); + result = cmdReset(args, opts, this.refs, this.objects, this.vfs, this.index, expand, label); break; case 'stash': @@ -244,7 +264,7 @@ export class GitEngine { break; case 'tag': - result = cmdTag(args, opts, this.refs, this.objects); + result = cmdTag(args, opts, this.refs, this.objects, expand); break; case 'rm': @@ -260,11 +280,29 @@ export class GitEngine { break; case 'cherry-pick': - result = cmdCherryPick(args, opts, this.refs, this.objects, this.vfs, this.index); + result = cmdCherryPick( + args, + opts, + this.refs, + this.objects, + this.vfs, + this.index, + expand, + label, + ); break; case 'revert': - result = cmdRevert(args, opts, this.refs, this.objects, this.vfs, this.index); + result = cmdRevert( + args, + opts, + this.refs, + this.objects, + this.vfs, + this.index, + expand, + label, + ); break; default: @@ -289,6 +327,36 @@ export class GitEngine { return this.refs.getHEAD(); } + // ------------------------------------------------------------------------- + // Commit labels (C1, C2, …) — presentation/addressing layer over hashes + // ------------------------------------------------------------------------- + + /** Friendly label for a commit hash, e.g. "C3". Falls back to short hash. */ + commitLabel(hash: string): string { + const ordinal = this.objects.commitOrdinal(hash); + return ordinal !== null ? `C${ordinal}` : hash.slice(0, 7); + } + + /** Resolve a label like "C3" (case-insensitive) to a commit hash, or null. */ + resolveCommitLabel(label: string): string | null { + const match = /^C(\d+)$/i.exec(label.trim()); + if (!match) return null; + const n = parseInt(match[1], 10); + const commit = this.objects.allCommits()[n - 1]; + return commit ? commit.hash : null; + } + + /** + * Resolve a C-label to its commit hash at commit-ish argument positions. + * Returns the token unchanged if it is not a label, does not resolve, or + * collides with a real branch/tag name (real refs always win). + */ + expandCommitish(token: string): string { + if (!token) return token; + if (this.refs.hasBranch(token) || this.refs.hasTag(token)) return token; + return this.resolveCommitLabel(token) ?? token; + } + // ------------------------------------------------------------------------- // Log // ------------------------------------------------------------------------- diff --git a/src/engine/objects.ts b/src/engine/objects.ts index b66a88d..a8b307e 100644 --- a/src/engine/objects.ts +++ b/src/engine/objects.ts @@ -144,6 +144,16 @@ export class ObjectStore { return [...this.commits.values()].map((c) => ({ ...c, parents: [...c.parents] })); } + /** + * 1-based creation-order index for a commit hash, or null if unknown. + * Creation order = Map insertion order, which survives serialize/deserialize + * because restoreCommit replays commits in their original order. + */ + commitOrdinal(hash: string): number | null { + const idx = [...this.commits.keys()].indexOf(hash); + return idx === -1 ? null : idx + 1; + } + // ------------------------------------------------------------------------- // Serialization helpers (used by Task 15 persistence layer) // ------------------------------------------------------------------------- diff --git a/src/graph/types.ts b/src/graph/types.ts index 35fc40a..d70abbe 100644 --- a/src/graph/types.ts +++ b/src/graph/types.ts @@ -3,6 +3,8 @@ export type GraphNode = { type: 'commit' | 'phantom'; parents: string[]; message: string; + /** Friendly creation-order label (e.g. "C3"). Absent for phantom nodes. */ + label?: string; branches: string[]; tags?: string[]; isHEAD: boolean; diff --git a/src/persistence/storage.ts b/src/persistence/storage.ts index f538950..28f5af2 100644 --- a/src/persistence/storage.ts +++ b/src/persistence/storage.ts @@ -43,6 +43,17 @@ export async function autoLoad(): Promise { } } +/** + * Remove the auto-save slot. Errors are swallowed like the other helpers. + */ +export async function clearAutoSave(): Promise { + try { + await del(AUTOSAVE_KEY); + } catch { + // storage unavailable – silently ignore + } +} + // --------------------------------------------------------------------------- // Named sandboxes // --------------------------------------------------------------------------- @@ -107,3 +118,14 @@ export async function loadHistory(): Promise { return []; } } + +/** + * Remove the persisted command history. Errors are swallowed like the others. + */ +export async function clearHistory(): Promise { + try { + await del(HISTORY_KEY); + } catch { + // storage unavailable – silently ignore + } +} diff --git a/src/store/engine.ts b/src/store/engine.ts index 95f35ae..3dfa5dc 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -5,7 +5,14 @@ import { ShellRouter } from '$shell/router'; import { generatePrompt } from '$shell/prompt'; import type { PromptSegment } from '$shell/prompt'; import { serialize, deserialize } from '$persistence/serializer'; -import { autoSave, autoLoad, saveHistory, loadHistory } from '$persistence/storage'; +import { + autoSave, + autoLoad, + saveHistory, + loadHistory, + clearAutoSave, + clearHistory, +} from '$persistence/storage'; export type TerminalLine = { id: number; @@ -76,6 +83,19 @@ export const prompt = derived(engine, ($engine) => { return generatePrompt($engine); }); +/** + * Wipe the sandbox back to a clean slate: fresh engine, welcome banner, + * empty history, and cleared IndexedDB so a reload stays clean. + */ +export function resetSession(): void { + engine.set(new GitEngine()); + engineVersion.update((v) => v + 1); + terminalLines.set(createInitialLines()); + history.set(new CommandHistory()); + clearAutoSave(); + clearHistory(); +} + export function executeCommand(command: string): void { const eng = get(engine); const hist = get(history); diff --git a/src/ui/CommitDetail.svelte b/src/ui/CommitDetail.svelte index dd33bc3..c300dc0 100644 --- a/src/ui/CommitDetail.svelte +++ b/src/ui/CommitDetail.svelte @@ -1,7 +1,14 @@
✕ - -
- {node.hash.slice(0, 7)} + +
+ {node.label ?? node.hash.slice(0, 7)} + {node.hash.slice(0, 7)}
@@ -49,13 +57,12 @@
{/if} - + {#if node.parents.length > 0}
parents: - {#each node.parents as parent, i (parent)} - {parent.slice(0, 7)}{i < node.parents.length - 1 ? ', ' : ''} + {#each parentLabels as parent, i (node.parents[i])} + {parent}{i < parentLabels.length - 1 ? ', ' : ''} {/each}
{/if} diff --git a/src/ui/Graph.svelte b/src/ui/Graph.svelte index d7872d5..b23b7d2 100644 --- a/src/ui/Graph.svelte +++ b/src/ui/Graph.svelte @@ -1,5 +1,5 @@
+ +
+ +
+
{` ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
diff --git a/src/ui/ResetButton.svelte b/src/ui/ResetButton.svelte
new file mode 100644
index 0000000..9d8af59
--- /dev/null
+++ b/src/ui/ResetButton.svelte
@@ -0,0 +1,34 @@
+
+
+{#if confirming}
+  
+ Reset sandbox? this wipes everything + + +
+{:else} + +{/if} diff --git a/tests/engine/labels.test.ts b/tests/engine/labels.test.ts new file mode 100644 index 0000000..4617c97 --- /dev/null +++ b/tests/engine/labels.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GitEngine } from '$engine/index'; + +function setup(): GitEngine { + const eng = new GitEngine(); + eng.execute('git init'); + return eng; +} + +function commit(eng: GitEngine, file: string, msg: string): string { + eng.getVFS().createFile(file, msg); + eng.execute(`git add ${file}`); + eng.execute(`git commit -m "${msg}"`); + return eng.getHEAD().attached + ? eng.allBranches().get(eng.getHEAD().target)! + : eng.getHEAD().target; +} + +describe('GitEngine commit labels', () => { + let eng: GitEngine; + let c1: string; + let c2: string; + let c3: string; + + beforeEach(() => { + eng = setup(); + c1 = commit(eng, 'a.txt', 'first'); + c2 = commit(eng, 'b.txt', 'second'); + c3 = commit(eng, 'c.txt', 'third'); + }); + + it('labels commits C1, C2, C3 in creation order', () => { + expect(eng.commitLabel(c1)).toBe('C1'); + expect(eng.commitLabel(c2)).toBe('C2'); + expect(eng.commitLabel(c3)).toBe('C3'); + }); + + it('falls back to short hash for an unknown commit', () => { + expect(eng.commitLabel('deadbee')).toBe('deadbee'); + }); + + it('resolveCommitLabel round-trips with commitLabel', () => { + expect(eng.resolveCommitLabel('C1')).toBe(c1); + expect(eng.resolveCommitLabel('C3')).toBe(c3); + }); + + it('resolveCommitLabel is case-insensitive', () => { + expect(eng.resolveCommitLabel('c2')).toBe(c2); + }); + + it('resolveCommitLabel returns null for unknown or malformed labels', () => { + expect(eng.resolveCommitLabel('C99')).toBeNull(); + expect(eng.resolveCommitLabel('C0')).toBeNull(); + expect(eng.resolveCommitLabel('main')).toBeNull(); + expect(eng.resolveCommitLabel('abc1234')).toBeNull(); + }); + + it('expandCommitish resolves labels to hashes', () => { + expect(eng.expandCommitish('C2')).toBe(c2); + }); + + it('expandCommitish leaves non-label tokens unchanged', () => { + expect(eng.expandCommitish('main')).toBe('main'); + expect(eng.expandCommitish(c1)).toBe(c1); + }); + + it('expandCommitish does not shadow a real branch named like a label', () => { + eng.execute('git branch C2'); + // A branch literally named "C2" wins over the C2 commit label. + expect(eng.expandCommitish('C2')).toBe('C2'); + }); +}); + +describe('C-labels as command arguments', () => { + let eng: GitEngine; + let c1: string; + + beforeEach(() => { + eng = setup(); + c1 = commit(eng, 'a.txt', 'first'); + commit(eng, 'b.txt', 'second'); + commit(eng, 'c.txt', 'third'); + }); + + it('git checkout C2 detaches HEAD at the right commit', () => { + const result = eng.execute('git checkout C2'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('C2'); + const head = eng.getHEAD(); + expect(head.attached).toBe(false); + expect(head.target).toBe(eng.resolveCommitLabel('C2')); + }); + + it('git reset C1 moves the branch to that commit', () => { + const result = eng.execute('git reset --hard C1'); + expect(result.exitCode).toBe(0); + expect(eng.allBranches().get('main')).toBe(c1); + }); + + it('git branch C3 creates a branch literally named C3 (not label-expanded)', () => { + const result = eng.execute('git branch C3'); + expect(result.exitCode).toBe(0); + expect(eng.allBranches().has('C3')).toBe(true); + // The C3 branch points at HEAD (the third commit), not at the C3 commit + // resolved via label — proving the name was not expanded. + expect(eng.allBranches().get('C3')).toBe(eng.allBranches().get('main')); + }); + + it('git tag at a C-label targets the right commit', () => { + const result = eng.execute('git tag v-c1 C1'); + expect(result.exitCode).toBe(0); + expect(eng.allTags().get('v-c1')).toBe(c1); + }); +}); diff --git a/tests/engine/log-diff.test.ts b/tests/engine/log-diff.test.ts index 0507d23..463e66c 100644 --- a/tests/engine/log-diff.test.ts +++ b/tests/engine/log-diff.test.ts @@ -52,11 +52,11 @@ describe('git log – full format (default)', () => { expect(idx1).toBeLessThan(idx2); }); - it('includes full commit hash in output', () => { + it('shows friendly commit labels in output', () => { const result = engine.execute('git log'); - const commits = engine.log(); - // Full hash (7+ chars) should appear - expect(result.output).toContain(commits[0].hash); + // Commits are addressed as C1, C2, … instead of raw hashes. + expect(result.output).toContain('commit C2'); + expect(result.output).toContain('commit C1'); }); it('includes commit message in output', () => { @@ -93,19 +93,18 @@ describe('git log – --oneline format', () => { expect(lines).toHaveLength(2); }); - it('shows 7-char short hash', () => { + it('shows commit labels', () => { const result = engine.execute('git log --oneline'); - const commits = engine.log(); - const shortHash = commits[0].hash.slice(0, 7); - expect(result.output).toContain(shortHash); + expect(result.output).toContain('C1'); + expect(result.output).toContain('C2'); }); - it('shows commit message on same line as hash', () => { + it('shows commit message on same line as label', () => { const result = engine.execute('git log --oneline'); const lines = result.output.split('\n').filter((l) => l.trim() !== ''); - // Each line should contain hash + message + // Each line should start with a C-label and contain its message. for (const line of lines) { - expect(line).toMatch(/\w{7}.*commit/); + expect(line).toMatch(/^C\d+.*commit/); } }); diff --git a/tests/engine/objects.test.ts b/tests/engine/objects.test.ts index 014f8a5..d0edd41 100644 --- a/tests/engine/objects.test.ts +++ b/tests/engine/objects.test.ts @@ -197,3 +197,37 @@ describe('ObjectStore - serialization helpers', () => { expect(store.readCommit('1234567').message).toBe('restored'); }); }); + +describe('ObjectStore - commitOrdinal', () => { + function makeCommit(store: ObjectStore, message: string): string { + const tree = store.writeTree(new Map()); + return store.writeCommit({ tree, parents: [], message, timestamp: 0 }); + } + + it('returns a stable 1-based index in creation order', () => { + const store = new ObjectStore(); + const c1 = makeCommit(store, 'first'); + const c2 = makeCommit(store, 'second'); + const c3 = makeCommit(store, 'third'); + expect(store.commitOrdinal(c1)).toBe(1); + expect(store.commitOrdinal(c2)).toBe(2); + expect(store.commitOrdinal(c3)).toBe(3); + }); + + it('returns null for an unknown hash', () => { + const store = new ObjectStore(); + expect(store.commitOrdinal('deadbee')).toBeNull(); + }); + + it('survives a restore replay in original order', () => { + const store = new ObjectStore(); + const c1 = makeCommit(store, 'first'); + const c2 = makeCommit(store, 'second'); + + const restored = new ObjectStore(); + restored.restoreCommit(store.readCommit(c1)); + restored.restoreCommit(store.readCommit(c2)); + expect(restored.commitOrdinal(c1)).toBe(1); + expect(restored.commitOrdinal(c2)).toBe(2); + }); +}); diff --git a/tests/engine/reset-stash-tag.test.ts b/tests/engine/reset-stash-tag.test.ts index d148205..d5ae024 100644 --- a/tests/engine/reset-stash-tag.test.ts +++ b/tests/engine/reset-stash-tag.test.ts @@ -153,10 +153,10 @@ describe('git reset --hard', () => { expect(engine.getVFS().readFile('readme.md')).toBe('# hello'); }); - it('output includes the short commit hash', () => { + it('output includes the commit label', () => { const { engine, c1Hash } = engineWithTwoCommits(); const result = engine.execute(`git reset --hard ${c1Hash}`); - expect(result.output).toContain(c1Hash.slice(0, 7)); + expect(result.output).toContain(engine.commitLabel(c1Hash)); }); }); diff --git a/tests/persistence/storage.test.ts b/tests/persistence/storage.test.ts new file mode 100644 index 0000000..de722ea --- /dev/null +++ b/tests/persistence/storage.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const delMock = vi.fn(); +vi.mock('idb-keyval', () => ({ + get: vi.fn(), + set: vi.fn(), + del: (key: string) => delMock(key), + keys: vi.fn(), +})); + +import { clearAutoSave, clearHistory } from '$persistence/storage'; + +describe('storage clears', () => { + beforeEach(() => { + delMock.mockReset(); + }); + + it('clearAutoSave deletes the autosave key', async () => { + await clearAutoSave(); + expect(delMock).toHaveBeenCalledWith('gitverse:autosave'); + }); + + it('clearHistory deletes the history key', async () => { + await clearHistory(); + expect(delMock).toHaveBeenCalledWith('gitverse:history'); + }); + + it('swallows storage errors', async () => { + delMock.mockRejectedValueOnce(new Error('boom')); + await expect(clearAutoSave()).resolves.toBeUndefined(); + }); +}); diff --git a/tests/store/engine.test.ts b/tests/store/engine.test.ts index b084f83..abcb3ae 100644 --- a/tests/store/engine.test.ts +++ b/tests/store/engine.test.ts @@ -8,14 +8,25 @@ import { CommandHistory } from '$shell/history'; const autoLoadMock = vi.fn(); const loadHistoryMock = vi.fn(); const saveHistoryMock = vi.fn(); +const clearAutoSaveMock = vi.fn(); +const clearHistoryMock = vi.fn(); vi.mock('$persistence/storage', () => ({ autoLoad: () => autoLoadMock(), autoSave: vi.fn(), loadHistory: () => loadHistoryMock(), saveHistory: (entries: string[]) => saveHistoryMock(entries), + clearAutoSave: () => clearAutoSaveMock(), + clearHistory: () => clearHistoryMock(), })); -import { engine, history, hydrate, executeCommand } from '$store/engine'; +import { + engine, + history, + terminalLines, + hydrate, + executeCommand, + resetSession, +} from '$store/engine'; describe('store hydrate', () => { beforeEach(() => { @@ -78,3 +89,28 @@ describe('store hydrate', () => { expect(lastSaved).toContain('git status'); }); }); + +describe('resetSession', () => { + beforeEach(() => { + clearAutoSaveMock.mockReset(); + clearHistoryMock.mockReset(); + }); + + it('wipes the engine, history, terminal, and persisted storage', () => { + // Dirty the session first. + executeCommand('git init'); + executeCommand('git status'); + expect(get(engine).isInitialized()).toBe(true); + + resetSession(); + + const live = get(engine); + expect(live.isInitialized()).toBe(false); + expect(live.allCommits()).toHaveLength(0); + expect(get(history).getEntries()).toEqual([]); + // Welcome banner restored (single line). + expect(get(terminalLines)).toHaveLength(1); + expect(clearAutoSaveMock).toHaveBeenCalled(); + expect(clearHistoryMock).toHaveBeenCalled(); + }); +}); From d8a849ce72e96491cebca8380d9dcbb7b183905f Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 23:20:03 +0800 Subject: [PATCH 3/4] 0.5.0-rc.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9050253..69bdd90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitverse", - "version": "0.4.1", + "version": "0.5.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitverse", - "version": "0.4.1", + "version": "0.5.0-rc.0", "dependencies": { "d3-dag": "1.2.1", "d3-hierarchy": "3.1.2", diff --git a/package.json b/package.json index c54fe58..7548e75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitverse", - "version": "0.4.1", + "version": "0.5.0-rc.0", "private": true, "type": "module", "scripts": { From ce38aab911154e8ddf1a7ac801e8b78e981dceee Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 23:33:15 +0800 Subject: [PATCH 4/4] style: prettier-format ui-ux spec doc Repo-wide format:check in CI flagged the spec doc committed earlier. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-28-ui-ux-improvements-design.md | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md b/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md index 120963a..1a357bf 100644 --- a/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md +++ b/docs/superpowers/specs/2026-05-28-ui-ux-improvements-design.md @@ -63,17 +63,17 @@ resolveCommitLabel(label: string): string | null ### "Everywhere" — where labels appear -| Location | Before | After | -| --- | --- | --- | -| Graph node circle | `node.hash.slice(0, 4)` | `C3` | -| Detail panel headline | `node.hash.slice(0, 7)` | `C3` (with short hash shown small for reference) | -| Detail panel parents | `parent.slice(0, 7)` | `C1, C2` | -| `git log` (full) | `commit ` | `commit C3` | -| `git log --oneline` | ` ` | `C3 ` | -| `commit` message | `[main 1a2b3c4] msg` | `[main C3] msg` | -| `checkout` message | `HEAD is now at 1a2b3c4 …` | `HEAD is now at C3 …` | -| `reset` message | short hash | `C3` | -| `revert` / `cherry-pick` / `merge` messages | short hash | `C3` | +| Location | Before | After | +| ------------------------------------------- | -------------------------- | ------------------------------------------------ | +| Graph node circle | `node.hash.slice(0, 4)` | `C3` | +| Detail panel headline | `node.hash.slice(0, 7)` | `C3` (with short hash shown small for reference) | +| Detail panel parents | `parent.slice(0, 7)` | `C1, C2` | +| `git log` (full) | `commit ` | `commit C3` | +| `git log --oneline` | ` ` | `C3 ` | +| `commit` message | `[main 1a2b3c4] msg` | `[main C3] msg` | +| `checkout` message | `HEAD is now at 1a2b3c4 …` | `HEAD is now at C3 …` | +| `reset` message | short hash | `C3` | +| `revert` / `cherry-pick` / `merge` messages | short hash | `C3` | The graph node and detail headline need the label without an engine round-trip per render. The `Graph.svelte` layout builder will attach a `label` field to each @@ -86,7 +86,7 @@ Because the terminal now shows `C3` instead of a hash, the user must be able to type `git checkout C3`, `git reset C3`, etc. The engine resolves C-labels to hashes **only at commit-ish argument positions**, so it never hijacks new-name arguments (e.g. `git branch C3` still creates a branch named `C3` if the -user really wants — branch/tag *creation* names are not label-expanded). +user really wants — branch/tag _creation_ names are not label-expanded). Approach: a single helper `GitEngine.expandCommitish(token: string): string` that returns the resolved hash if `token` is a C-label that resolves, else returns @@ -98,7 +98,7 @@ returns the resolved hash if `token` is a C-label that resolves, else returns - `cherry-pick` — the commit(s). - `revert` — the commit(s). - `log` — a start ref argument, if present. -- `branch ` / `tag ` — the *start-point/target* +- `branch ` / `tag ` — the _start-point/target_ arg only, never the new name. Real short/long hashes continue to work everywhere they did before; label @@ -112,7 +112,7 @@ expansion is additive. insensitivity; unknown label → null. - Command tests updated to expect `C{n}` in output (log, checkout, reset, etc.). - New tests: `git checkout C2` and `git reset C1` resolve correctly; `git branch - C3` is **not** label-expanded (creates branch named `C3`). +C3` is **not** label-expanded (creates branch named `C3`). --- @@ -192,25 +192,25 @@ A small button in the **top-left** corner (the logo occupies top-right on deskto ## File change summary -| File | Change | -| --- | --- | -| `src/engine/objects.ts` | `commitOrdinal(hash)` | -| `src/engine/index.ts` | `commitLabel`, `resolveCommitLabel`, `expandCommitish`; apply expansion in dispatch/commands | -| `src/engine/commands/log.ts` | headline uses label | -| `src/engine/commands/checkout.ts` | message uses label; expand target | -| `src/engine/commands/reset.ts` | message uses label; expand target | -| `src/engine/commands/commit.ts` | message uses label (if it prints a hash) | -| `src/engine/commands/merge.ts` | message + expand target | -| `src/engine/commands/cherry-pick.ts` | message + expand target | -| `src/engine/commands/revert.ts` | message + expand target | -| `src/engine/commands/branch.ts` / `tag.ts` | expand start-point/target arg only | -| `src/graph/types.ts` | `GraphNode.label?: string` | -| `src/ui/Graph.svelte` | render label in node; click → checkout via `executeCommand`; pass label to detail | -| `src/ui/CommitDetail.svelte` | show label headline + parents as labels; keep short hash small | -| `src/ui/ResetButton.svelte` | **new** — confirm + reset | -| `src/ui/Layout.svelte` | mount `ResetButton` top-left | -| `src/store/engine.ts` | `resetSession()` | -| `src/persistence/storage.ts` | `clearAutoSave()`, `clearHistory()` | +| File | Change | +| ------------------------------------------ | -------------------------------------------------------------------------------------------- | +| `src/engine/objects.ts` | `commitOrdinal(hash)` | +| `src/engine/index.ts` | `commitLabel`, `resolveCommitLabel`, `expandCommitish`; apply expansion in dispatch/commands | +| `src/engine/commands/log.ts` | headline uses label | +| `src/engine/commands/checkout.ts` | message uses label; expand target | +| `src/engine/commands/reset.ts` | message uses label; expand target | +| `src/engine/commands/commit.ts` | message uses label (if it prints a hash) | +| `src/engine/commands/merge.ts` | message + expand target | +| `src/engine/commands/cherry-pick.ts` | message + expand target | +| `src/engine/commands/revert.ts` | message + expand target | +| `src/engine/commands/branch.ts` / `tag.ts` | expand start-point/target arg only | +| `src/graph/types.ts` | `GraphNode.label?: string` | +| `src/ui/Graph.svelte` | render label in node; click → checkout via `executeCommand`; pass label to detail | +| `src/ui/CommitDetail.svelte` | show label headline + parents as labels; keep short hash small | +| `src/ui/ResetButton.svelte` | **new** — confirm + reset | +| `src/ui/Layout.svelte` | mount `ResetButton` top-left | +| `src/store/engine.ts` | `resetSession()` | +| `src/persistence/storage.ts` | `clearAutoSave()`, `clearHistory()` | ## Risks / tradeoffs @@ -221,6 +221,6 @@ A small button in the **top-left** corner (the logo occupies top-right on deskto fully lost. - **Label expansion scope:** expansion is applied only at commit-ish positions to avoid clobbering new-name args. The one deliberate carve-out is that - branch/tag *names* are never expanded. + branch/tag _names_ are never expanded. - **Orphan gaps:** rebase can leave numbering gaps in the visible graph. Accepted as low-impact for a sandbox.