c copies the absolute directory of the selected submodule via OSC 52. Keys are grouped into navigation (footer left), global (footer right) and submodule (detail pane palette, footer if the pane can't show it). Adds a key reference table to the UX guide. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
10 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
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
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)
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
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 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}, "fetched" from the mtime ofFETCH_HEAD. -
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), and the background fetch 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.
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.- After the TUI exits, a one-line notice is printed if a newer release
exists. The check runs in the background at startup, at most once a day
(cached in the user cache dir) and is off for dev builds,
--no-fetch,--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