From 2212b24fe1665d06c71552eef1e03c73b7ce68a1 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Wed, 21 Oct 2026 14:59:53 +0200 Subject: [PATCH] Add submarine TUI prototype with mock data Bubble Tea prototype for browsing repositories with many nested git submodules: collapsible tree, pins shown as ref + distance, summarized issue words with severity levels, detail pane, async loading via a Provider interface backed by mock data. Includes docs/ with requirements, state model, UX guide, architecture and decision log, plus AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 36 +++ AGENTS.md | 42 +++ cmd/submarine/main.go | 46 ++++ docs/README.md | 29 +++ docs/architecture.md | 105 ++++++++ docs/decisions.md | 101 +++++++ docs/requirements.md | 104 ++++++++ docs/state-model.md | 108 ++++++++ docs/ux-guide.md | 145 +++++++++++ go.mod | 33 +++ go.sum | 56 ++++ internal/submodule/mock/mock.go | 193 ++++++++++++++ internal/submodule/model.go | 114 ++++++++ internal/submodule/provider.go | 10 + internal/submodule/status.go | 81 ++++++ internal/submodule/status_test.go | 52 ++++ internal/tui/app.go | 211 +++++++++++++++ internal/tui/detail/detail.go | 156 +++++++++++ internal/tui/footer/footer.go | 66 +++++ internal/tui/header/header.go | 71 +++++ internal/tui/keys.go | 48 ++++ internal/tui/msgs.go | 14 + internal/tui/theme/issues.go | 71 +++++ internal/tui/theme/theme.go | 65 +++++ internal/tui/tree/rows.go | 68 +++++ internal/tui/tree/tree.go | 420 ++++++++++++++++++++++++++++++ internal/tui/tree/tree_test.go | 234 +++++++++++++++++ 27 files changed, 2679 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 cmd/submarine/main.go create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/decisions.md create mode 100644 docs/requirements.md create mode 100644 docs/state-model.md create mode 100644 docs/ux-guide.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/submodule/mock/mock.go create mode 100644 internal/submodule/model.go create mode 100644 internal/submodule/provider.go create mode 100644 internal/submodule/status.go create mode 100644 internal/submodule/status_test.go create mode 100644 internal/tui/app.go create mode 100644 internal/tui/detail/detail.go create mode 100644 internal/tui/footer/footer.go create mode 100644 internal/tui/header/header.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/msgs.go create mode 100644 internal/tui/theme/issues.go create mode 100644 internal/tui/theme/theme.go create mode 100644 internal/tui/tree/rows.go create mode 100644 internal/tui/tree/tree.go create mode 100644 internal/tui/tree/tree_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..910889f --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Binaries +/submarine +/bin/ +/dist/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test and coverage output +*.test +*.out +coverage.* +*.coverprofile + +# Go workspace files (local) +go.work +go.work.sum + +# Environment +.env + +# Editors and IDEs +.idea/ +.vscode/ +.zed/ +*.swp +*~ + +# OS files +.DS_Store +Thumbs.db + +# Claude Code local settings +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..920a2d7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS.md + +submarine: a Go + Bubble Tea TUI for repositories with many git submodules. +Currently a prototype on mock data. + +## Read first + +The `docs/` folder is the source of truth for requirements, state rules, UX +and decisions. Read the relevant document before changing behavior: + +- [docs/README.md](docs/README.md): overview, status, next steps +- [docs/requirements.md](docs/requirements.md): what the tool must do +- [docs/state-model.md](docs/state-model.md): states, issues, levels, pin → ref resolution +- [docs/ux-guide.md](docs/ux-guide.md): layout, colors, wording, key bindings +- [docs/architecture.md](docs/architecture.md): packages, provider boundary, loading, testing +- [docs/decisions.md](docs/decisions.md): decision log with rationale + +## Commands + +```sh +go run ./cmd/submarine # run (mock data) +go vet ./... +go test ./... +gofmt -l . # must print nothing +``` + +## Rules + +- **Keep docs in sync.** A change to behavior, keys, colors, wording or + architecture updates the matching document in `docs/`. A new significant + decision gets an entry in `docs/decisions.md`. +- **Layering.** `internal/submodule` has no UI imports and decides what a + state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. + Components never classify states or define colors themselves. +- **Data access** only through `submodule.Provider`. +- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see + `docs/ux-guide.md#colors`). Render segments on a background with a `base` + style (`LevelStyle(level, base)`, `Delta(pin, base)`). +- **Tree navigation** stays in plain methods on `tree.Model` with unit tests + in `internal/tui/tree/tree_test.go`. +- **Verify UI changes** by running the app, e.g. headless in tmux (see + `docs/architecture.md#testing`), not only by tests. diff --git a/cmd/submarine/main.go b/cmd/submarine/main.go new file mode 100644 index 0000000..402d6fa --- /dev/null +++ b/cmd/submarine/main.go @@ -0,0 +1,46 @@ +// Command submarine is a TUI for repositories with many git submodules. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" + + "submarine/internal/submodule" + "submarine/internal/submodule/mock" + "submarine/internal/tui" +) + +func main() { + useMock := flag.Bool("mock", true, "use built-in mock data instead of a real repository") + flag.Parse() + + var provider submodule.Provider + if *useMock { + provider = mock.New() + } else { + fmt.Fprintln(os.Stderr, "only --mock is supported in the prototype") + os.Exit(2) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Detect the background (for adaptive colors) before Bubble Tea takes + // over stdin; the result is cached by lipgloss. + lipgloss.HasDarkBackground() + + zone.NewGlobal() + defer zone.Close() + + p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..45cb6de --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# submarine — documentation + +submarine is a terminal UI for git repositories with many (recursively nested) +submodules. It shows **only the submodules**, as a collapsible tree, and +describes each pin as a **branch plus distance** (`origin/main ↓1`) instead of a +bare commit hash. + +## Documents + +| Document | Contents | +|---|---| +| [requirements.md](requirements.md) | What the tool must do: functional and non-functional requirements | +| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs | +| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings | +| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing | +| [decisions.md](decisions.md) | Decision log with rationale (ADR style) | + +## Status + +**Prototype.** The UI is complete for browsing and runs on mock data +(`internal/submodule/mock`). There is no git backend yet, and actions +(`u`, `b`, `f`, `/`, `!`) only show a "not implemented" message. + +Next steps, roughly in order: + +1. Git provider implementing `submodule.Provider` (read-only). +2. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init. +3. Filter (`/`) and "problems only" (`!`) views. +4. Refresh after actions and on demand. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f3fea0f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,105 @@ +# Architecture + +Go, [Bubble Tea](https://github.com/charmbracelet/bubbletea) (v1), +[Lip Gloss](https://github.com/charmbracelet/lipgloss), +[Bubbles](https://github.com/charmbracelet/bubbles) (key, help, spinner), +[bubblezone](https://github.com/lrstanley/bubblezone) (mouse hit areas). + +## Package layout + +``` +cmd/submarine/main.go wiring: choose provider (--mock), start the program +internal/submodule/ domain model; NO UI imports + model.go Repo, Submodule, Status, PinInfo, Commit, enums + status.go Issues(), HiddenIssues(), Walk() + provider.go Provider interface +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 + tree/ tree component: rows, navigation, mouse, view + detail/ detail pane + header/ header line + footer/ help and flash messages +``` + +### Layering rules + +- `internal/submodule` knows nothing about the UI. Everything that decides + **what** a state means (issue kinds, levels, hidden issues) lives here. +- `internal/tui/theme` decides **how** it looks: words, colors, symbols. +- Components (`tree`, `detail`, `header`, `footer`) use the theme; they never + define colors or classify states themselves. +- The UI only accesses data through `submodule.Provider`. + +## Provider boundary + +```go +type Provider interface { + Tree(ctx context.Context) (*Repo, error) // structure only, fast + Status(ctx context.Context, path string) (*Status, error) // per submodule, may be slow +} +``` + +- `Tree` returns the superproject and all submodules with `Parent` and + `Children` linked. `Status` fields are nil. +- `Status` is called once per submodule, concurrently. It must be safe to + call concurrently and must respect `ctx`. +- `Submodule.Path` (relative to the superproject root) is the unique ID. + +The mock provider (`internal/submodule/mock`) covers every state and sleeps +100–800 ms per `Status` call to exercise the loading path. + +## Loading flow + +1. `Init` runs `Tree()` → `treeLoadedMsg`. +2. The app indexes submodules by path and starts one `tea.Cmd` per submodule + calling `Status()` → `statusLoadedMsg`. +3. Each result is stored on the submodule (`Status` or `Err`); the view + updates incrementally, including parents' hidden-issue counts. +4. The spinner stops ticking when nothing is loading. + +## Tree component + +- State: roots, `expanded map[path]bool`, cursor (a `*Submodule`), scroll + offset, size. +- `rows()` flattens the visible tree into directory-header rows and + submodule rows. Directory grouping uses `path.Dir(RelPath)` and keeps the + order of first appearance. +- Navigation methods (`Up`, `Down`, `Left`, `Right`, `Toggle`, + `CollapseAll`, `ExpandAll`, `Home`, `End`, `PageUp`, `PageDown`) are plain + methods without Bubble Tea dependencies, so they are unit-tested directly. +- Key mapping happens in `app.go`; the tree only handles mouse messages. +- Mouse zones: `toggle:` and `row:`, prefixed per instance. + +## Rendering notes + +- Each segment of a row is rendered with the row's `base` style + (background), because an inner style's reset would otherwise clear the + outer background. +- Widths are measured with `lipgloss.Width`; truncation uses + `ansi.Truncate`, which keeps escape sequences and bubblezone markers intact. +- `zone.Scan` runs once, on the root view. + +## Testing + +```sh +go vet ./... +go test ./... +``` + +- `internal/submodule`: issue classification and hidden-issue aggregation. +- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see + `TestMain`). + +Manual check in a headless terminal: + +```sh +go build -o /tmp/submarine ./cmd/submarine +tmux new-session -d -s sub -x 130 -y 24 /tmp/submarine +tmux send-keys -t sub Down Right +tmux capture-pane -pt sub # add -e to see colors +tmux kill-session -t sub +``` diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..a3c8d8c --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,101 @@ +# Decision log + +Each entry: context, decision, consequences. Newest last. + +## D1 — Show pins as ref + distance, not hashes + +**Context.** Hashes don't tell users whether a pin is current. +**Decision.** Resolve each pin to a ref and show the distance (`origin/main ↓1`). +The resolution order is fixed and the applied rule is shown in the detail pane +(see [state-model.md](state-model.md#resolving-a-pin-to-a-ref)). +**Consequences.** Resolution is a heuristic; showing the rule keeps it +transparent. + +## D2 — Compare three commits: pinned, checked out, tip + +**Context.** The first mockup showed one branch for both the checkout and the +pin, so a mismatch was invisible. +**Decision.** Model all three commits. The tree's ref column always describes +the **pin**; checkout drift is an issue (`off pin`). +**Consequences.** The detail pane shows Pinned / Checkout / Tip rows. + +## D3 — Raw dimensions in the domain, summarized issues in the UI + +**Context.** States are not mutually exclusive (a submodule can be off pin and +dirty), but showing every dimension made rows noisy (14 symbols). +**Decision.** `Status` stores independent dimensions. `Status.Issues()` +summarizes them into five kinds with three levels. The tree shows only the +most severe issue (+n); the detail pane shows all. +**Consequences.** One place defines what a problem is; a git backend gets +the same behavior for free. + +## D4 — Attention follows severity + +**Context.** Healthy rows competed for attention with dangerous ones. +**Decision.** Three levels: quiet (muted row, no word), warn (yellow), +danger (red bold). Behind/ahead, staged pin and detached HEAD are quiet. +**Consequences.** A long list reads as grey, with problems standing out. + +## D5 — Words instead of glyphs + +**Context.** Symbols (`≠ ✚ ○ ⚠ ●`) were found unintuitive. +**Decision.** Short words: `unpushed`, `conflict`, `missing`, `off pin`, +`changed` (plus `error`). Symbols remain only for arrows and tree markers. +**Consequences.** Status column is 14 columns wide; no legend needed. + +## D6 — "Unpushed pin" is the most severe state + +**Context.** Committing a superproject whose pin isn't on any remote breaks +everyone else's checkout. +**Decision.** `unpushed` is Danger, listed first, and its `↑n` is red. + +## D7 — Hidden issues bubble up to collapsed parents + +**Context.** Only the first level is shown by default, so nested problems +would be invisible. +**Decision.** Collapsed parents show `·k` (issues in all descendants), colored +by the worst one. Problems in a child do **not** mark the parent itself as +`changed`. + +## D8 — Collapsed by default, branch-first navigation + +**Decision.** Only the first level is visible. `→` expands and enters the +first child, `←` collapses and goes to the parent, `space` toggles, +`ctrl+→/←` expand/collapse all (see [ux-guide.md](ux-guide.md#navigation)). +`←` on an expanded node collapses it in place, so a node can be closed +without leaving it. Remembered expansion is kept on collapse, but reset by +"collapse all". + +## D9 — Directory headers are path groups, not nesting + +**Decision.** Submodules are grouped by the directory of their path relative +to the parent repo. Headers (`src/`, `modules/`) are muted, non-selectable, +and skipped by navigation. Nesting is expressed by indentation under the +parent submodule. + +## D10 — ANSI 16 colors + +**Context.** Fixed 256-colors (e.g. violet 63) ignored the user's terminal +theme and were unreadable on light backgrounds. +**Decision.** Use ANSI 0–15 for all meaning (red, yellow, blue, cyan, grey, +default foreground). Only background shades use `AdaptiveColor`. Detect the +background before Bubble Tea starts. + +## D11 — Provider boundary and async loading + +**Decision.** The UI accesses data only through `submodule.Provider`: +`Tree()` for the fast structure, `Status(path)` per submodule, loaded +concurrently with a spinner per row. The prototype uses a mock provider with +artificial latency. +**Consequences.** A git backend can be added without UI changes; the UI +stays responsive with many submodules. + +## D12 — No network on startup + +**Decision.** Work from local refs and show their age; fetching is an +explicit action (`f`). + +## D13 — Mouse support via bubblezone + +**Decision.** Keep mouse support (click to select/toggle, wheel to move) +using bubblezone markers per row. diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..dac0e10 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,104 @@ +# Requirements + +## Problem + +In repositories with many submodules, `git submodule status` shows pinned +commit hashes. It doesn't answer the questions users actually have: + +- Which branch is this submodule on, and is the pin current? +- Which submodules need my attention, and which are fine? +- Is anything dangerous, e.g. a pin that other people can't fetch? + +## Goals + +- Show **only submodules** (not files), recursively nested, as a tree. +- Describe pins as **ref + distance** instead of a commit hash. +- Let **problems stand out** and keep healthy submodules quiet. +- Work comfortably with **dozens to hundreds** of submodules. + +## Functional requirements + +### Tree + +- **F1** Show all submodules of the superproject, including nested submodules + of submodules, at any depth. +- **F2** Group submodules by the directory of their path relative to the + parent repository. A directory with submodules is shown as a non-selectable + header line (e.g. `src/`, `modules/`). Submodules at the repository root + have no header. +- **F3** Nested submodules are shown under their parent submodule. A + directory header inside a submodule (e.g. `modules/`) means the nested + submodule lives in that subdirectory of the parent. +- **F4** On startup only the first level is shown; every submodule with + nested submodules is **collapsed**. +- **F5** A collapsed submodule shows the number of its **direct** children, + e.g. `[2]`. +- **F6** A collapsed submodule shows the number of issues **hidden** in all + its descendants (`·2`), colored by the most severe hidden issue. +- **F7** Expansion state of descendants is remembered when a parent is + collapsed and restored when it is expanded again (except after + "collapse all", which resets it). + +### State per submodule + +- **F10** Each submodule shows at most **one** state word: the most severe + issue, plus `+n` if there are more. Submodules without issues show no word. + See [state-model.md](state-model.md) for the full rules. +- **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a + short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`). +- **F12** A pin that is not reachable from any remote ref ("unpushed") is + the most prominent state. +- **F13** States load asynchronously per submodule; a submodule shows a + spinner until its state is known. Loading errors are shown as `error`. + +### Detail pane + +- **F20** Shows the selected submodule: full path, name, all issues in + words, staged-pin and detached-HEAD notes. +- **F21** Shows the resolved ref, how it was resolved, and three commits: + **Pinned**, **Checkout** and **Tip** (SHA + subject). A checkout that + differs from the pin is highlighted. +- **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. + +### Header and footer + +- **F30** The header shows the superproject path, its branch and + ahead/behind, the number of submodules, a count per issue kind (most + severe first), staged pins, how many are still loading, and the time since + the last fetch. +- **F31** The footer shows key hints; `?` toggles the full help. It + temporarily shows short messages (e.g. "not implemented"). + +### Navigation + +- **F40** Keyboard navigation as specified in + [ux-guide.md](ux-guide.md#navigation). +- **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll + wheel moves the selection. + +### Actions (planned, not implemented) + +| Key | Action | +|---|---| +| `u` | Check out the pinned commit (`git submodule update`) | +| `b` | Bump the pin to the tip of its ref | +| `f` | Fetch remotes | +| `/` | Filter by name/path | +| `!` | Show only submodules with issues | + +## Non-functional requirements + +- **N1** Startup must not block on the network. The tool works from local + refs and shows how old they are ("fetched 2m ago"); fetching is explicit. +- **N2** Loading many submodules must not freeze the UI (async, per + submodule). +- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI + works on dark and light backgrounds. +- **N4** Meaning never depends on color alone: every state has a word. +- **N5** Works without a Nerd Font. `SUBMARINE_ASCII=1` replaces the few + remaining Unicode symbols with ASCII. +- **N6** Works in narrow terminals (detail pane hides below 90 columns). +- **N7** The UI is independent of the data source: replacing the mock + provider with a git provider requires no UI changes. diff --git a/docs/state-model.md b/docs/state-model.md new file mode 100644 index 0000000..778f812 --- /dev/null +++ b/docs/state-model.md @@ -0,0 +1,108 @@ +# State model + +Code: `internal/submodule/model.go`, `internal/submodule/status.go`. + +## Three commits per submodule + +Every submodule involves three commits. All state is derived from comparing +them. + +| Commit | Source | +|---|---| +| **Pinned** | The gitlink recorded in the superproject (HEAD or index) | +| **Checked out** | The submodule's `HEAD` | +| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` | + +This gives two independent questions: + +- **Is the checkout on the pin?** → `off pin` if not. +- **Where is the pin relative to its ref?** → `↑n` / `↓n` / `↑n↓m`. + +## Raw status dimensions + +`submodule.Status` stores the raw, independent dimensions. A submodule can be +in several at once. + +| Field | Values | +|---|---| +| `Presence` | `PresenceOK`, `NotInitialized`, `DirMissing` | +| `Checkout` | `CheckoutMatch`, `CheckoutDiffers`, `CheckoutDetached` (on the pin, detached HEAD; informational) | +| `Worktree` | `WorktreeClean`, `WorktreeModified`, `WorktreeUntracked`, `WorktreeConflict` | +| `PinStaged` | Pin changed in the superproject index but not committed | +| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` | +| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) | +| `Between` | Commits that make up the pin ↔ tip difference | + +`Status == nil` means "still loading". `Submodule.Err` is set if loading +failed. + +## Issues: summarizing the dimensions + +The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes +them into **issue kinds**, each with a **level**. This is the single source of +truth for what counts as a problem. + +| Issue (word) | Combines | Level | +|---|---|---| +| `unpushed` | Pin not reachable from any remote ref | Danger | +| `conflict` | Merge conflict in the worktree | Danger | +| `missing` | Not initialized; directory missing | Warn | +| `off pin` | Checkout differs from pin | Warn | +| `changed` | Uncommitted changes; untracked files | Warn | + +Rules: + +- If the submodule is **missing**, that is its only issue (nothing else can + be determined). +- Issues are returned **most severe first**: unpushed, conflict, off pin, + changed. +- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed, + staged pin, detached HEAD on the pin. They are shown as information only. +- A loading error is shown as `error` (Danger) in the UI; it is not an issue + kind. + +### Levels + +| Level | Meaning | Presentation | +|---|---|---| +| `LevelQuiet` | Nothing to do | Whole row muted, no word | +| `LevelWarn` | Look at it | Yellow word | +| `LevelDanger` | Act now | Red bold word | + +### Hidden issues + +`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants +and returns the most severe level. A collapsed submodule shows this as `·k` +in that level's color, so a danger can never hide inside a collapsed subtree. + +## Resolving a pin to a ref + +The pin is displayed as a ref, not a hash. A commit can be on several +branches, on none, or deep in the history, so the git provider resolves it by +this order and records which rule applied in `PinInfo.Source`: + +| # | Rule | `RefSource` | +|---|---|---| +| 1 | `branch = …` in `.gitmodules` | `RefFromGitmodules` | +| 2 | Branch currently checked out in the submodule | `RefFromCurrentBranch` | +| 3 | Remote default branch, if it contains the pin | `RefFromDefaultBranch` | +| 4 | Any remote branch containing the pin, preferring the closest tip (`git branch -r --contains`) | `RefFromContains` | +| 5 | A tag (`git describe`) | `RefFromTag` | +| 6 | Short SHA | `RefFromSHA` | + +Distances: `git rev-list --left-right --count ...` gives +`Ahead` (left) and `Behind` (right). + +`Pushed` is true if the pin is reachable from any remote ref. A pin that is +ahead of its ref can still be pushed (e.g. on another branch); only +`Pushed == false` is dangerous. + +## Distance notation + +| Display | Meaning | +|---|---| +| `↓n` | The ref has n commits the pin doesn't have; the pin is behind | +| `↑n` | The pin has n commits the ref doesn't have | +| `↑n↓m` | Diverged | + +The arrows follow the convention of `git status`, lazygit and shell prompts. diff --git a/docs/ux-guide.md b/docs/ux-guide.md new file mode 100644 index 0000000..92de76a --- /dev/null +++ b/docs/ux-guide.md @@ -0,0 +1,145 @@ +# UX guide + +## Principles + +1. **Attention follows severity.** Healthy submodules are quiet, problems are + loud, dangers are loudest. Users should be able to scan a long list and + only see what needs action. +2. **One word per row.** At most one state word in the tree (`+n` for more); + the detail pane lists everything. +3. **Words over symbols.** States are short words that need no legend. + Symbols are only used where they are conventional (`↑↓`, `▸▾`). +4. **Meaning never depends on color alone** (every state has a word). +5. **Follow the user's terminal theme.** Use ANSI 16 colors for meaning. + +## Layout + +``` + ~/coding/my_project main 13 submodules · 1 unpushed · 1 missing · 2 off pin · … fetched 2m ago + docs origin/main │ src/ + misc/tools/ │ my_submodule_3 + my_submodule_1 origin/main │ + my_submodule_2 missing a1b2c3d │ unpushed pin not pushed to any remote + src/ │ + my_submodule_1 off pin origin/main │ Ref origin/main ↑1 + ▸ my_submodule_2 [2] changed ·2 origin/main │ via checked out branch + my_submodule_3 unpushed origin/main ↑1 │ Pinned b7c8d9e Hotfix null pointer + libs/ │ Checkout b7c8d9e Hotfix null pointer + logging origin/main ↓3 │ Tip 6f5e4d3 Release 2.1.0 + ↑/↓ move • ←/→ collapse/expand • space toggle • u checkout pin • … • ? help • q quit +``` + +| Area | Height / width | +|---|---| +| Header | 1 line | +| Tree | Remaining width | +| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns | +| Footer | 1 line (more with full help) | + +### Tree row + +``` +indent ▸ name [n] status ref delta +``` + +| Column | Width | Content | +|---|---|---| +| indent | 2 per depth | | +| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves | +| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) | +| status | 14 | issue word, `+n`, `·k` hidden issues; spinner while loading | +| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved | +| delta | 6 | `↑n`, `↓n`, `↑n↓m` | + +- The name column is only as wide as needed, so states stay close to names. +- Directory headers (`src/`, `modules/`) are muted and not selectable. +- Rows without issues: name and ref muted (except the selected row). + +### Detail pane + +1. Directory (muted) and name (bold) +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 ` +4. `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` + +### Header + +`path branch ↑↓ · n submodules · … · n pin staged · n loading · fetched ` + +Issue counts are ordered by severity (unpushed, conflict, missing, off pin, +changed), each in its level color. Zero counts are omitted. + +## Colors + +All colors are defined in `internal/tui/theme/theme.go`. Never hard-code +colors in components. + +| Role | Color | Used for | +|---|---|---| +| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin | +| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA | +| Info | ANSI `4` blue | `staged` | +| Primary | ANSI `6` cyan | branch names, commit hashes, spinner | +| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels | +| Text | terminal default | normal text | +| Border | ANSI `8` grey | pane and help separators | +| Bar | adaptive 254 (light) / 235 (dark) | header background | +| Selected | adaptive 253 (light) / 237 (dark) | selected row background | + +- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`. + The background is detected once in `main` before Bubble Tea starts. +- Styles that render on a background (header, selected row) take a `base` + style (`LevelStyle(level, base)`, `Delta(pin, base)`), so inner segments + keep the background. + +## Wording + +| Word | Detail pane text | +|---|---| +| `unpushed` | pin not pushed to any remote | +| `conflict` | merge conflict | +| `missing` | not initialized / directory missing | +| `off pin` | checkout differs from pin | +| `changed` | uncommitted changes / untracked files | +| `staged` | pin change staged, not committed | +| `in sync` | nothing to do | + +## Navigation + +| Key | Behavior | +|---|---| +| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped | +| `space` | Toggle expand/collapse of the selected submodule | +| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing | +| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing | +| `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children | +| `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor | +| `home` / `g`, `end` / `G` | First / last submodule | +| `pgup`, `pgdn` | Move by one screen | +| `?` | Toggle full help | +| `q`, `ctrl+c` | Quit | +| `u`, `b`, `f`, `/`, `!` | Planned actions (see requirements) | + +"First child" always means the first child **as displayed** (children are +grouped by directory). + +Collapsing an ancestor of the selection moves the selection to that ancestor. +The view scrolls so the selection is always visible; when the first submodule +is selected, headers above it are shown too. + +### Mouse + +- Click a row: select it. +- Click `▸` / `▾`: select and toggle. +- Scroll wheel: move the selection. + +## Accessibility and terminals + +- `SUBMARINE_ASCII=1`: ASCII symbols (`^ v > v . *`) instead of Unicode. +- Some terminals or multiplexers capture `ctrl+←/→`; letter aliases can be + added if needed. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5c18606 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module submarine + +go 1.27.3 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/lrstanley/bubblezone v1.0.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..45d4b7d --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go new file mode 100644 index 0000000..73e9a7a --- /dev/null +++ b/internal/submodule/mock/mock.go @@ -0,0 +1,193 @@ +// Package mock provides a submodule.Provider backed by fixture data. It +// covers every state the UI can display and simulates loading latency. +package mock + +import ( + "context" + "fmt" + "math/rand/v2" + "path" + "time" + + "submarine/internal/submodule" +) + +// Provider serves a fixed fixture tree. +type Provider struct { + MinDelay, MaxDelay time.Duration + statuses map[string]*submodule.Status +} + +func New() *Provider { + return &Provider{ + MinDelay: 100 * time.Millisecond, + MaxDelay: 800 * time.Millisecond, + } +} + +type node struct { + relPath string + status *submodule.Status + children []node +} + +func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) { + p.statuses = map[string]*submodule.Status{} + repo := &submodule.Repo{ + Path: "~/coding/my_project", + Branch: "main", + FetchedAt: time.Now().Add(-2 * time.Minute), + } + repo.Submodules = p.build(fixture(), nil) + return repo, nil +} + +func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule.Submodule { + var out []*submodule.Submodule + for _, n := range nodes { + full := n.relPath + if parent != nil { + full = path.Join(parent.Path, n.relPath) + } + m := &submodule.Submodule{ + Name: path.Base(n.relPath), + Path: full, + RelPath: n.relPath, + URL: fmt.Sprintf("git@github.com:acme/%s.git", path.Base(n.relPath)), + Parent: parent, + } + m.Children = p.build(n.children, m) + p.statuses[full] = n.status + out = append(out, m) + } + return out +} + +func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) { + delay := p.MinDelay + if span := p.MaxDelay - p.MinDelay; span > 0 { + delay += rand.N(span) + } + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + s, ok := p.statuses[path] + if !ok { + return nil, fmt.Errorf("unknown submodule %q", path) + } + return s, nil +} + +// --- fixture ----------------------------------------------------------------- + +var now = time.Now() + +func commit(sha, subject, author string, ago time.Duration) *submodule.Commit { + return &submodule.Commit{SHA: sha, Subject: subject, Author: author, When: now.Add(-ago)} +} + +func inSync(ref string, c *submodule.Commit) *submodule.Status { + return &submodule.Status{ + Pin: submodule.PinInfo{Ref: ref, Pushed: true}, + Pinned: c, CheckedOut: c, Tip: c, + } +} + +func fixture() []node { + c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour) + c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour) + + srcSub1Pin := commit("5d6e7f8", "Add retry to client", "chris", 3*24*time.Hour) + srcSub1Head := commit("0c1d2e3", "WIP: experiment with backoff", "you", 2*time.Hour) + + srcSub2 := inSync("origin/main", commit("77aa88b", "Merge PR #42", "dana", 5*time.Hour)) + srcSub2.Worktree = submodule.WorktreeModified + + subSubPin := commit("4e5f6a7", "Add feature flag", "eve", 30*time.Hour) + subSubTip := commit("9f8e7d6", "Fix login timeout", "anna", 2*time.Hour) + subSub := &submodule.Status{ + Checkout: submodule.CheckoutDiffers, + Pin: submodule.PinInfo{Ref: "origin/feat/02", Behind: 1, Pushed: true}, + Pinned: subSubPin, + CheckedOut: subSubTip, + Tip: subSubTip, + Between: []submodule.Commit{*subSubTip}, + } + + schema := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour)) + schema.Worktree = submodule.WorktreeUntracked + + unpushed := commit("b7c8d9e", "Hotfix null pointer in parser", "you", 20*time.Minute) + sub3Tip := commit("6f5e4d3", "Release 2.1.0", "chris", 4*24*time.Hour) + + logPin := commit("2b3c4d5", "Add structured fields", "dana", 9*24*time.Hour) + logTip := commit("8e9f0a1", "Support slog handler", "eve", 1*24*time.Hour) + + httpPin := commit("c4d5e6f", "Experimental HTTP/3 support", "frank", 8*24*time.Hour) + httpTip := commit("e1f2a3b", "Drop Go 1.21 support", "anna", 12*time.Hour) + + cryptoNew := commit("d2e3f4a", "Release v1.4.2", "ben", 3*24*time.Hour) + crypto := inSync("v1.4.2", cryptoNew) + crypto.Pin.Source = submodule.RefFromTag + crypto.PinStaged = true + + zlib := inSync("v1.3.1", commit("51f2c3e", "zlib 1.3.1", "madler", 300*24*time.Hour)) + zlib.Pin.Source = submodule.RefFromTag + zlib.Checkout = submodule.CheckoutDetached + + return []node{ + {relPath: "docs", status: inSync("origin/main", c2)}, + {relPath: "misc/tools/my_submodule_1", status: inSync("origin/main", c1)}, + {relPath: "misc/tools/my_submodule_2", status: &submodule.Status{ + Presence: submodule.NotInitialized, + Pin: submodule.PinInfo{Pushed: true, Source: submodule.RefFromSHA}, + Pinned: &submodule.Commit{SHA: "a1b2c3d"}, + }}, + {relPath: "src/my_submodule_1", status: &submodule.Status{ + Checkout: submodule.CheckoutDiffers, + Pin: submodule.PinInfo{Ref: "origin/main", Pushed: true}, + Pinned: srcSub1Pin, + CheckedOut: srcSub1Head, + Tip: srcSub1Pin, + }}, + {relPath: "src/my_submodule_2", status: srcSub2, children: []node{ + {relPath: "modules/my_sub_submodule", status: subSub}, + {relPath: "modules/protocol", status: inSync("origin/main", commit("aa11bb2", "Regenerate stubs", "frank", 50*time.Hour)), children: []node{ + {relPath: "vendor/schema", status: schema}, + }}, + }}, + {relPath: "src/my_submodule_3", status: &submodule.Status{ + Pin: submodule.PinInfo{Ref: "origin/main", Ahead: 1, Pushed: false, Source: submodule.RefFromCurrentBranch}, + Pinned: unpushed, + CheckedOut: unpushed, + Tip: sub3Tip, + Between: []submodule.Commit{*unpushed}, + }}, + {relPath: "libs/logging", status: &submodule.Status{ + Pin: submodule.PinInfo{Ref: "origin/main", Behind: 3, Pushed: true}, + Pinned: logPin, + CheckedOut: logPin, + Tip: logTip, + Between: []submodule.Commit{ + *logTip, + *commit("7d8e9f0", "Fix race in buffered writer", "dana", 3*24*time.Hour), + *commit("6c7d8e9", "Update dependencies", "renovate", 5*24*time.Hour), + }, + }}, + {relPath: "libs/http", status: &submodule.Status{ + Pin: submodule.PinInfo{Ref: "origin/develop", Ahead: 1, Behind: 2, Pushed: true, Source: submodule.RefFromContains}, + Pinned: httpPin, + CheckedOut: httpPin, + Tip: httpTip, + Between: []submodule.Commit{ + *httpTip, + *commit("d0e1f2a", "Refactor transport", "frank", 2*24*time.Hour), + *httpPin, + }, + }}, + {relPath: "libs/crypto", status: crypto}, + {relPath: "third_party/zlib", status: zlib}, + } +} diff --git a/internal/submodule/model.go b/internal/submodule/model.go new file mode 100644 index 0000000..d2fa28f --- /dev/null +++ b/internal/submodule/model.go @@ -0,0 +1,114 @@ +// Package submodule holds the domain model for a superproject and its +// (recursively nested) git submodules. It has no UI dependencies. +package submodule + +import "time" + +// Repo is the superproject. +type Repo struct { + Path string + Branch string + Ahead int + Behind int + FetchedAt time.Time + Submodules []*Submodule +} + +// Submodule is one node in the submodule tree. +type Submodule struct { + Name string + Path string // relative to the superproject root; unique ID + RelPath string // relative to the parent repository, e.g. "modules/foo" + URL string + Parent *Submodule + Children []*Submodule + Status *Status // nil while loading + Err error // set if loading the status failed +} + +// Presence describes whether the submodule exists on disk. +type Presence int + +const ( + PresenceOK Presence = iota + NotInitialized + DirMissing +) + +// Checkout compares the submodule's HEAD with the pinned commit. +type Checkout int + +const ( + CheckoutMatch Checkout = iota + CheckoutDiffers + CheckoutDetached // on the pinned commit, but detached (informational) +) + +// Worktree describes uncommitted state inside the submodule. +type Worktree int + +const ( + WorktreeClean Worktree = iota + WorktreeModified + WorktreeUntracked + WorktreeConflict +) + +// RefSource tells which rule resolved the pin to a ref. +type RefSource int + +const ( + RefFromGitmodules RefSource = iota + RefFromCurrentBranch + RefFromDefaultBranch + RefFromContains + RefFromTag + RefFromSHA +) + +func (r RefSource) String() string { + switch r { + case RefFromGitmodules: + return ".gitmodules branch" + case RefFromCurrentBranch: + return "checked out branch" + case RefFromDefaultBranch: + return "remote default branch" + case RefFromContains: + return "branch containing pin" + case RefFromTag: + return "tag" + default: + return "commit sha" + } +} + +// PinInfo relates the pinned commit to a ref. +type PinInfo struct { + Ref string // e.g. "origin/main"; empty if unresolved + Source RefSource + Ahead int // commits in pin not in ref + Behind int // commits in ref not in pin + Pushed bool // pin is reachable from some remote ref +} + +// Commit is a short commit description. +type Commit struct { + SHA string + Subject string + Author string + When time.Time +} + +// Status is the full state of one submodule. +type Status struct { + Presence Presence + Checkout Checkout + Worktree Worktree + PinStaged bool // pin changed in the superproject index, not committed + Pin PinInfo + Pinned *Commit + CheckedOut *Commit + Tip *Commit + Between []Commit // commits that make up the pin <-> tip difference +} diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go new file mode 100644 index 0000000..0496149 --- /dev/null +++ b/internal/submodule/provider.go @@ -0,0 +1,10 @@ +package submodule + +import "context" + +// Provider supplies repository data. Tree is expected to be fast (structure +// only); Status may be slow and is called once per submodule, concurrently. +type Provider interface { + Tree(ctx context.Context) (*Repo, error) + Status(ctx context.Context, path string) (*Status, error) +} diff --git a/internal/submodule/status.go b/internal/submodule/status.go new file mode 100644 index 0000000..810edc3 --- /dev/null +++ b/internal/submodule/status.go @@ -0,0 +1,81 @@ +package submodule + +// Level is how much attention an issue deserves. +type Level int + +const ( + LevelQuiet Level = iota // nothing to do + LevelWarn // look at it + LevelDanger // act now +) + +// IssueKind is the summarized category of a problem. +type IssueKind int + +const ( + IssueMissing IssueKind = iota // not initialized or directory missing + IssueUnpushed // pin not reachable from any remote ref + IssueConflict // merge conflict in the worktree + IssueOffPin // checkout differs from pin + IssueChanged // uncommitted changes or untracked files +) + +// Issue is one problem of a submodule. Detail distinguishes the states that +// are summarized into one kind. +type Issue struct { + Kind IssueKind + Level Level + Detail string +} + +// Issues summarizes the status, most severe first. Being behind or ahead of +// the remote, a staged pin and a detached HEAD are not issues. +func (s *Status) Issues() []Issue { + if s == nil { + return nil + } + switch s.Presence { + case NotInitialized: + return []Issue{{IssueMissing, LevelWarn, "not initialized"}} + case DirMissing: + return []Issue{{IssueMissing, LevelWarn, "directory missing"}} + } + + var out []Issue + if !s.Pin.Pushed { + out = append(out, Issue{IssueUnpushed, LevelDanger, "pin not pushed to any remote"}) + } + if s.Worktree == WorktreeConflict { + out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"}) + } + if s.Checkout == CheckoutDiffers { + out = append(out, Issue{IssueOffPin, LevelWarn, "checkout differs from pin"}) + } + switch s.Worktree { + case WorktreeModified: + out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"}) + case WorktreeUntracked: + out = append(out, Issue{IssueChanged, LevelWarn, "untracked files"}) + } + return out +} + +// HiddenIssues counts the issues of all loaded descendants and returns the +// most severe level among them. +func (m *Submodule) HiddenIssues() (count int, worst Level) { + Walk(m.Children, func(c *Submodule) { + for _, is := range c.Status.Issues() { + count++ + worst = max(worst, is.Level) + } + }) + return count, worst +} + +// Walk calls fn for every submodule in depth-first order. +func Walk(mods []*Submodule, fn func(*Submodule)) { + for _, m := range mods { + fn(m) + Walk(m.Children, fn) + } +} diff --git a/internal/submodule/status_test.go b/internal/submodule/status_test.go new file mode 100644 index 0000000..3fdcfcf --- /dev/null +++ b/internal/submodule/status_test.go @@ -0,0 +1,52 @@ +package submodule + +import ( + "slices" + "testing" +) + +func kinds(is []Issue) []IssueKind { + var out []IssueKind + for _, i := range is { + out = append(out, i.Kind) + } + return out +} + +func TestIssues(t *testing.T) { + pushed := PinInfo{Pushed: true} + tests := []struct { + name string + s *Status + want []IssueKind + }{ + {"loading", nil, nil}, + {"clean", &Status{Pin: pushed}, nil}, + {"behind, staged and detached are quiet", &Status{Pin: PinInfo{Pushed: true, Behind: 3}, PinStaged: true, Checkout: CheckoutDetached}, nil}, + {"not initialized", &Status{Presence: NotInitialized}, []IssueKind{IssueMissing}}, + {"dir missing", &Status{Presence: DirMissing}, []IssueKind{IssueMissing}}, + {"untracked is changed", &Status{Worktree: WorktreeUntracked, Pin: pushed}, []IssueKind{IssueChanged}}, + {"danger first", &Status{Checkout: CheckoutDiffers, Worktree: WorktreeModified}, []IssueKind{IssueUnpushed, IssueOffPin, IssueChanged}}, + {"conflict", &Status{Worktree: WorktreeConflict, Pin: pushed}, []IssueKind{IssueConflict}}, + } + for _, tt := range tests { + if got := kinds(tt.s.Issues()); !slices.Equal(got, tt.want) { + t.Errorf("%s: Issues() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestHiddenIssues(t *testing.T) { + ok := &Status{Pin: PinInfo{Pushed: true}} + dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}} + unpushed := &Status{} + grandchild := &Submodule{Status: unpushed} + child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}} + loading := &Submodule{} + root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}} + + count, worst := root.HiddenIssues() + if count != 2 || worst != LevelDanger { + t.Errorf("HiddenIssues() = %d, %v; want 2, LevelDanger", count, worst) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..473631d --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,211 @@ +// Package tui is the Bubble Tea application. It only talks to repository +// data through submodule.Provider. +package tui + +import ( + "context" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" + + "submarine/internal/submodule" + "submarine/internal/tui/detail" + "submarine/internal/tui/footer" + "submarine/internal/tui/header" + "submarine/internal/tui/theme" + "submarine/internal/tui/tree" +) + +const ( + headerHeight = 1 + minDetailWidth = 36 + detailHideBelow = 90 // terminal width below which the detail pane is hidden + detailWidthRatio = 0.4 +) + +type Model struct { + ctx context.Context + provider submodule.Provider + theme theme.Theme + keys keyMap + + repo *submodule.Repo + byPath map[string]*submodule.Submodule + loading int + err error + + tree tree.Model + detail detail.Model + footer footer.Model + spinner spinner.Model + + width, height int + showDetail bool +} + +func New(ctx context.Context, p submodule.Provider) Model { + t := theme.Default() + k := defaultKeys() + sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) + return Model{ + ctx: ctx, + provider: p, + theme: t, + keys: k, + tree: tree.New(t), + detail: detail.New(t), + footer: footer.New(t, k), + spinner: sp, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(m.loadTree(), m.spinner.Tick) +} + +func (m Model) loadTree() tea.Cmd { + return func() tea.Msg { + repo, err := m.provider.Tree(m.ctx) + return treeLoadedMsg{repo, err} + } +} + +func (m Model) loadStatus(path string) tea.Cmd { + return func() tea.Msg { + st, err := m.provider.Status(m.ctx, path) + return statusLoadedMsg{path, st, err} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.layout() + return m, nil + + case treeLoadedMsg: + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.repo = msg.repo + m.byPath = map[string]*submodule.Submodule{} + var cmds []tea.Cmd + submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) { + m.byPath[s.Path] = s + cmds = append(cmds, m.loadStatus(s.Path)) + }) + m.loading = len(cmds) + m.tree.SetRoots(m.repo.Submodules) + return m, tea.Batch(cmds...) + + case statusLoadedMsg: + if s, ok := m.byPath[msg.path]; ok { + s.Status, s.Err = msg.status, msg.err + m.loading-- + } + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + m.tree.SetSpinner(m.spinner.View()) + m.detail.SetSpinner(m.spinner.View()) + if m.repo != nil && m.loading == 0 { + return m, nil // stop ticking once everything is loaded + } + return m, cmd + + case tea.MouseMsg: + m.tree.Update(msg) + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + + m.footer.Update(msg) + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + k := m.keys + switch { + case key.Matches(msg, k.Quit): + return m, tea.Quit + case key.Matches(msg, k.Help): + m.footer.ToggleHelp() + m.layout() + case key.Matches(msg, k.Up): + m.tree.Up() + case key.Matches(msg, k.Down): + m.tree.Down() + case key.Matches(msg, k.Left): + m.tree.Left() + case key.Matches(msg, k.Right): + m.tree.Right() + case key.Matches(msg, k.Toggle): + m.tree.Toggle() + case key.Matches(msg, k.CollapseAll): + m.tree.CollapseAll() + case key.Matches(msg, k.ExpandAll): + m.tree.ExpandAll() + case key.Matches(msg, k.Home): + m.tree.Home() + case key.Matches(msg, k.End): + m.tree.End() + case key.Matches(msg, k.PageUp): + m.tree.PageUp() + case key.Matches(msg, k.PageDown): + m.tree.PageDown() + case key.Matches(msg, k.Update, k.Bump, k.Fetch, k.Filter, k.Issues): + cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype") + m.layout() + return m, cmd + } + return m, nil +} + +// layout distributes the terminal size among the components. +func (m *Model) layout() { + m.footer.SetWidth(m.width) + bodyHeight := max(m.height-headerHeight-lipgloss.Height(m.footer.View()), 0) + + m.showDetail = m.width >= detailHideBelow + treeWidth := m.width + if m.showDetail { + detailWidth := max(int(float64(m.width)*detailWidthRatio), minDetailWidth) + treeWidth = m.width - detailWidth + m.detail.SetSize(detailWidth-2, bodyHeight) // border + padding + } + m.tree.SetSize(treeWidth, bodyHeight) +} + +func (m Model) View() string { + if m.width == 0 { + return "" + } + if m.err != nil { + return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error()) + } + + body := m.tree.View() + if m.showDetail { + pane := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(m.theme.Border). + PaddingLeft(1). + Render(m.detail.View(m.tree.Selected())) + body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane) + } + + return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, + header.View(m.theme, m.repo, m.width), + body, + m.footer.View(), + )) +} diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go new file mode 100644 index 0000000..2442709 --- /dev/null +++ b/internal/tui/detail/detail.go @@ -0,0 +1,156 @@ +// Package detail renders the information pane for the selected submodule. +package detail + +import ( + "fmt" + "path" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "submarine/internal/submodule" + "submarine/internal/tui/theme" +) + +const labelWidth = 10 + +type Model struct { + theme theme.Theme + width int + height int + spinner string +} + +func New(t theme.Theme) Model { return Model{theme: t} } + +func (m *Model) SetSize(w, h int) { m.width, m.height = w, h } +func (m *Model) SetSpinner(frame string) { m.spinner = frame } + +func (m Model) View(s *submodule.Submodule) string { + if m.width <= 0 || m.height <= 0 { + return "" + } + var lines []string + if s != nil { + lines = m.lines(s) + } + for i, l := range lines { + lines[i] = ansi.Truncate(l, m.width, "…") + } + if len(lines) > m.height { + lines = lines[:m.height] + } + return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(lines, "\n")) +} + +func (m Model) lines(s *submodule.Submodule) []string { + t := m.theme + muted, text := theme.Fg(t.Muted), theme.Fg(t.Text) + label := func(l string) string { return muted.Width(labelWidth).Render(l) } + + dir := path.Dir(s.Path) + if dir == "." { + dir = "" + } else { + dir += "/" + } + out := []string{ + muted.Render(dir), + text.Bold(true).Render(s.Name), + "", + } + + switch { + case s.Err != nil: + return append(out, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render("error")+" "+muted.Render(s.Err.Error())) + case s.Status == nil: + return append(out, theme.Fg(t.Primary).Render(m.spinner)+muted.Render(" loading…")) + } + st := s.Status + + issues := st.Issues() + for _, is := range issues { + out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail)) + } + if len(issues) == 0 { + out = append(out, label("in sync")+muted.Render("nothing to do")) + } + if st.PinStaged { + out = append(out, theme.Fg(t.Info).Width(labelWidth).Render("staged")+text.Render("pin change staged, not committed")) + } + if st.Checkout == submodule.CheckoutDetached { + out = append(out, label("")+muted.Render("detached HEAD on pin")) + } + out = append(out, "") + + // ref + if st.Pin.Ref != "" { + delta := t.Delta(st.Pin, lipgloss.NewStyle()) + if delta != "" { + delta = " " + delta + } + out = append(out, + label("Ref")+text.Render(st.Pin.Ref)+delta, + label("")+muted.Render("via "+st.Pin.Source.String()), + ) + } + + // commits + commitLine := func(name string, c *submodule.Commit, highlight bool) string { + if c == nil { + return label(name) + muted.Render("—") + } + shaStyle := theme.Fg(t.Primary) + if highlight { + shaStyle = theme.Fg(t.Warn) + } + return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject) + } + out = append(out, + commitLine("Pinned", st.Pinned, false), + commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers), + commitLine("Tip", st.Tip, false), + ) + + // difference between pin and tip + if len(st.Between) > 0 { + out = append(out, "", text.Render(m.betweenTitle(st.Pin))) + for _, c := range st.Between { + out = append(out, + " "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject), + " "+muted.Render(fmt.Sprintf("%7s %s · %s", "", c.Author, theme.Ago(c.When))), + ) + } + } + + // children & meta + out = append(out, "") + if n := len(s.Children); n > 0 { + children := fmt.Sprintf("%d direct", n) + if k, worst := s.HiddenIssues(); k > 0 { + children += t.LevelStyle(worst, lipgloss.NewStyle()).Render(fmt.Sprintf(" · %d issue(s) below", k)) + } + out = append(out, label("Children")+children) + } + out = append(out, label("URL")+muted.Render(s.URL)) + return out +} + +func (m Model) betweenTitle(p submodule.PinInfo) string { + plural := func(n int) string { + if n == 1 { + return "commit" + } + return "commits" + } + switch { + case p.Ahead > 0 && p.Behind > 0: + return fmt.Sprintf("Pin and %s have diverged (%s%d %s%d):", + p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind) + case p.Ahead > 0: + return fmt.Sprintf("Pin is %d %s ahead of %s:", p.Ahead, plural(p.Ahead), p.Ref) + default: + return fmt.Sprintf("Pin is %d %s behind %s:", p.Behind, plural(p.Behind), p.Ref) + } +} diff --git a/internal/tui/footer/footer.go b/internal/tui/footer/footer.go new file mode 100644 index 0000000..6a1b181 --- /dev/null +++ b/internal/tui/footer/footer.go @@ -0,0 +1,66 @@ +// Package footer renders key hints and short-lived status messages. +package footer + +import ( + "time" + + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "submarine/internal/tui/theme" +) + +const flashDuration = 2 * time.Second + +type clearFlashMsg struct{ id int } + +type Model struct { + theme theme.Theme + help help.Model + keys help.KeyMap + width int + flash string + flashID int +} + +func New(t theme.Theme, keys help.KeyMap) Model { + h := help.New() + h.Styles.ShortKey = theme.Fg(t.Text).Bold(true) + h.Styles.ShortDesc = theme.Fg(t.Muted) + h.Styles.ShortSeparator = theme.Fg(t.Border) + h.Styles.FullKey = h.Styles.ShortKey + h.Styles.FullDesc = h.Styles.ShortDesc + h.Styles.FullSeparator = h.Styles.ShortSeparator + return Model{theme: t, help: h, keys: keys} +} + +func (m *Model) SetWidth(w int) { + m.width = w + m.help.Width = w - 2 +} + +// ToggleHelp switches between short and full key help. +func (m *Model) ToggleHelp() { m.help.ShowAll = !m.help.ShowAll } + +// Flash shows msg for a short time instead of the key hints. +func (m *Model) Flash(msg string) tea.Cmd { + m.flashID++ + m.flash = msg + id := m.flashID + return tea.Tick(flashDuration, func(time.Time) tea.Msg { return clearFlashMsg{id} }) +} + +func (m *Model) Update(msg tea.Msg) { + if c, ok := msg.(clearFlashMsg); ok && c.id == m.flashID { + m.flash = "" + } +} + +func (m Model) View() string { + style := lipgloss.NewStyle().Width(m.width).Padding(0, 1) + if m.flash != "" { + return style.Foreground(m.theme.Warn).Render(m.flash) + } + return style.Render(m.help.View(m.keys)) +} diff --git a/internal/tui/header/header.go b/internal/tui/header/header.go new file mode 100644 index 0000000..ee112b2 --- /dev/null +++ b/internal/tui/header/header.go @@ -0,0 +1,71 @@ +// Package header renders the superproject summary line. +package header + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + + "submarine/internal/submodule" + "submarine/internal/tui/theme" +) + +// View renders repo info, state counts and fetch age on one line. +func View(t theme.Theme, repo *submodule.Repo, width int) string { + bar := lipgloss.NewStyle().Background(t.Bar) + seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) } + + if repo == nil { + return bar.Width(width).Render(seg(t.Muted, " loading…")) + } + + left := seg(t.Text, " "+repo.Path) + seg(t.Muted, " ") + seg(t.Primary, repo.Branch) + if d := t.Delta(submodule.PinInfo{Ahead: repo.Ahead, Behind: repo.Behind, Pushed: true}, bar); d != "" { + left += seg(t.Text, " ") + d + } + + // count submodules per issue kind (most severe first), plus staged pins + var total, loading, staged int + perKind := map[submodule.IssueKind]int{} + level := map[submodule.IssueKind]submodule.Level{} + submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { + total++ + if s.Status == nil { + loading++ + return + } + if s.Status.PinStaged { + staged++ + } + for _, is := range s.Status.Issues() { + perKind[is.Kind]++ + level[is.Kind] = is.Level + } + }) + + sep := seg(t.Muted, " · ") + mid := seg(t.Muted, fmt.Sprintf("%d submodules", total)) + for _, k := range []submodule.IssueKind{ + submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing, + submodule.IssueOffPin, submodule.IssueChanged, + } { + if n := perKind[k]; n > 0 { + mid += sep + t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))) + } + } + if staged > 0 { + mid += sep + seg(t.Info, fmt.Sprintf("%d pin staged", staged)) + } + if loading > 0 { + mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading)) + } + + right := seg(t.Muted, "fetched "+theme.Ago(repo.FetchedAt)+" ") + + gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right) + if gap < 4 { + return bar.Width(width).MaxWidth(width).Render(left + seg(t.Text, " ") + mid) + } + sp := func(n int) string { return bar.Render(fmt.Sprintf("%*s", n, "")) } + return left + sp(gap/2) + mid + sp(gap-gap/2) + right +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..4fed449 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,48 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Up, Down, Left, Right, Toggle key.Binding + CollapseAll, ExpandAll key.Binding + Home, End, PageUp, PageDown key.Binding + Update, Bump, Fetch, Filter, Issues key.Binding + Help, Quit key.Binding +} + +func defaultKeys() keyMap { + return keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "move")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/→", "collapse/expand")), + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "expand, first child")), + Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), + CollapseAll: key.NewBinding(key.WithKeys("ctrl+left"), key.WithHelp("ctrl+←", "collapse all")), + ExpandAll: key.NewBinding(key.WithKeys("ctrl+right"), key.WithHelp("ctrl+→", "expand all")), + Home: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "first")), + End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), + Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")), + Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")), + Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Left, k.Toggle, k.Update, k.Bump, k.Fetch, k.Help, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right, k.Toggle}, + {k.CollapseAll, k.ExpandAll}, + {k.Home, k.End, k.PageUp, k.PageDown}, + {k.Update, k.Bump, k.Fetch, k.Filter, k.Issues}, + {k.Help, k.Quit}, + } +} diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go new file mode 100644 index 0000000..0c2de2c --- /dev/null +++ b/internal/tui/msgs.go @@ -0,0 +1,14 @@ +package tui + +import "submarine/internal/submodule" + +type treeLoadedMsg struct { + repo *submodule.Repo + err error +} + +type statusLoadedMsg struct { + path string + status *submodule.Status + err error +} diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go new file mode 100644 index 0000000..a015c61 --- /dev/null +++ b/internal/tui/theme/issues.go @@ -0,0 +1,71 @@ +package theme + +import ( + "fmt" + "time" + + "github.com/charmbracelet/lipgloss" + + "submarine/internal/submodule" +) + +// IssueWord is the short label for an issue kind shown in the tree. +func IssueWord(k submodule.IssueKind) string { + switch k { + case submodule.IssueMissing: + return "missing" + case submodule.IssueUnpushed: + return "unpushed" + case submodule.IssueConflict: + return "conflict" + case submodule.IssueOffPin: + return "off pin" + default: + return "changed" + } +} + +// LevelStyle returns base with the text color (and weight) for a level. +func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style { + switch l { + case submodule.LevelDanger: + return base.Foreground(t.Danger).Bold(true) + case submodule.LevelWarn: + return base.Foreground(t.Warn) + default: + return base.Foreground(t.Muted) + } +} + +// Delta renders ahead/behind counts like "↑1↓2", muted unless the pin is +// unpushed. Empty if both are zero. base is applied to every segment, e.g. +// to keep a row background. +func (t Theme) Delta(p submodule.PinInfo, base lipgloss.Style) string { + var s string + if p.Ahead > 0 { + c := t.Muted + if !p.Pushed { + c = t.Danger + } + s += base.Foreground(c).Render(fmt.Sprintf("%s%d", t.Glyphs.Ahead, p.Ahead)) + } + if p.Behind > 0 { + s += base.Foreground(t.Muted).Render(fmt.Sprintf("%s%d", t.Glyphs.Behind, p.Behind)) + } + return s +} + +// Ago formats a time relative to now, e.g. "5m ago". +func Ago(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go new file mode 100644 index 0000000..2708fe6 --- /dev/null +++ b/internal/tui/theme/theme.go @@ -0,0 +1,65 @@ +// Package theme holds the colors, glyphs and shared styles for the TUI. +package theme + +import ( + "os" + + "github.com/charmbracelet/lipgloss" +) + +// Theme uses the 16 ANSI colors for everything that carries meaning, so +// the TUI follows the user's terminal color scheme. Only background shades, +// which have no ANSI equivalent, use adaptive 256-color values. +type Theme struct { + Primary lipgloss.TerminalColor // accent: branch names, commit hashes + Info lipgloss.TerminalColor + Warn lipgloss.TerminalColor + Danger lipgloss.TerminalColor + Muted lipgloss.TerminalColor + Text lipgloss.TerminalColor + Border lipgloss.TerminalColor // pane separators, help separators + Bar lipgloss.TerminalColor // background of the header bar + Selected lipgloss.TerminalColor // background of the selected row + Glyphs Glyphs +} + +func Default() Theme { + g := Unicode() + if os.Getenv("SUBMARINE_ASCII") != "" { + g = ASCII() + } + return Theme{ + Primary: lipgloss.Color("6"), // cyan + Info: lipgloss.Color("4"), // blue + Warn: lipgloss.Color("3"), // yellow + Danger: lipgloss.Color("1"), // red + Muted: lipgloss.Color("8"), // bright black / grey + Text: lipgloss.NoColor{}, // terminal default foreground + Border: lipgloss.Color("8"), + Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"}, + Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"}, + Glyphs: g, + } +} + +// Fg returns a style with the given foreground color. +func Fg(c lipgloss.TerminalColor) lipgloss.Style { + return lipgloss.NewStyle().Foreground(c) +} + +// Glyphs are the few symbols used next to the state words. +type Glyphs struct { + Staged, Ahead, Behind, Collapsed, Expanded, Problems string +} + +func Unicode() Glyphs { + return Glyphs{ + Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", + } +} + +func ASCII() Glyphs { + return Glyphs{ + Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", + } +} diff --git a/internal/tui/tree/rows.go b/internal/tui/tree/rows.go new file mode 100644 index 0000000..83f7fbf --- /dev/null +++ b/internal/tui/tree/rows.go @@ -0,0 +1,68 @@ +package tree + +import ( + "path" + + "submarine/internal/submodule" +) + +type rowKind int + +const ( + rowDir rowKind = iota // non-selectable directory header, e.g. "modules/" + rowNode // a submodule +) + +type row struct { + kind rowKind + depth int + label string // directory label for rowDir + node *submodule.Submodule +} + +// rows flattens the visible part of the tree. +func (m *Model) rows() []row { + var out []row + m.appendRows(&out, m.roots, 0) + return out +} + +// appendRows groups mods by the directory of their RelPath. Each group with a +// directory gets a header row; its submodules are indented one level deeper. +func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) { + for _, g := range groupByDir(mods) { + d := depth + if g.dir != "." { + *out = append(*out, row{kind: rowDir, depth: depth, label: g.dir + "/"}) + d++ + } + for _, s := range g.mods { + *out = append(*out, row{kind: rowNode, depth: d, node: s}) + if m.expanded[s.Path] && len(s.Children) > 0 { + m.appendRows(out, s.Children, d+1) + } + } + } +} + +type dirGroup struct { + dir string + mods []*submodule.Submodule +} + +// groupByDir keeps the order in which directories first appear. +func groupByDir(mods []*submodule.Submodule) []dirGroup { + var groups []dirGroup + idx := map[string]int{} + for _, s := range mods { + dir := path.Dir(s.RelPath) + i, ok := idx[dir] + if !ok { + i = len(groups) + idx[dir] = i + groups = append(groups, dirGroup{dir: dir}) + } + groups[i].mods = append(groups[i].mods, s) + } + return groups +} diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go new file mode 100644 index 0000000..05b6023 --- /dev/null +++ b/internal/tui/tree/tree.go @@ -0,0 +1,420 @@ +// Package tree implements the collapsible submodule tree view. +package tree + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + zone "github.com/lrstanley/bubblezone" + + "submarine/internal/submodule" + "submarine/internal/tui/theme" +) + +const ( + statusWidth = 14 // "unpushed +1 ·2" + deltaWidth = 6 + maxRefWidth = 22 +) + +type Model struct { + id string // zone prefix, unique to this instance + theme theme.Theme + roots []*submodule.Submodule + expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse + cursor *submodule.Submodule + offset int // index of the first visible row + width int + height int + spinner string +} + +func New(t theme.Theme) Model { + return Model{id: zone.NewPrefix(), theme: t, expanded: map[string]bool{}} +} + +// SetRoots replaces the tree. All nodes start collapsed. +func (m *Model) SetRoots(roots []*submodule.Submodule) { + m.roots = roots + m.expanded = map[string]bool{} + m.cursor = nil + m.offset = 0 + if len(roots) > 0 { + m.cursor = m.rows()[m.firstNodeRow()].node + } +} + +func (m *Model) SetSize(w, h int) { + m.width, m.height = w, h + m.clampOffset() +} + +// SetSpinner sets the frame drawn for submodules that are still loading. +func (m *Model) SetSpinner(frame string) { m.spinner = frame } + +// Selected returns the submodule under the cursor, or nil. +func (m Model) Selected() *submodule.Submodule { return m.cursor } + +// --- navigation -------------------------------------------------------------- + +// Up selects the previous submodule row. +func (m *Model) Up() { m.moveBy(-1) } + +// Down selects the next submodule row. +func (m *Model) Down() { m.moveBy(1) } + +// PageUp and PageDown move by one screen height. +func (m *Model) PageUp() { m.moveBy(-max(m.height-1, 1)) } +func (m *Model) PageDown() { m.moveBy(max(m.height-1, 1)) } + +// Home and End select the first and last visible submodule. +func (m *Model) Home() { m.moveBy(-len(m.rows())) } +func (m *Model) End() { m.moveBy(len(m.rows())) } + +// Toggle expands or collapses the selected submodule. +func (m *Model) Toggle() { + if m.cursor != nil && len(m.cursor.Children) > 0 { + m.setExpanded(m.cursor, !m.expanded[m.cursor.Path]) + } +} + +// Right expands the selected submodule and selects its first child. +func (m *Model) Right() { + if m.cursor == nil || len(m.cursor.Children) == 0 { + return + } + m.setExpanded(m.cursor, true) + m.Down() // children directly follow their parent +} + +// Left collapses the selected submodule if it is expanded; otherwise it +// collapses the parent and selects it. +func (m *Model) Left() { + switch { + case m.cursor == nil: + case m.expanded[m.cursor.Path] && len(m.cursor.Children) > 0: + m.setExpanded(m.cursor, false) + case m.cursor.Parent != nil: + m.setExpanded(m.cursor.Parent, false) + } +} + +// CollapseAll collapses every submodule and selects the top-level ancestor +// of the current selection. +func (m *Model) CollapseAll() { + m.expanded = map[string]bool{} + for m.cursor != nil && m.cursor.Parent != nil { + m.cursor = m.cursor.Parent + } + m.clampOffset() +} + +// ExpandAll expands every submodule and follows the first child from the +// current selection down to a submodule without children. +func (m *Model) ExpandAll() { + submodule.Walk(m.roots, func(s *submodule.Submodule) { + if len(s.Children) > 0 { + m.expanded[s.Path] = true + } + }) + for m.cursor != nil && len(m.cursor.Children) > 0 { + m.Down() // children directly follow their parent + } + m.clampOffset() +} + +// Select selects the submodule with the given path if it is visible. +func (m *Model) Select(path string) { + for _, r := range m.rows() { + if r.kind == rowNode && r.node.Path == path { + m.cursor = r.node + m.clampOffset() + return + } + } +} + +// setExpanded changes the expansion of s. If collapsing hides the cursor, +// s becomes the selection. +func (m *Model) setExpanded(s *submodule.Submodule, open bool) { + m.expanded[s.Path] = open + if !open && isAncestor(s, m.cursor) { + m.cursor = s + } + m.clampOffset() +} + +func isAncestor(a, s *submodule.Submodule) bool { + for s != nil { + s = s.Parent + if s == a { + return true + } + } + return false +} + +// moveBy moves the cursor by n submodule rows, skipping directory headers +// and stopping at the ends. +func (m *Model) moveBy(n int) { + rows := m.rows() + i := m.cursorRow(rows) + if i < 0 { + return + } + step := 1 + if n < 0 { + step, n = -1, -n + } + for j := i + step; n > 0 && j >= 0 && j < len(rows); j += step { + if rows[j].kind == rowNode { + i = j + n-- + } + } + m.cursor = rows[i].node + m.clampOffset() +} + +func (m Model) cursorRow(rows []row) int { + for i, r := range rows { + if r.kind == rowNode && r.node == m.cursor { + return i + } + } + return -1 +} + +func (m Model) firstNodeRow() int { + for i, r := range m.rows() { + if r.kind == rowNode { + return i + } + } + return 0 +} + +// clampOffset scrolls so the cursor is visible. When the cursor is the first +// submodule, headers above it are shown too. +func (m *Model) clampOffset() { + if m.height <= 0 { + return + } + rows := m.rows() + i := m.cursorRow(rows) + if i < 0 { + return + } + if i == m.firstNodeRow() { + i = 0 + } + if i < m.offset { + m.offset = i + } + if i >= m.offset+m.height { + m.offset = i - m.height + 1 + } + m.offset = max(0, min(m.offset, len(rows)-m.height)) +} + +// --- mouse ------------------------------------------------------------------- + +// Update handles mouse input. Keyboard input is mapped by the parent model. +func (m *Model) Update(msg tea.Msg) { + mm, ok := msg.(tea.MouseMsg) + if !ok { + return + } + switch mm.Button { + case tea.MouseButtonWheelUp: + m.Up() + return + case tea.MouseButtonWheelDown: + m.Down() + return + } + if mm.Action != tea.MouseActionPress || mm.Button != tea.MouseButtonLeft { + return + } + for _, r := range m.visibleRows() { + if r.kind != rowNode { + continue + } + if zone.Get(m.id + "toggle:" + r.node.Path).InBounds(mm) { + m.cursor = r.node + m.Toggle() + return + } + if zone.Get(m.id + "row:" + r.node.Path).InBounds(mm) { + m.cursor = r.node + m.clampOffset() + return + } + } +} + +func (m Model) visibleRows() []row { + rows := m.rows() + end := min(len(rows), m.offset+m.height) + if m.offset >= end { + return nil + } + return rows[m.offset:end] +} + +// --- view -------------------------------------------------------------------- + +func (m Model) View() string { + if m.width <= 0 || m.height <= 0 { + return "" + } + rows := m.visibleRows() + refW := m.refWidth() + nameColW := m.nameColumnWidth(rows, refW) + lines := make([]string, 0, m.height) + for _, r := range rows { + if r.kind == rowDir { + lines = append(lines, m.dirLine(r)) + } else { + lines = append(lines, m.nodeLine(r, nameColW, refW)) + } + } + for len(lines) < m.height { + lines = append(lines, strings.Repeat(" ", m.width)) + } + return strings.Join(lines, "\n") +} + +// nameColumnWidth is the width of indent + toggle + name for the widest +// visible row, so the status column sits close to the names. It shrinks to +// fit the available width. +func (m Model) nameColumnWidth(rows []row, refW int) int { + w := 0 + for _, r := range rows { + if r.kind == rowNode { + w = max(w, 2*r.depth+3+lipgloss.Width(r.node.Name)+len(m.childCount(r.node))) + } + } + return min(w+2, m.width-statusWidth-refW-deltaWidth-3) +} + +func (m Model) childCount(s *submodule.Submodule) string { + if len(s.Children) == 0 || m.expanded[s.Path] { + return "" + } + return fmt.Sprintf(" [%d]", len(s.Children)) +} + +func (m Model) refWidth() int { + w := 0 + submodule.Walk(m.roots, func(s *submodule.Submodule) { + w = max(w, lipgloss.Width(refLabel(s))) + }) + return min(w, maxRefWidth) +} + +func refLabel(s *submodule.Submodule) string { + if s.Status == nil { + return "" + } + if s.Status.Pin.Ref != "" { + return s.Status.Pin.Ref + } + if s.Status.Pinned != nil { + return s.Status.Pinned.SHA + } + return "" +} + +func (m Model) dirLine(r row) string { + text := strings.Repeat(" ", r.depth) + " " + r.label + return theme.Fg(m.theme.Muted).Width(m.width).Render(ansi.Truncate(text, m.width, "…")) +} + +// nodeLine renders: indent ▸ name [n] │ status ·k │ ref │ delta +func (m Model) nodeLine(r row, nameColW, refW int) string { + t, s := m.theme, r.node + base := lipgloss.NewStyle() + if s == m.cursor { + base = base.Background(t.Selected).Bold(true) + } + seg := func(c lipgloss.TerminalColor, text string) string { + return base.Foreground(c).Render(text) + } + pad := func(n int) string { return base.Render(strings.Repeat(" ", max(n, 0))) } + + collapsed := len(s.Children) > 0 && !m.expanded[s.Path] + + // toggle marker + marker := " " + if len(s.Children) > 0 { + marker = t.Glyphs.Expanded + " " + if collapsed { + marker = t.Glyphs.Collapsed + " " + } + } + indent := pad(len(" ") * r.depth) + toggle := zone.Mark(m.id+"toggle:"+s.Path, seg(t.Muted, " "+marker)) + + // Rows without issues are drawn muted so problems stand out. + var issues []submodule.Issue + if s.Status != nil { + issues = s.Status.Issues() + } + quiet := s.Err == nil && s.Status != nil && len(issues) == 0 + textColor := t.Text + if quiet && s != m.cursor { + textColor = t.Muted + } + + // name + child count + nameW := nameColW - 2*r.depth - 3 + count := m.childCount(s) + name := ansi.Truncate(s.Name, max(nameW-len(count), 1), "…") + nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count)) + + // status: the most severe issue as a word, "+n" for further issues + var status string + switch { + case s.Err != nil: + status = t.LevelStyle(submodule.LevelDanger, base).Render("error") + case s.Status == nil: + status = seg(t.Primary, m.spinner) + case len(issues) > 0: + status = t.LevelStyle(issues[0].Level, base).Render(theme.IssueWord(issues[0].Kind)) + if len(issues) > 1 { + status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1)) + } + } + if k, worst := s.HiddenIssues(); collapsed && k > 0 { + if status != "" { + status += pad(1) + } + status += t.LevelStyle(worst, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, k)) + } + status = ansi.Truncate(status, statusWidth, "") + status += pad(statusWidth - lipgloss.Width(status)) + + // ref + delta + refColor := textColor + if s.Status != nil && s.Status.Pin.Ref == "" { + refColor = t.Muted + } + ref := ansi.Truncate(refLabel(s), refW, "…") + refCell := seg(refColor, ref) + pad(refW-lipgloss.Width(ref)) + delta := "" + if s.Status != nil { + delta = t.Delta(s.Status.Pin, base) + } + deltaCell := delta + pad(deltaWidth-lipgloss.Width(delta)) + + content := nameCell + pad(1) + status + pad(1) + refCell + pad(1) + deltaCell + line := indent + toggle + zone.Mark(m.id+"row:"+s.Path, content) + if w := lipgloss.Width(line); w < m.width { + line += pad(m.width - w) + } + return ansi.Truncate(line, m.width, "") +} diff --git a/internal/tui/tree/tree_test.go b/internal/tui/tree/tree_test.go new file mode 100644 index 0000000..36cdd6c --- /dev/null +++ b/internal/tui/tree/tree_test.go @@ -0,0 +1,234 @@ +package tree + +import ( + "os" + "testing" + + zone "github.com/lrstanley/bubblezone" + + "submarine/internal/submodule" + "submarine/internal/tui/theme" +) + +func TestMain(m *testing.M) { + zone.NewGlobal() + os.Exit(m.Run()) +} + +// fixture: +// +// a +// src/ +// b +// modules/ +// c +// d +// e +// f +func fixture() []*submodule.Submodule { + mk := func(path, rel string, parent *submodule.Submodule) *submodule.Submodule { + s := &submodule.Submodule{Name: path, Path: path, RelPath: rel, Parent: parent} + if parent != nil { + parent.Children = append(parent.Children, s) + } + return s + } + a := mk("a", "a", nil) + b := mk("b", "src/b", nil) + c := mk("c", "modules/c", b) + mk("d", "d", c) + mk("e", "modules/e", b) + f := mk("f", "src/f", nil) + return []*submodule.Submodule{a, b, f} +} + +func newTree(t *testing.T) *Model { + t.Helper() + m := New(theme.Default()) + m.SetRoots(fixture()) + m.SetSize(80, 20) + return &m +} + +func assertCursor(t *testing.T, m *Model, want string) { + t.Helper() + if got := m.Selected().Path; got != want { + t.Fatalf("cursor = %q, want %q", got, want) + } +} + +func visible(m *Model) []string { + var out []string + for _, r := range m.rows() { + if r.kind == rowDir { + out = append(out, r.label) + } else { + out = append(out, r.node.Path) + } + } + return out +} + +func TestInitiallyCollapsedWithHeaders(t *testing.T) { + m := newTree(t) + got := visible(m) + want := []string{"a", "src/", "b", "f"} + if len(got) != len(want) { + t.Fatalf("rows = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("rows = %v, want %v", got, want) + } + } + assertCursor(t, m, "a") +} + +func TestDownSkipsHeaders(t *testing.T) { + m := newTree(t) + m.Down() + assertCursor(t, m, "b") + m.Down() + assertCursor(t, m, "f") + m.Down() // stays at end + assertCursor(t, m, "f") + m.Up() + m.Up() + m.Up() // stays at start + assertCursor(t, m, "a") +} + +func TestRightExpandsAndSelectsFirstChild(t *testing.T) { + m := newTree(t) + m.Down() + m.Right() + assertCursor(t, m, "c") + if !m.expanded["b"] { + t.Fatal("b should be expanded") + } + m.Right() + assertCursor(t, m, "d") + m.Right() // leaf: no-op + assertCursor(t, m, "d") +} + +func TestRightOnLeafIsNoop(t *testing.T) { + m := newTree(t) + m.Right() + assertCursor(t, m, "a") +} + +func TestLeftCollapsesParentAndSelectsIt(t *testing.T) { + m := newTree(t) + m.Down() + m.Right() // b expanded, cursor c + m.Down() // e + assertCursor(t, m, "e") + m.Left() + assertCursor(t, m, "b") + if m.expanded["b"] { + t.Fatal("b should be collapsed") + } +} + +func TestLeftOnExpandedNodeCollapsesIt(t *testing.T) { + m := newTree(t) + m.Down() + m.Toggle() // expand b, cursor stays + assertCursor(t, m, "b") + m.Left() + assertCursor(t, m, "b") + if m.expanded["b"] { + t.Fatal("b should be collapsed") + } + m.Left() // top level and collapsed: no-op + assertCursor(t, m, "b") +} + +func TestExpansionStateKept(t *testing.T) { + m := newTree(t) + m.Down() + m.Right() // c + m.Right() // d, c expanded + m.Left() // collapse c, select c + m.Right() // d again + m.Left() // c + m.Toggle() // re-expand c + m.Left() // c is expanded -> collapse c + m.Toggle() // expand c + m.Up() // b + m.Toggle() // collapse b + m.Toggle() // expand b: c should still be expanded + got := visible(m) + want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"} + if len(got) != len(want) { + t.Fatalf("rows = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("rows = %v, want %v", got, want) + } + } +} + +func TestCollapsingAncestorMovesCursor(t *testing.T) { + m := newTree(t) + m.Down() + m.Right() + m.Right() // cursor d + m.setExpanded(m.roots[1], false) + assertCursor(t, m, "b") +} + +func TestScrollKeepsCursorVisible(t *testing.T) { + m := newTree(t) + m.SetSize(80, 2) + m.Down() + m.Right() // rows: a src/ b modules/ c e f ; cursor c (index 4) + if i := m.cursorRow(m.rows()); i < m.offset || i >= m.offset+m.height { + t.Fatalf("cursor row %d not in [%d,%d)", i, m.offset, m.offset+m.height) + } + m.Home() + if m.offset != 0 { + t.Fatalf("offset = %d after Home, want 0", m.offset) + } +} + +func TestCollapseAllSelectsTopLevelAncestor(t *testing.T) { + m := newTree(t) + m.Down() + m.Right() // c + m.Right() // d + m.CollapseAll() + assertCursor(t, m, "b") + got := visible(m) + want := []string{"a", "src/", "b", "f"} + if len(got) != len(want) { + t.Fatalf("rows = %v, want %v", got, want) + } + m.Toggle() // re-expanding b must not restore c's expansion + if m.expanded["c"] { + t.Fatal("c should be collapsed after CollapseAll") + } +} + +func TestExpandAllDescendsFirstChildren(t *testing.T) { + m := newTree(t) + m.Down() // b + m.ExpandAll() + assertCursor(t, m, "d") + got := visible(m) + want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"} + if len(got) != len(want) { + t.Fatalf("rows = %v, want %v", got, want) + } +} + +func TestExpandAllOnLeafKeepsSelection(t *testing.T) { + m := newTree(t) // cursor a, a leaf + m.ExpandAll() + assertCursor(t, m, "a") + if !m.expanded["b"] || !m.expanded["c"] { + t.Fatal("all nodes with children should be expanded") + } +}