The update check ran at most once a day and only printed a line after the TUI exited. Now the registry is asked on every start (in the background, 3 s timeout, no cache) and the result reaches the TUI as a message. With a newer release, the footer shows `q quit and update to vX` right of help and quit, on a highlighted background (Theme.Highlight). `q` quits and `main` installs the release once the terminal is restored, sharing the code of `submarine update`; plain quit moves to `Q`, ctrl+c never updates. Drop LatestCached and CheckInterval. Add D27 and F58. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
12 KiB
Architecture
Go, Bubble Tea (v1), Lip Gloss, Bubbles (key, help, spinner), bubblezone (mouse hit areas).
Package layout
cmd/submarine/main.go wiring: choose provider (git or --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; optional Fetcher, Watcher, Locator, Restorer, Switcher
internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
exec.go git runner (environment, errors)
parse.go pure parsers for git output (status, numstat, …)
provider.go Tree, Status, pin → ref resolution
fetch.go Fetch (network), Fetchable
watch.go Watch: polls git metadata for changes
restore.go Restore, RestoreLoss (network: only what is missing)
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach the branch)
gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency
internal/update/ self-update from the package registry; NO UI imports
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
restore.go, switch.go restore / switch flows; a switch reuses restoreState
overlay.go draws the picker over the finished view at the position the picker reports
msgs.go tree/status/fetch/change messages
refresh.go reloader: one load per key, coalesced reruns
theme/ colors, glyphs, issue words, change letters, level styles
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview
header/ header line
branches/ branch picker popup (search line + list)
footer/ key bar (navigation left, global right) and flash messages
Layering rules
internal/submoduleknows nothing about the UI. Everything that decides what a state means (issue kinds, levels, hidden issues) lives here.internal/tui/themedecides 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
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
}
Optional capabilities; the app type-asserts them:
type Fetcher interface {
Fetchable(path string) bool // initialized? ("" = superproject)
Fetch(ctx context.Context, path string) error
}
type Watcher interface {
Watch(ctx context.Context) <-chan Change // {Path, Local, Structure}
}
type Restorer interface {
RestoreLoss(ctx context.Context, path string) (Loss, error) // what a restore would discard
Restore(ctx context.Context, path string) error // may clone/fetch what is missing
}
type Switcher interface {
Branches(ctx context.Context, path string) ([]Branch, error) // local + remote, no network
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error) // as RestoreLoss
Switch(ctx context.Context, path string, b Branch) (string, error) // branch HEAD ends on, "" if detached
}
type Locator interface {
Dir(path string) string // absolute directory, "" if unknown
}
Treereturns the superproject and all submodules withParentandChildrenlinked.Statusfields are nil.Statusis called once per submodule, concurrently. It must be safe to call concurrently and must respectctx.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.
Git provider
internal/submodule/git shells out to git -C <dir> … (see D14).
-
Environment:
GIT_TERMINAL_PROMPT=0,GIT_OPTIONAL_LOCKS=0(reads never takeindex.lock),LC_ALL=C. OnlyFetchuses the network. -
Treereads each repository's.gitmodules(git config -f .gitmodules) and recurses into initialized submodules. It records, per path, the parent repository and the.gitmodulesbranch forStatus.Nameis the last element ofRelPath. -
Statusruns about ten small git commands per submodule (see state-model.md). At mostruntime.NumCPU()Statuscalls run at once; the others wait on a semaphore and still respectctx(D16). -
Superproject: branch from
symbolic-ref, ahead/behind against@{upstream}. Its fetch age (mtime ofFETCH_HEAD) isStatus.FetchedAtof the root node, like a submodule's. -
Fetch:git fetch --all --prune --no-recurse-submodules --quiet(we fetch every submodule ourselves), at most 8 at once, 2 min timeout each. To never prompt,GIT_SSH_COMMAND="ssh -o BatchMode=yes"is set unless the user configuredGIT_SSH_COMMANDorcore.sshCommand. The error is git's first line of output. -
Watchpolls every 2 s (WatchInterval) and compares a fingerprint per repository: names, mtimes and sizes of the git metadata files (D19).- remote part:
FETCH_HEAD,refs/remotes/**,refs/tags/** - local part:
HEAD,index,MERGE_HEAD,packed-refs,refs/heads/**,reftable/** - structure: the worktree
.gitmodules, and whether.gitexists
Git dirs are resolved from
.git(directory, orgitdir:file for submodules, pluscommondirfor worktrees) without running git. Our own reads write nothing (GIT_OPTIONAL_LOCKS=0), so they never trigger a change. Worktree file edits are not watched (rreloads). - remote part:
Loading flow
InitrunsTree()→treeLoadedMsg.- The app indexes submodules by path and starts one
tea.Cmdper submodule callingStatus()→statusLoadedMsg. - Each result is stored on the submodule (
StatusorErr); the view updates incrementally, including parents' hidden-issue counts. - After the first tree: the watcher starts (
changeMsg). Nothing is fetched until the user pressesf, which starts onetea.Cmdper fetchable repository (fetchDoneMsg). - The spinner ticks only while something loads or fetches.
Fetch and refresh are decoupled (D20)
Fetch (network) ──writes refs──▶ .git/… ◀──writes refs── user's shell
│
Watch (polls every 2 s)
│ Change{Path, Local, Structure}
▼
app reloads Status / Tree
fetchDoneMsgonly updates the progress andSubmodule.FetchErr; it never reloads anything. The watcher sees the new refs and triggers the reload, exactly as for a fetch made in another shell.changeMsg:- submodule, remote only → reload its
Status - submodule, local → reload it and its direct children (their pins live in its index)
- superproject, or
.gitmodulesanywhere → reload the tree; if local, also every status
- submodule, remote only → reload its
- A reloaded tree carries the old
Status,ErrandFetchErrover by path, andtree.ReplaceRootskeeps expansion and the cursor, so a reload doesn't flash spinners or move the selection. - The
reloaderruns at most one load per key (a submodule path, or the tree). A request during a load marks it dirty; it runs once more afterwards. Results therefore never arrive out of order and bursts of changes (a fetch writing many refs) coalesce.
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 usespath.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>androw:<path>, prefixed per instance.
Rendering notes
- Each segment of a row is rendered with the row's
basestyle (background), because an inner style's reset would otherwise clear the outer background. - Widths are measured with
lipgloss.Width; truncation usesansi.Truncate, which keeps escape sequences and bubblezone markers intact. zone.Scanruns once, on the root view.
Distribution
No CI runner: releases are built and published from a developer machine.
make distcross-compilesCGO_ENABLED=0binaries for linux/darwin × amd64/arm64 intodist/pluschecksums.txt; the version comes fromgit describeand is baked in with-X main.version.make releasewithGITEA_TOKENset, e.g. in.env(copy.env.template; token needswrite:package; HEAD must be a cleanvX.Y.Ztag) runs tests, builds and uploads to the Gitea generic registryhttps://git.simon-franken.de/api/packages/simonfranken/generic:submarine/<version>/submarine_<os>_<arch>andchecksums.txtsubmarine-latest/latest/VERSION, replaced last, so a failed upload never advertises a broken version.
- The
releaseskill (.claude/skills/release/SKILL.md) drives a release: latest tag → next semver from the commits since → check (vet,test,gofmt,make dist) → tag and push → the developer runsmake release→ the skill verifies the registry'sVERSIONand creates the Gitea release with MCP (notes only; the binaries stay in the registry). install.sh(curl -fsSL …/raw/branch/main/install.sh | sh) detects OS/arch, verifies the sha256, installssubmarineand asubsymlink to~/.local/bin(SUBMARINE_INSTALL_DIR,SUBMARINE_VERSION,SUBMARINE_BASE_URLoverride).submarine update [--check] [--force](packageinternal/update) readsVERSION, downloads the asset for the running platform, verifies the checksum and atomically replaces the real executable (symlinks resolved, sosubworks too).SUBMARINE_UPDATE_URLoverrides the registry.- On every start the TUI looks for a newer release in the background (3 s
timeout, no cache, no blocking).
mainpassestui.WithUpdateCheckand the TUI gets the result as a message; the footer then offersq quit and update to vX. That key quits, andmaininstalls the release once the terminal is restored (the same code assubmarine update). The check is off for dev builds,--mockandSUBMARINE_NO_UPDATE_CHECK=1.
Testing
go vet ./...
go test ./...
internal/submodule: issue classification and hidden-issue aggregation.internal/submodule/git: parser unit tests, and integration tests against a fixture repository built byscripts/make-fixture.sh(skipped ifgitis not installed). Fetch and watch tests build their own fixture, since they change it; the fixture's remotes are local bare repositories, so fetching works offline.internal/tui:reloader.internal/tui/tree: navigation rules. Tests needzone.NewGlobal()(seeTestMain).
Manual check in a headless terminal:
scripts/make-fixture.sh /tmp/subfix # superproject with every state
go build -o /tmp/submarine ./cmd/submarine
tmux new-session -d -s sub -x 130 -y 24 "/tmp/submarine /tmp/subfix/super"
tmux send-keys -t sub Down Right
tmux capture-pane -pt sub # add -e to see colors
tmux kill-session -t sub