Preview uncommitted changes in the detail pane

For a dirty submodule, list the changed files so the user can remember
what they were doing there:

  Changes   5 files · 2M 1A 1D 1R · +37 −12
            ──────────────────────────────────
            M  render.go  src/render/  +20 −4  3d ago
            ?  scratch.txt                     5m ago

- Status.Changes: files changed against HEAD (staged and unstaged
  combined) with kind, line counts and modification time; sorted by kind,
  then by size; capped at 200 with totals over all files.
- Git provider: parse `status --porcelain=v2 -z` and one
  `diff HEAD --numstat -z -M`, only for dirty submodules.
- Detail pane: flat list with a colored status letter per file, like IDE
  source-control views; counts and age in compact columns; up to 10 files,
  every kind keeps a line, the rest is summarized as "n more".
- Fixture gains libs/work with every kind of change; mock data extended.
- Docs: F24, change preview in the UX guide, D17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Franken
2026-10-21 15:55:46 +02:00
co-authored by Claude Opus 5.5
parent 64fe34b5b0
commit 19c6f17eb2
17 changed files with 882 additions and 32 deletions
+3 -3
View File
@@ -15,16 +15,16 @@ internal/submodule/ domain model; NO UI imports
provider.go Provider interface
internal/submodule/git/ read-only provider using the git CLI
exec.go git runner (environment, errors)
parse.go pure parsers for git output
parse.go pure parsers for git output (status, numstat, …)
provider.go Tree, Status, pin → ref resolution
internal/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
msgs.go treeLoadedMsg, statusLoadedMsg
theme/ colors, glyphs, issue words, level styles
theme/ colors, glyphs, issue words, change letters, level styles
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane
detail/ detail pane; changes.go: change preview
header/ header line
footer/ help and flash messages
```
+18
View File
@@ -128,3 +128,21 @@ processes.
**Decision.** The git provider limits concurrent `Status` calls to
`runtime.NumCPU()` with a semaphore that respects `ctx`.
**Consequences.** The UI stays unchanged; rows fill in steadily.
## D17 — Change preview: flat letter list against HEAD
**Context.** `changed` doesn't tell users what they changed or why; they
want to remember what they were doing in a submodule.
**Decision.** `Status.Changes` lists the changed files against HEAD (staged
and unstaged combined) with line counts. The detail pane shows them as a
flat list with a colored status letter in front, like IDE source-control
views, instead of group headers. Sorted by kind, then by size; the 10-line
budget keeps one line for every kind present and summarizes the rest.
Placed directly below the issue lines, because the pane is cut by height.
One summary line for all files, then strictly one line per file with its
own age: a second summary line ("+1 · edited 19m ago") read as if it
belonged to the first file.
Dirt inside nested submodules is ignored (D7); unstaged moves show as
removed + untracked.
**Consequences.** One extra `git diff` per dirty submodule, none for clean
ones. Not included: staged/unstaged markers, diff bodies, stash count.
+7
View File
@@ -61,6 +61,13 @@ commit hashes. It doesn't answer the questions users actually have:
- **F22** Lists the commits between pin and tip (SHA, subject, author, age)
with a title stating ahead/behind/diverged.
- **F23** Shows child count, issues below, and the remote URL.
- **F24** For a submodule with uncommitted changes, previews the changed
files so the user can remember what they were doing: a flat list with a
colored letter per file (`M` modified, `A` added, `D` removed, `R`
renamed, `?` untracked, `!` conflict), line counts (`+n −m`) per file and
in total, and how long ago each file was edited. Only a handful of files are
shown; the rest is summarized as "n more" with their kinds. Staged and
unstaged changes are combined.
### Header and footer
+27 -1
View File
@@ -32,10 +32,29 @@ in several at once.
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference |
| `Changes` | Changed files of the worktree (nil if clean), see below |
`Status == nil` means "still loading". `Submodule.Err` is set if loading
failed.
## Changes
`Status.Changes` describes *what* is behind `Worktree`: one `FileChange` per
path with a `ChangeKind` (conflicted, modified, added, removed, renamed,
untracked), added/removed line counts, and flags for binary files and moved
nested submodule pointers. It compares the worktree **with HEAD**, so staged
and unstaged changes are combined (a file added to the index and edited
afterwards is one added file).
- `Files` is sorted by kind (that order), then by changed lines (largest
first), then by path, and capped at `MaxChangeFiles` (200). `Total`,
`Added` and `Removed` cover all files.
- `FileChange.Modified` is the file's modification time (zero for removed
files).
- `Worktree` is derived from the same list: conflict, else any tracked
change → modified, else untracked.
- It is a preview, not an issue: it adds no issue kinds and changes no levels.
## Issues: summarizing the dimensions
The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes
@@ -107,7 +126,9 @@ are local; `<pin>` is the index gitlink.
| `Pinned`, `PinStaged` | parent: `ls-files -s -- <path>` vs `ls-tree HEAD -- <path>` |
| `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` |
| `Checkout` | `rev-parse HEAD`, `symbolic-ref -q HEAD` (no branch → detached) |
| `Worktree` | `status --porcelain=v2 --ignore-submodules=dirty` |
| `Worktree`, `Changes` kinds | `status --porcelain=v2 -z --untracked-files=normal --ignore-submodules=dirty` |
| `Changes` line counts | `diff HEAD --numstat -z -M --ignore-submodules=dirty`, only if a tracked file changed; failure leaves the counts at 0 |
| `FileChange.Modified` | `stat` of each changed file that still exists |
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
@@ -123,6 +144,11 @@ Details:
- Rule 6 shows the short SHA; there is no tip and no distance.
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
its parent `changed` (D7), but a moved nested pin does.
- Renames are detected only for staged moves (`git mv`); an unstaged move
is a removed file plus an untracked file. Untracked directories are one
entry (`dir/`) without line counts.
- `--untracked-files=normal` overrides `status.showUntrackedFiles`, so the
result doesn't depend on the user's config.
- A missing submodule only gets a `Pinned` SHA; nested submodules of an
uninitialized submodule are unknown and not shown.
- If the pinned commit doesn't exist in the submodule (not fetched), loading
+50 -4
View File
@@ -61,12 +61,58 @@ indent ▸ name [n] status ref delta
2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none; `staged` and
`detached HEAD on pin` as information
3. `Ref` with distance and `via <rule>`
4. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview)
4. `Ref` with distance and `via <rule>`
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
differs from the pin
5. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
6. `Children` (count and issues below), `URL`
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
7. `Children` (count and issues below), `URL`
#### Change preview
```
Changes 5 files · 2M 1A 1D 1R · +37 −12
──────────────────────────────────────
M render.go src/render/ +20 −4 3d ago
M README.md +3 −1 20m ago
A notes.md docs/ +14 2h ago
D old.go src/ −8
R new.go ← old.go 1d ago
? scratch.txt 5m ago
… 3 more (2M 1?)
```
- A flat list, no group headers: IDEs (VS Code, JetBrains) do the same, and
headers would use up the few lines the preview has. The letter is the
meaning, the color only supports it (N4).
- Letters and colors: `!` red bold, `M` yellow, `A` green, `D` red, `R`
cyan, `?` muted. Line counts: green `+n`, red `−n`; `bin` for binary
files, `submodule` for a moved nested pin, nothing for untracked files.
- Order: kind (`!`, `M`, `A`, `D`, `R`, `?`), then changed lines, largest
first, because the biggest edits are the ones people remember.
- Budget: 10 files. Every kind that occurs gets at least one line, the
rest is filled in sort order. `… n more (<kinds>)` summarizes the hidden
files. With 11 files all are shown: "1 more" would take the same line as
the file itself.
- The `Changes` line summarizes all files: count, files per kind, line
totals. Everything below it describes one file per line, so no line can
be mistaken for a file's detail. In a narrow pane the line totals are
dropped rather than cut.
- A muted rule (`─`, `-` in ASCII mode) separates the summary from the
files. It starts in the value column and is as wide as the summary or
the file table, whichever is wider.
- The age (muted) is the file's modification time, so the most recently
touched files stand out. Removed files have no age.
- The list starts in the value column (under `5 files`), like every other
value in the pane.
- File name bold, directory muted. Counts form a right-aligned column
directly after the widest path (max 48 columns), not at the pane's edge,
so they stay next to their file in wide panes. If space is short the age
column goes first, then the directory is cut from the left
(`…ernal/client/`), the name last. For
renames the muted part is `← old/path`.
- Untracked directories are shown as one entry (`tmp/`).
### Header
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`