Archived
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 <noreply@anthropic.com>
This commit is contained in:
+36
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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:<path>` and `row:<path>`, 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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 <pin>...<ref>` 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.
|
||||||
@@ -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 <rule>`
|
||||||
|
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 · <count> <issue>… · n pin staged · n loading · fetched <age>`
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ".",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user