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 provider.go Provider interface
internal/submodule/git/ read-only provider using the git CLI internal/submodule/git/ read-only provider using the git CLI
exec.go git runner (environment, errors) 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 provider.go Tree, Status, pin → ref resolution
internal/submodule/mock/ fixture provider with artificial latency internal/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view) keys.go key bindings (also feed the help view)
msgs.go treeLoadedMsg, statusLoadedMsg 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 tree/ tree component: rows, navigation, mouse, view
detail/ detail pane detail/ detail pane; changes.go: change preview
header/ header line header/ header line
footer/ help and flash messages footer/ help and flash messages
``` ```
+18
View File
@@ -128,3 +128,21 @@ processes.
**Decision.** The git provider limits concurrent `Status` calls to **Decision.** The git provider limits concurrent `Status` calls to
`runtime.NumCPU()` with a semaphore that respects `ctx`. `runtime.NumCPU()` with a semaphore that respects `ctx`.
**Consequences.** The UI stays unchanged; rows fill in steadily. **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) - **F22** Lists the commits between pin and tip (SHA, subject, author, age)
with a title stating ahead/behind/diverged. with a title stating ahead/behind/diverged.
- **F23** Shows child count, issues below, and the remote URL. - **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 ### Header and footer
+27 -1
View File
@@ -32,10 +32,29 @@ in several at once.
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` | | `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) | | `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference | | `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 `Status == nil` means "still loading". `Submodule.Err` is set if loading
failed. 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 ## Issues: summarizing the dimensions
The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes 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>` | | `Pinned`, `PinStaged` | parent: `ls-files -s -- <path>` vs `ls-tree HEAD -- <path>` |
| `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` | | `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` |
| `Checkout` | `rev-parse HEAD`, `symbolic-ref -q HEAD` (no branch → detached) | | `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 | | `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote | | `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` | | `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. - Rule 6 shows the short SHA; there is no tip and no distance.
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make - `--ignore-submodules=dirty`: changes inside a nested submodule do not make
its parent `changed` (D7), but a moved nested pin does. 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 - A missing submodule only gets a `Pinned` SHA; nested submodules of an
uninitialized submodule are unknown and not shown. uninitialized submodule are unknown and not shown.
- If the pinned commit doesn't exist in the submodule (not fetched), loading - 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; 2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none; `staged` and `in sync nothing to do` if there are none; `staged` and
`detached HEAD on pin` as information `detached HEAD on pin` as information
3. `Ref` with distance and `via <rule>` 3. `Changes` (only with uncommitted changes), see
4. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it [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 differs from the pin
5. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list 6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
6. `Children` (count and issues below), `URL` 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 ### Header
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>` `path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
+103 -7
View File
@@ -56,23 +56,119 @@ func parseGitmodules(out string) []gitmodule {
return mods return mods
} }
// parseWorktree summarizes `git status --porcelain=v2` output. A conflict // entry is one changed path of `git status --porcelain=v2 -z`.
// outranks modifications, which outrank untracked files. type entry struct {
func parseWorktree(out string) submodule.Worktree { path, orig string
kind submodule.ChangeKind
tracked bool // false for untracked files
submodule bool // a nested submodule pointer changed
}
// parseStatus parses `git status --porcelain=v2 -z`. Entries that don't
// differ from HEAD (e.g. added to the index, then deleted again) are
// dropped.
func parseStatus(out string) []entry {
var es []entry
recs := strings.Split(out, "\x00")
for i := 0; i < len(recs); i++ {
rec := recs[i]
if len(rec) < 3 {
continue
}
switch rec[0] {
case '?':
es = append(es, entry{path: rec[2:], kind: submodule.ChangeUntracked})
case 'u': // u XY sub m1 m2 m3 mW h1 h2 h3 path
if f := strings.SplitN(rec, " ", 11); len(f) == 11 {
es = append(es, entry{path: f[10], kind: submodule.ChangeConflicted, tracked: true})
}
case '1': // 1 XY sub mH mI mW hH hI path
if f := strings.SplitN(rec, " ", 9); len(f) == 9 {
if e, ok := trackedEntry(f[1], f[2], f[8], ""); ok {
es = append(es, e)
}
}
case '2': // 2 XY sub mH mI mW hH hI Xscore path NUL origPath
if f := strings.SplitN(rec, " ", 10); len(f) == 10 && i+1 < len(recs) {
i++
if e, ok := trackedEntry(f[1], f[2], f[9], recs[i]); ok {
es = append(es, e)
}
}
}
}
return es
}
func trackedEntry(xy, sub, path, orig string) (entry, bool) {
if len(xy) != 2 {
return entry{}, false
}
x, y := xy[0], xy[1]
e := entry{path: path, orig: orig, tracked: true, submodule: strings.HasPrefix(sub, "S")}
switch {
case x == 'R' || y == 'R':
e.kind = submodule.ChangeRenamed
case x == 'A' && y == 'D':
return entry{}, false
case x == 'A' || y == 'A' || x == 'C' || y == 'C':
e.kind = submodule.ChangeAdded
case x == 'D' || y == 'D':
e.kind = submodule.ChangeRemoved
default:
e.kind = submodule.ChangeModified
}
return e, true
}
// worktreeOf summarizes entries: a conflict outranks tracked changes, which
// outrank untracked files.
func worktreeOf(es []entry) submodule.Worktree {
w := submodule.WorktreeClean w := submodule.WorktreeClean
for _, line := range strings.Split(out, "\n") { for _, e := range es {
switch { switch {
case strings.HasPrefix(line, "u "): case e.kind == submodule.ChangeConflicted:
return submodule.WorktreeConflict return submodule.WorktreeConflict
case strings.HasPrefix(line, "1 "), strings.HasPrefix(line, "2 "): case e.tracked:
w = submodule.WorktreeModified w = submodule.WorktreeModified
case strings.HasPrefix(line, "? ") && w == submodule.WorktreeClean: case w == submodule.WorktreeClean:
w = submodule.WorktreeUntracked w = submodule.WorktreeUntracked
} }
} }
return w return w
} }
// numstat holds the line counts of one file.
type numstat struct {
added, removed int
binary bool
}
// parseNumstat parses `git diff --numstat -z -M`, keyed by the (new) path.
func parseNumstat(out string) map[string]numstat {
m := map[string]numstat{}
recs := strings.Split(out, "\x00")
for i := 0; i < len(recs); i++ {
f := strings.SplitN(recs[i], "\t", 3)
if len(f) != 3 {
continue
}
path := f[2]
if path == "" { // rename: the next two records are old and new path
if i+2 >= len(recs) {
break
}
path = recs[i+2]
i += 2
}
n := numstat{binary: f[0] == "-"}
n.added, _ = strconv.Atoi(f[0])
n.removed, _ = strconv.Atoi(f[1])
m[path] = n
}
return m
}
// parseLeftRight parses `git rev-list --left-right --count a...b`. // parseLeftRight parses `git rev-list --left-right --count a...b`.
func parseLeftRight(out string) (left, right int, err error) { func parseLeftRight(out string) (left, right int, err error) {
f := strings.Fields(out) f := strings.Fields(out)
+62 -9
View File
@@ -2,6 +2,7 @@ package git
import ( import (
"slices" "slices"
"strings"
"testing" "testing"
"submarine/internal/submodule" "submarine/internal/submodule"
@@ -26,20 +27,72 @@ func TestParseGitmodules(t *testing.T) {
} }
} }
func TestParseWorktree(t *testing.T) { func z(recs ...string) string { return strings.Join(recs, "\x00") + "\x00" }
func TestParseStatus(t *testing.T) {
out := z(
"1 .M N... 100644 100644 100644 a b src/my file.go",
"1 A. N... 000000 100644 100644 a b new.go",
"1 AD N... 000000 100644 000000 a b gone.go",
"1 D. N... 100644 000000 000000 a b old.go",
"1 .M S.M. 160000 160000 160000 a b libs/nested",
"2 R. N... 100644 100644 100644 a b R100 dir/ü new.go", "dir/old.go",
"u UU N... 100644 100644 100644 100644 a b c conflict.go",
"? scratch dir/",
"! ignored.o",
)
want := []entry{
{path: "src/my file.go", kind: submodule.ChangeModified, tracked: true},
{path: "new.go", kind: submodule.ChangeAdded, tracked: true},
{path: "old.go", kind: submodule.ChangeRemoved, tracked: true},
{path: "libs/nested", kind: submodule.ChangeModified, tracked: true, submodule: true},
{path: "dir/ü new.go", orig: "dir/old.go", kind: submodule.ChangeRenamed, tracked: true},
{path: "conflict.go", kind: submodule.ChangeConflicted, tracked: true},
{path: "scratch dir/", kind: submodule.ChangeUntracked},
}
if got := parseStatus(out); !slices.Equal(got, want) {
t.Errorf("parseStatus() =\n%+v\nwant\n%+v", got, want)
}
if got := parseStatus(""); got != nil {
t.Errorf("parseStatus(\"\") = %+v, want nil", got)
}
}
func TestWorktreeOf(t *testing.T) {
untracked := entry{kind: submodule.ChangeUntracked}
modified := entry{kind: submodule.ChangeModified, tracked: true}
conflict := entry{kind: submodule.ChangeConflicted, tracked: true}
tests := []struct { tests := []struct {
out string es []entry
want submodule.Worktree want submodule.Worktree
}{ }{
{"", submodule.WorktreeClean}, {nil, submodule.WorktreeClean},
{"? new.txt", submodule.WorktreeUntracked}, {[]entry{untracked}, submodule.WorktreeUntracked},
{"? new.txt\n1 .M N... 100644 100644 100644 a b README", submodule.WorktreeModified}, {[]entry{untracked, modified}, submodule.WorktreeModified},
{"2 R. N... 100644 100644 100644 a b R100 new\told", submodule.WorktreeModified}, {[]entry{modified, untracked, conflict}, submodule.WorktreeConflict},
{"1 .M N... 100644 100644 100644 a b README\nu UU N... 1 2 3 4 a b c README", submodule.WorktreeConflict},
} }
for _, tt := range tests { for _, tt := range tests {
if got := parseWorktree(tt.out); got != tt.want { if got := worktreeOf(tt.es); got != tt.want {
t.Errorf("parseWorktree(%q) = %v, want %v", tt.out, got, tt.want) t.Errorf("worktreeOf(%+v) = %v, want %v", tt.es, got, tt.want)
}
}
}
func TestParseNumstat(t *testing.T) {
out := z("3\t1\tREADME.md", "-\t-\timg/logo.png", "0\t0\t", "old name.go", "new name.go", "5\t0\tsrc/a b.go")
want := map[string]numstat{
"README.md": {added: 3, removed: 1},
"img/logo.png": {binary: true},
"new name.go": {},
"src/a b.go": {added: 5},
}
got := parseNumstat(out)
if len(got) != len(want) {
t.Fatalf("parseNumstat() = %+v, want %+v", got, want)
}
for k, w := range want {
if got[k] != w {
t.Errorf("parseNumstat()[%q] = %+v, want %+v", k, got[k], w)
} }
} }
} }
+47 -2
View File
@@ -11,6 +11,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync" "sync"
@@ -182,11 +183,15 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
case !onBranch: case !onBranch:
st.Checkout = submodule.CheckoutDetached st.Checkout = submodule.CheckoutDetached
} }
out, err = run(ctx, in.dir, "status", "--porcelain=v2", "--ignore-submodules=dirty") out, err = run(ctx, in.dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=dirty")
if err != nil { if err != nil {
return nil, err return nil, err
} }
st.Worktree = parseWorktree(out) entries := parseStatus(out)
st.Worktree = worktreeOf(entries)
if st.Changes, err = changes(ctx, in.dir, entries); err != nil {
return nil, err
}
// the pin must exist locally to say anything about it // the pin must exist locally to say anything about it
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil { if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
@@ -341,6 +346,46 @@ func remoteName(ctx context.Context, dir string) (string, error) {
return "", nil return "", nil
} }
// changes describes the changed files of the worktree; nil if there are
// none. Line counts come from one `git diff HEAD`; if that fails (e.g. no
// commit yet) they stay zero.
func changes(ctx context.Context, dir string, entries []entry) (*submodule.Changes, error) {
if len(entries) == 0 {
return nil, nil
}
var counts map[string]numstat
if slices.ContainsFunc(entries, func(e entry) bool { return e.tracked && !e.submodule }) {
out, ok, err := try(ctx, dir, "diff", "HEAD", "--numstat", "-z", "-M", "--no-ext-diff", "--ignore-submodules=dirty")
if err != nil {
return nil, err
}
if ok {
counts = parseNumstat(out)
}
}
c := &submodule.Changes{Total: len(entries)}
for _, e := range entries {
f := submodule.FileChange{Path: e.path, OrigPath: e.orig, Kind: e.kind, Submodule: e.submodule}
if n, ok := counts[e.path]; ok && !e.submodule {
f.Added, f.Removed, f.Binary = n.added, n.removed, n.binary
c.Added += n.added
c.Removed += n.removed
}
if e.kind != submodule.ChangeRemoved {
if fi, err := os.Stat(filepath.Join(dir, filepath.FromSlash(e.path))); err == nil {
f.Modified = fi.ModTime()
}
}
c.Files = append(c.Files, f)
}
c.Sort()
if len(c.Files) > submodule.MaxChangeFiles {
c.Files = c.Files[:submodule.MaxChangeFiles]
}
return c, nil
}
func commit(ctx context.Context, dir, rev string) (*submodule.Commit, error) { func commit(ctx context.Context, dir, rev string) (*submodule.Commit, error) {
out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--") out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--")
if err != nil { if err != nil {
+90 -1
View File
@@ -3,12 +3,14 @@ package git
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices" "slices"
"testing" "testing"
"time"
"submarine/internal/submodule" "submarine/internal/submodule"
) )
@@ -66,7 +68,7 @@ func TestTree(t *testing.T) {
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) }) submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
want := []string{ want := []string{
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested", "src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/work",
} }
if !slices.Equal(paths, want) { if !slices.Equal(paths, want) {
t.Errorf("paths = %v\nwant %v", paths, want) t.Errorf("paths = %v\nwant %v", paths, want)
@@ -101,6 +103,7 @@ func TestStatus(t *testing.T) {
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true}, {path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}}, {path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2}, {path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
{path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch},
} }
for _, tt := range tests { for _, tt := range tests {
st, err := p.Status(context.Background(), tt.path) st, err := p.Status(context.Background(), tt.path)
@@ -139,6 +142,92 @@ func TestStatus(t *testing.T) {
} }
} }
func TestChanges(t *testing.T) {
p, _ := loadFixture(t)
ctx := context.Background()
st, err := p.Status(ctx, "libs/work")
if err != nil {
t.Fatal(err)
}
c := st.Changes
if c == nil {
t.Fatal("libs/work: Changes = nil")
}
want := []submodule.FileChange{
{Path: "main.go", Kind: submodule.ChangeModified, Added: 6, Removed: 2},
{Path: "util.go", Kind: submodule.ChangeModified, Added: 1},
{Path: "logo.png", Kind: submodule.ChangeModified, Binary: true},
{Path: "notes_new.go", Kind: submodule.ChangeAdded, Added: 1},
{Path: "old.go", Kind: submodule.ChangeRemoved, Removed: 8},
{Path: "lib2.go", OrigPath: "lib.go", Kind: submodule.ChangeRenamed},
{Path: "scratch.txt", Kind: submodule.ChangeUntracked},
{Path: "tmp/", Kind: submodule.ChangeUntracked},
}
for i, f := range c.Files {
if f.Modified.IsZero() != (f.Kind == submodule.ChangeRemoved) {
t.Errorf("libs/work: %s: Modified %v", f.Path, f.Modified)
}
c.Files[i].Modified = time.Time{}
}
if !slices.Equal(c.Files, want) {
t.Errorf("libs/work: Files =\n%+v\nwant\n%+v", c.Files, want)
}
if c.Total != 8 || c.Added != 8 || c.Removed != 10 {
t.Errorf("libs/work: Total %d, +%d -%d; want 8, +8 -10", c.Total, c.Added, c.Removed)
}
if n := c.Counts(nil); n[submodule.ChangeModified] != 3 || n[submodule.ChangeUntracked] != 2 {
t.Errorf("libs/work: Counts = %v", n)
}
for _, path := range []string{"libs/log", "src/ui"} {
st, err := p.Status(ctx, path)
if err != nil {
t.Fatal(err)
}
if st.Changes != nil {
t.Errorf("%s: clean submodule has Changes %+v", path, st.Changes)
}
}
st, err = p.Status(ctx, "libs/conflict")
if err != nil {
t.Fatal(err)
}
if st.Changes == nil || len(st.Changes.Files) != 1 || st.Changes.Files[0].Kind != submodule.ChangeConflicted {
t.Errorf("libs/conflict: Changes = %+v", st.Changes)
}
}
func TestChangesCap(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
dir := t.TempDir()
for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"}} {
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
n := submodule.MaxChangeFiles + 5
for i := range n {
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%03d.txt", i)), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
out, err := run(context.Background(), dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal")
if err != nil {
t.Fatal(err)
}
c, err := changes(context.Background(), dir, parseStatus(out))
if err != nil {
t.Fatal(err)
}
if c.Total != n || len(c.Files) != submodule.MaxChangeFiles || c.Files[0].Path != "f000.txt" {
t.Errorf("Total %d, len %d, first %q; want %d, %d, f000.txt", c.Total, len(c.Files), c.Files[0].Path, n, submodule.MaxChangeFiles)
}
}
func TestStatusMissingPresence(t *testing.T) { func TestStatusMissingPresence(t *testing.T) {
p, _ := loadFixture(t) p, _ := loadFixture(t)
st, err := p.Status(context.Background(), "libs/missing") st, err := p.Status(context.Background(), "libs/missing")
+39
View File
@@ -95,6 +95,24 @@ func inSync(ref string, c *submodule.Commit) *submodule.Status {
} }
} }
// changes builds a Changes value like the git provider: sorted, with
// totals. The last file was edited age ago, earlier ones increasingly
// longer ago.
func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes {
c := &submodule.Changes{Files: files, Total: len(files)}
for i := range files {
f := &files[i]
if f.Kind != submodule.ChangeRemoved {
n := time.Duration(len(files) - 1 - i)
f.Modified = time.Now().Add(-age - n*n*90*time.Minute)
}
c.Added += f.Added
c.Removed += f.Removed
}
c.Sort()
return c
}
func fixture() []node { func fixture() []node {
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour) c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour) c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour)
@@ -104,6 +122,23 @@ func fixture() []node {
srcSub2 := inSync("origin/main", commit("77aa88b", "Merge PR #42", "dana", 5*time.Hour)) srcSub2 := inSync("origin/main", commit("77aa88b", "Merge PR #42", "dana", 5*time.Hour))
srcSub2.Worktree = submodule.WorktreeModified srcSub2.Worktree = submodule.WorktreeModified
srcSub2.Changes = changes(20*time.Minute, []submodule.FileChange{
{Path: "internal/client/retry.go", Kind: submodule.ChangeModified, Added: 84, Removed: 12},
{Path: "internal/client/backoff.go", Kind: submodule.ChangeModified, Added: 41, Removed: 3},
{Path: "internal/server/handler.go", Kind: submodule.ChangeModified, Added: 17, Removed: 17},
{Path: "README.md", Kind: submodule.ChangeModified, Added: 6, Removed: 2},
{Path: "go.mod", Kind: submodule.ChangeModified, Added: 2, Removed: 2},
{Path: "go.sum", Kind: submodule.ChangeModified, Added: 9, Removed: 4},
{Path: "Makefile", Kind: submodule.ChangeModified, Added: 1, Removed: 1},
{Path: "internal/client/retry_test.go", Kind: submodule.ChangeAdded, Added: 63},
{Path: "docs/design/backoff-notes.md", Kind: submodule.ChangeAdded, Added: 28},
{Path: "internal/legacy/old_client.go", Kind: submodule.ChangeRemoved, Removed: 212},
{Path: "assets/logo.png", Kind: submodule.ChangeModified, Binary: true},
{Path: "internal/client/transport.go", OrigPath: "internal/net/transport.go", Kind: submodule.ChangeRenamed, Added: 5, Removed: 1},
{Path: "scratch.txt", Kind: submodule.ChangeUntracked},
{Path: "tmp/", Kind: submodule.ChangeUntracked},
{Path: "profile.out", Kind: submodule.ChangeUntracked},
})
subSubPin := commit("4e5f6a7", "Add feature flag", "eve", 30*time.Hour) subSubPin := commit("4e5f6a7", "Add feature flag", "eve", 30*time.Hour)
subSubTip := commit("9f8e7d6", "Fix login timeout", "anna", 2*time.Hour) subSubTip := commit("9f8e7d6", "Fix login timeout", "anna", 2*time.Hour)
@@ -118,6 +153,10 @@ func fixture() []node {
schema := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour)) schema := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour))
schema.Worktree = submodule.WorktreeUntracked schema.Worktree = submodule.WorktreeUntracked
schema.Changes = changes(40*time.Minute, []submodule.FileChange{
{Path: "v3/user.schema.json", Kind: submodule.ChangeUntracked},
{Path: "v3/order.schema.json", Kind: submodule.ChangeUntracked},
})
unpushed := commit("b7c8d9e", "Hotfix null pointer in parser", "you", 20*time.Minute) unpushed := commit("b7c8d9e", "Hotfix null pointer in parser", "you", 20*time.Minute)
sub3Tip := commit("6f5e4d3", "Release 2.1.0", "chris", 4*24*time.Hour) sub3Tip := commit("6f5e4d3", "Release 2.1.0", "chris", 4*24*time.Hour)
+69 -1
View File
@@ -2,7 +2,12 @@
// (recursively nested) git submodules. It has no UI dependencies. // (recursively nested) git submodules. It has no UI dependencies.
package submodule package submodule
import "time" import (
"cmp"
"slices"
"strings"
"time"
)
// Repo is the superproject. // Repo is the superproject.
type Repo struct { type Repo struct {
@@ -100,6 +105,68 @@ type Commit struct {
When time.Time When time.Time
} }
// ChangeKind classifies one changed file. The order is the display order.
type ChangeKind int
const (
ChangeConflicted ChangeKind = iota
ChangeModified
ChangeAdded
ChangeRemoved
ChangeRenamed
ChangeUntracked
NumChangeKinds
)
// MaxChangeFiles limits Changes.Files; Changes.Total keeps the real count.
const MaxChangeFiles = 200
// FileChange is one changed file, relative to HEAD of the submodule
// (staged and unstaged changes are combined).
type FileChange struct {
Path string
OrigPath string // old path, only for renames
Kind ChangeKind
Added int // added lines; 0 for untracked, binary and nested submodules
Removed int // removed lines
Binary bool // line counts are unknown
Submodule bool // a nested submodule pointer changed
Modified time.Time // modification time of the file; zero if it doesn't exist
}
// Changes describes what differs in the worktree of a submodule.
type Changes struct {
Files []FileChange // at most MaxChangeFiles, sorted by kind, then by changed lines
Total int // all changed files; may exceed len(Files)
Added int // added lines in all files
Removed int // removed lines in all files
}
// Counts returns the number of files per kind among files (all of
// c.Files if files is nil).
func (c *Changes) Counts(files []FileChange) [NumChangeKinds]int {
if files == nil && c != nil {
files = c.Files
}
var n [NumChangeKinds]int
for _, f := range files {
n[f.Kind]++
}
return n
}
// Sort orders Files by kind, then by changed lines (largest first), then by
// path.
func (c *Changes) Sort() {
slices.SortStableFunc(c.Files, func(a, b FileChange) int {
return cmp.Or(
cmp.Compare(a.Kind, b.Kind),
cmp.Compare(b.Added+b.Removed, a.Added+a.Removed),
strings.Compare(a.Path, b.Path),
)
})
}
// Status is the full state of one submodule. // Status is the full state of one submodule.
type Status struct { type Status struct {
Presence Presence Presence Presence
@@ -111,4 +178,5 @@ type Status struct {
CheckedOut *Commit CheckedOut *Commit
Tip *Commit Tip *Commit
Between []Commit // commits that make up the pin <-> tip difference Between []Commit // commits that make up the pin <-> tip difference
Changes *Changes // nil if the worktree is clean or the details are unknown
} }
+234
View File
@@ -0,0 +1,234 @@
package detail
import (
"fmt"
"path"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
// previewBudget is the number of file lines shown in the change preview.
const previewBudget = 10
// pickPreview chooses which files of a sorted change list to show. Every
// kind that occurs gets at least one line; the remaining lines go to the
// files in sort order. Both results keep the sort order. A single hidden
// file would cost a "1 more" line, so it is shown instead.
func pickPreview(files []submodule.FileChange, budget int) (shown, hidden []submodule.FileChange) {
if len(files) <= budget+1 {
return files, nil
}
pick := make([]bool, len(files))
var seen [submodule.NumChangeKinds]bool
left := budget
for i, f := range files {
if !seen[f.Kind] && left > 0 {
seen[f.Kind], pick[i] = true, true
left--
}
}
for i := range files {
if left > 0 && !pick[i] {
pick[i] = true
left--
}
}
for i, f := range files {
if pick[i] {
shown = append(shown, f)
} else {
hidden = append(hidden, f)
}
}
return shown, hidden
}
// changeLines renders the change preview: a summary line and a flat list of
// files, a colored letter in front of each.
func (m Model) changeLines(c *submodule.Changes, label func(string) string) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
plural := "files"
if c.Total == 1 {
plural = "file"
}
// summary over all files: count, kinds, lines
parts := []string{text.Render(fmt.Sprintf("%d %s", c.Total, plural))}
if k := m.kindCounts(c.Counts(nil)); k != "" {
parts = append(parts, k)
}
sep := muted.Render(" · ")
if c.Added+c.Removed > 0 {
// line totals are the least important part: drop them if they don't fit
if withLines := append(parts, m.lineCounts(c.Added, c.Removed)); labelWidth+lipgloss.Width(strings.Join(withLines, sep)) <= m.width {
parts = withLines
}
}
out := []string{label("Changes") + strings.Join(parts, sep)}
shown, hidden := pickPreview(c.Files, previewBudget)
files := m.fileLines(shown)
// a rule under the summary, as wide as the summary or the file table
ruleW := lipgloss.Width(out[0]) - labelWidth
for _, l := range files {
ruleW = max(ruleW, lipgloss.Width(l)-labelWidth)
}
ruleW = min(ruleW, m.width-labelWidth)
out = append(out, label("")+muted.Render(strings.Repeat(m.theme.Glyphs.Rule, ruleW)))
out = append(out, files...)
if more := c.Total - len(shown); more > 0 {
line := label("") + muted.Render(fmt.Sprintf("… %d more", more))
if k := m.kindCounts(c.Counts(hidden)); k != "" {
line += muted.Render(" (") + k + muted.Render(")")
}
out = append(out, line)
}
return out
}
// kindCounts renders "2M 1A 1?", each in the color of its kind.
func (m Model) kindCounts(n [submodule.NumChangeKinds]int) string {
var parts []string
for k := submodule.ChangeKind(0); k < submodule.NumChangeKinds; k++ {
if n[k] > 0 {
parts = append(parts, m.theme.ChangeStyle(k, lipgloss.NewStyle()).Render(fmt.Sprintf("%d%s", n[k], theme.ChangeLetter(k))))
}
}
return strings.Join(parts, " ")
}
// lineCounts renders "+20 −4"; zero parts are omitted.
func (m Model) lineCounts(added, removed int) string {
t := m.theme
var parts []string
if added > 0 {
parts = append(parts, theme.Fg(t.Added).Render(fmt.Sprintf("+%d", added)))
}
if removed > 0 {
parts = append(parts, theme.Fg(t.Danger).Render(fmt.Sprintf("%s%d", t.Glyphs.Minus, removed)))
}
return strings.Join(parts, " ")
}
// maxPathWidth caps the path column, so the counts stay close to the names
// in wide panes; longer paths have their directory cut.
const maxPathWidth = 48
// minPathWidth is the path width below which the age column is dropped.
const minPathWidth = 16
// fileRow is one file of the preview, split into its columns.
type fileRow struct {
kind submodule.ChangeKind
name, prefix, dir string // prefix ("← ") is kept when dir is cut
counts string // rendered
age string // e.g. "19m ago"; empty for removed files
}
func (r fileRow) pathWidth() int {
w := lipgloss.Width(r.name)
if r.dir != "" {
w += 2 + lipgloss.Width(r.prefix) + lipgloss.Width(r.dir)
}
return w
}
// fileLines renders the files as a table in the value column:
//
// M name dir/ +20 −4 19m ago
//
// The path column is as wide as the widest path (up to maxPathWidth and the
// pane width); counts are right-aligned next to it, followed by the age. If
// space is short the age column goes first, then the directory is cut from
// the left, the name last.
func (m Model) fileLines(files []submodule.FileChange) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
rows := make([]fileRow, len(files))
pathW, countsW, ageW := 0, 0, 0
for i, f := range files {
r := fileRow{kind: f.Kind}
r.name, r.dir = splitPath(f.Path)
if f.OrigPath != "" {
r.prefix, r.dir = t.Glyphs.From+" ", f.OrigPath
}
switch {
case f.Submodule:
r.counts = muted.Render("submodule")
case f.Binary:
r.counts = muted.Render("bin")
default:
r.counts = m.lineCounts(f.Added, f.Removed)
}
if !f.Modified.IsZero() {
r.age = theme.Ago(f.Modified)
}
rows[i] = r
pathW = max(pathW, r.pathWidth())
countsW = max(countsW, lipgloss.Width(r.counts))
ageW = max(ageW, lipgloss.Width(r.age))
}
avail := m.width - labelWidth - 3 // letter and its gap
if countsW > 0 {
avail -= countsW + 2
}
if ageW > 0 && avail-ageW-2 < min(pathW, minPathWidth) {
ageW = 0 // too narrow: drop the age column
}
if ageW > 0 {
avail -= ageW + 2
}
pathW = max(min(pathW, maxPathWidth, avail), 1)
out := make([]string, len(rows))
for i, r := range rows {
name := ansi.Truncate(r.name, pathW, "…")
dir := ""
if room := pathW - lipgloss.Width(name) - 2 - lipgloss.Width(r.prefix); room >= 2 && r.dir != "" {
dir = r.dir
if w := lipgloss.Width(dir); w > room {
dir = ansi.TruncateLeft(dir, w-(room-1), "…")
}
dir = r.prefix + dir
}
path := text.Bold(true).Render(name)
if dir != "" {
path += " " + muted.Render(dir)
}
line := strings.Repeat(" ", labelWidth) +
t.ChangeStyle(r.kind, lipgloss.NewStyle()).Render(theme.ChangeLetter(r.kind)) + " " + path
line += strings.Repeat(" ", pathW-lipgloss.Width(path))
if countsW > 0 {
line += strings.Repeat(" ", 2+countsW-lipgloss.Width(r.counts)) + r.counts
}
if ageW > 0 && r.age != "" {
line += " " + muted.Render(r.age)
}
out[i] = strings.TrimRight(line, " ")
}
return out
}
// splitPath returns the base name and the directory with a trailing slash.
// A path ending in a slash (an untracked directory) keeps it on the name.
func splitPath(p string) (name, dir string) {
suffix := ""
if strings.HasSuffix(p, "/") {
p, suffix = strings.TrimSuffix(p, "/"), "/"
}
name = path.Base(p) + suffix
if d := path.Dir(p); d != "." {
dir = d + "/"
}
return name, dir
}
+65
View File
@@ -0,0 +1,65 @@
package detail
import (
"fmt"
"testing"
"submarine/internal/submodule"
)
func files(kind submodule.ChangeKind, n int) []submodule.FileChange {
var fs []submodule.FileChange
for i := range n {
fs = append(fs, submodule.FileChange{Path: fmt.Sprintf("%d-%d", kind, i), Kind: kind})
}
return fs
}
func TestPickPreview(t *testing.T) {
t.Run("fits", func(t *testing.T) {
all := append(files(submodule.ChangeModified, 2), files(submodule.ChangeUntracked, 1)...)
shown, hidden := pickPreview(all, 5)
if len(shown) != 3 || len(hidden) != 0 {
t.Errorf("shown %d, hidden %d; want 3, 0", len(shown), len(hidden))
}
})
t.Run("one over the budget shows all", func(t *testing.T) {
shown, hidden := pickPreview(files(submodule.ChangeModified, 6), 5)
if len(shown) != 6 || len(hidden) != 0 {
t.Errorf("shown %d, hidden %d; want 6, 0", len(shown), len(hidden))
}
})
t.Run("every kind keeps a line", func(t *testing.T) {
all := files(submodule.ChangeModified, 8)
all = append(all, files(submodule.ChangeRemoved, 1)...)
all = append(all, files(submodule.ChangeUntracked, 3)...)
shown, hidden := pickPreview(all, 4)
if len(shown) != 4 || len(hidden) != 8 {
t.Fatalf("shown %d, hidden %d; want 4, 8", len(shown), len(hidden))
}
want := []string{"1-0", "1-1", "3-0", "5-0"} // sort order is kept
for i, f := range shown {
if f.Path != want[i] {
t.Errorf("shown[%d] = %q, want %q (all: %+v)", i, f.Path, want[i], shown)
}
}
if n := (&submodule.Changes{}).Counts(hidden); n[submodule.ChangeModified] != 6 || n[submodule.ChangeUntracked] != 2 {
t.Errorf("hidden counts = %v", n)
}
})
}
func TestSplitPath(t *testing.T) {
for _, tt := range []struct{ in, name, dir string }{
{"README.md", "README.md", ""},
{"src/a/b.go", "b.go", "src/a/"},
{"tmp/", "tmp/", ""},
{"src/tmp/", "tmp/", "src/"},
} {
if name, dir := splitPath(tt.in); name != tt.name || dir != tt.dir {
t.Errorf("splitPath(%q) = %q, %q; want %q, %q", tt.in, name, dir, tt.name, tt.dir)
}
}
}
+4
View File
@@ -82,6 +82,10 @@ func (m Model) lines(s *submodule.Submodule) []string {
if st.Checkout == submodule.CheckoutDetached { if st.Checkout == submodule.CheckoutDetached {
out = append(out, label("")+muted.Render("detached HEAD on pin")) out = append(out, label("")+muted.Render("detached HEAD on pin"))
} }
if st.Changes != nil {
out = append(out, "")
out = append(out, m.changeLines(st.Changes, label)...)
}
out = append(out, "") out = append(out, "")
// ref // ref
+36
View File
@@ -25,6 +25,42 @@ func IssueWord(k submodule.IssueKind) string {
} }
} }
// ChangeLetter is the one-letter label of a changed file.
func ChangeLetter(k submodule.ChangeKind) string {
switch k {
case submodule.ChangeConflicted:
return "!"
case submodule.ChangeModified:
return "M"
case submodule.ChangeAdded:
return "A"
case submodule.ChangeRemoved:
return "D"
case submodule.ChangeRenamed:
return "R"
default:
return "?"
}
}
// ChangeStyle returns base with the color of a change kind.
func (t Theme) ChangeStyle(k submodule.ChangeKind, base lipgloss.Style) lipgloss.Style {
switch k {
case submodule.ChangeConflicted:
return base.Foreground(t.Danger).Bold(true)
case submodule.ChangeModified:
return base.Foreground(t.Warn)
case submodule.ChangeAdded:
return base.Foreground(t.Added)
case submodule.ChangeRemoved:
return base.Foreground(t.Danger)
case submodule.ChangeRenamed:
return base.Foreground(t.Primary)
default:
return base.Foreground(t.Muted)
}
}
// LevelStyle returns base with the text color (and weight) for a level. // LevelStyle returns base with the text color (and weight) for a level.
func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style { func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style {
switch l { switch l {
+5 -3
View File
@@ -13,6 +13,7 @@ import (
type Theme struct { type Theme struct {
Primary lipgloss.TerminalColor // accent: branch names, commit hashes Primary lipgloss.TerminalColor // accent: branch names, commit hashes
Info lipgloss.TerminalColor Info lipgloss.TerminalColor
Added lipgloss.TerminalColor // added lines and files
Warn lipgloss.TerminalColor Warn lipgloss.TerminalColor
Danger lipgloss.TerminalColor Danger lipgloss.TerminalColor
Muted lipgloss.TerminalColor Muted lipgloss.TerminalColor
@@ -31,6 +32,7 @@ func Default() Theme {
return Theme{ return Theme{
Primary: lipgloss.Color("6"), // cyan Primary: lipgloss.Color("6"), // cyan
Info: lipgloss.Color("4"), // blue Info: lipgloss.Color("4"), // blue
Added: lipgloss.Color("2"), // green
Warn: lipgloss.Color("3"), // yellow Warn: lipgloss.Color("3"), // yellow
Danger: lipgloss.Color("1"), // red Danger: lipgloss.Color("1"), // red
Muted: lipgloss.Color("8"), // bright black / grey Muted: lipgloss.Color("8"), // bright black / grey
@@ -49,17 +51,17 @@ func Fg(c lipgloss.TerminalColor) lipgloss.Style {
// Glyphs are the few symbols used next to the state words. // Glyphs are the few symbols used next to the state words.
type Glyphs struct { type Glyphs struct {
Staged, Ahead, Behind, Collapsed, Expanded, Problems string Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule string
} }
func Unicode() Glyphs { func Unicode() Glyphs {
return Glyphs{ return Glyphs{
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─",
} }
} }
func ASCII() Glyphs { func ASCII() Glyphs {
return Glyphs{ return Glyphs{
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-",
} }
} }
+23 -1
View File
@@ -42,7 +42,7 @@ upstream() {
} }
mkdir -p "$root/remotes" "$root/work" mkdir -p "$root/remotes" "$root/work"
for r in core ui net store log feature nested; do remote "$r"; done for r in core ui net store log feature nested work; do remote "$r"; done
# "feature" has a branch that main doesn't contain # "feature" has a branch that main doesn't contain
g "$root/work/feature" switch -c feat/search g "$root/work/feature" switch -c feat/search
@@ -57,6 +57,17 @@ commit "$root/work/store" dev "Start develop"
g "$root/work/store" push origin develop g "$root/work/store" push origin develop
g "$root/work/store" switch main g "$root/work/store" switch main
# "work" has several tracked files, to show every kind of change
w="$root/work/work"
seq 1 20 | sed 's/^/line /' >"$w/main.go"
seq 1 3 | sed 's/^/util /' >"$w/util.go"
seq 1 8 | sed 's/^/old /' >"$w/old.go"
seq 1 5 | sed 's/^/lib /' >"$w/lib.go"
printf '\000\001\002\003' >"$w/logo.png"
g "$w" add .
g "$w" commit -m "Add sources"
g "$w" push origin main
# "nested" becomes a submodule of "log" # "nested" becomes a submodule of "log"
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
g "$root/work/log" commit -m "Add nested" g "$root/work/log" commit -m "Add nested"
@@ -76,6 +87,7 @@ add core libs/conflict
add ui libs/staged add ui libs/staged
add net libs/missing add net libs/missing
add ui vendor add ui vendor
add work libs/work
g "$super" submodule update --init --recursive g "$super" submodule update --init --recursive
g "$super" commit -m "Add submodules" g "$super" commit -m "Add submodules"
@@ -106,6 +118,16 @@ echo dirty >>"$super/libs/log/modules/nested/README"
# vendor: untracked file # vendor: untracked file
echo scratch >"$super/vendor/scratch.txt" echo scratch >"$super/vendor/scratch.txt"
# libs/work: one of every kind of change
wk="$super/libs/work"
{ seq 3 20 | sed 's/^/line /'; seq 1 6 | sed 's/^/new /'; } >"$wk/main.go" # modified +6 -2
echo "util 4" >>"$wk/util.go" # modified +1
printf '\000\001\002\003\004' >"$wk/logo.png" # modified, binary
echo "package work" >"$wk/notes_new.go"; g "$wk" add notes_new.go # added (staged)
rm "$wk/old.go" # removed
g "$wk" mv lib.go lib2.go # renamed (staged)
echo scratch >"$wk/scratch.txt"; mkdir "$wk/tmp"; echo x >"$wk/tmp/a.txt" # untracked
# libs/staged: new pin staged in the superproject, not committed # libs/staged: new pin staged in the superproject, not committed
g "$super/libs/staged" merge --ff-only origin/main g "$super/libs/staged" merge --ff-only origin/main
g "$super" add libs/staged g "$super" add libs/staged