TUI for reviewing diffs, files, and documents with inline annotations, built with bubbletea.
┌─────────────────────────────────────────────────────┐
│ app/ — composition root (package main) │
│ main.go — main(), early-exit flow │
│ config.go — options, parseArgs, config IO │
│ stdin.go — stdin validation, /dev/tty │
│ renderer_setup.go — VCS detection, renderer pick │
│ themes.go — theme CLI commands, wiring │
│ history_save.go — history-save policy │
├─────────────────────────────────────────────────────┤
│ app/ui/ — bubbletea TUI (single Model struct) │
│ ├── overlay/ — popup layers (help, annots, │
│ │ theme selector) │
│ ├── sidepane/ — file tree + markdown TOC │
│ ├── style/ — color/ANSI resolution │
│ └── worddiff/ — intra-line diff engine │
├─────────────────────────────────────────────────────┤
│ app/diff/ — VCS detection + diff parsing │
│ app/highlight/ — chroma syntax coloring │
│ app/annotation/ — in-memory annotation store │
│ app/editor/ — external $EDITOR invocation │
│ app/handoff/ — post-flush command preparation │
│ app/keymap/ — configurable keybindings │
│ app/theme/ — Catalog-centric theme system │
│ app/history/ — review session auto-save │
│ app/fsutil/ — filesystem utilities │
└─────────────────────────────────────────────────────┘
package main is the composition root, split across files by concern:
main.go—main(), early-exit commands (version, dump-config, dump-keys),run()orchestration,finalize()(history safety-net vs-ohandoff afterp.Run())config.go—optionsstruct,parseArgs,dumpConfig,loadConfigFile, config-path helpersstdin.go— stdin validation,/dev/ttyreopen, stdin renderer preprenderer_setup.go—DetectVCSwiring,setupVCSRenderer(git/hg/jj/no-VCS/all-files)themes.go— theme CLI commands (--init-themes,--install-theme,--list-themes,--theme),applyTheme(),themeCatalogadapter (composestheme.Catalog+ config persistence forui.ThemeCataloginterface)history_save.go—histReqstruct andsaveHistorysignal.go—shutdownGuard(turns SIGHUP/SIGTERM into a single gracefulQuit+ awasSignaledflag; SIGINT is caught and drained so a Ctrl-C during an external$EDITORdoes not quit revdiff) and the consumer-sidequitterinterface
Key wiring pattern — all concrete types constructed here, injected into ui.Model through
interfaces and factory closures:
ModelConfig{
Renderer: diffRenderer, // diff.Git, diff.Hg, etc.
Highlighter: highlighter, // highlight.Highlighter
StyleResolver: styleResolver, // style.Resolver
StyleRenderer: styleRenderer, // style.Renderer
SGR: sgr, // style.SGR
WordDiffer: &worddiff.Differ{},
Overlay: overlay.NewManager(...),
Themes: themes, // themeCatalog adapter
NewFileTree: func(...) FileTreeComponent { ... },
ParseTOC: func(...) TOCComponent { ... },
// ...flags, store, keymap
}Handles all interaction with version control systems and diff parsing.
VCS detection (vcs.go): DetectVCS() walks up directory tree looking for .jj/.git/.hg
markers, returns VCSJJ, VCSGit, VCSHg, or VCSNone. .jj is checked before .git so
colocated jj+git repositories resolve as jj (reads go through the jj working-copy model instead of
bypassing it via git).
Renderer implementations — all implement the ui.Renderer interface (ChangedFiles() +
FileDiff()). FileDiff takes a single FileDiffRequest value (Ref, Path, OldPath,
Staged, ContextLines) rather than positional args — bundled to stay under the 4-param limit and
to carry the rename origin:
Git— runsgit diff, parses unified diff output. Rename-aware:ChangedFileskeeps the rename origin onFileEntry.OldPath, andFileDiffpasses-Mplus both old/new paths (pathArgs) so git pairs the rename into a minimal diff instead of rendering the file as fully added. Untracked renames (plainmv old new, wherenewis untracked sogit diff -Mcan't pair it) are recovered byUntrackedRenamesoff a throwaway index (tempIndexWithIntentToAddcopies.git/index,git add -Nthe untracked paths against the copy withGIT_INDEX_FILE, thengit diff -Mreports the pair);FileDiffrenders them the same way viauntrackedRenameDiff. Git-only —Hg/Jjnever setOldPath, so they ignore itHg— runshg diff --git, parses unified diff outputJj— runsjj diff --git, parses unified diff output; git-style refs (HEAD, HEAD~N, A..B) translate to jj revsets via--from/--to. jj emits raw bytes for binary files, so(*Jj).synthesizeBinaryDiffrewrites such diffs with the git-style "Binary files … differ" marker soparseUnifiedDiffproduces a binary placeholder.
CommitLogger capability (CommitLog(ref string) ([]CommitInfo, error)) — an additive capability
interface implemented by Git/Hg/Jj and consumed by the i info overlay. Separate from the
base Renderer so non-VCS renderers (FileReader, DirectoryReader, StdinReader) stay
unaffected. Each VCS translates the pre-combined ref string to its own log syntax (X..HEAD for
git, X::. for hg, X..@ for jj), caps results at 500 commits, and strips raw \x1b bytes from
subject/body at parse time so the overlay can render without re-scanning for ANSI injection. Hg uses
ASCII US/RS separators (\x1f/\x1e) because literal NUL is invalid in argv; git and jj use
NUL/SOH via stdout.
FileReader— reads standalone files as full-context (no VCS needed)DirectoryReader— lists all tracked files via a pluggable lister (git ls-filesby default;NewJjDirectoryReaderusesjj file list) for--all-filesmodeStdinReader— reads from stdin as scratch bufferFallbackRenderer— wraps a primary renderer with fallback for files not in diffExcludeFilter/IncludeFilter— decorators for prefix-based file filtering
Diff parsing (parseUnifiedDiff): converts unified diff output into []DiffLine. Each
DiffLine carries:
Content— line text without+/-prefix (prefix re-added at render time)Change—ChangeAdd,ChangeRemove,ChangeContext, orChangeDividerOldNum/NewNum— original and new line numbers (0 for non-applicable)
Blame (blame.go, hgblame.go, jjblame.go): Blamer interface provides FileBlame()
returning map[int]BlameLine keyed by new line number. jj blame uses
jj file annotate -T <template> with a tab-separated template.
Central package. Single Model struct implements bubbletea's Model interface. Methods split
across files by concern to keep files under ~500 lines:
model.go— Model struct, sub-state structs,NewModel,Init,Update,handleKey, interfacesview.go—View(), status bar rendering, ANSI helpershandlers.go— modal handlers (enter/esc, discard, filter, reviewed), help specloaders.go— async file/blame loading, reviewed-fingerprint reconciliation, loaded-message handlers, data helpersdiffview.go— diff line rendering, gutters, line styling, search highlightsdiffnav.go— cursor movement, hunk navigation, viewport sync, horizontal scrollscrollbar.go— vertical scrollbar thumb post-processing on rendered diff/tree/TOC panes (replaces right-border│with┃on rows mapped to the visible viewport portion)collapsed.go— collapsed diff mode: hide removes, show modified markersannotate.go— annotation input lifecycle (start, save, cancel, delete) and the visual-row chokepoint:annotationVisualRowsis the single source of truth for "how many rows + what content does this annotation paint as." Memoized onannot.rowCache, invalidated byhandleFileLoaded,applyTheme, andcancelThemeSelectannotlist.go— annotation list spec building, cross-file jump logic (jumpToAnnotationTargetfor the@popup,tryJumpToAnnotationTargetreturning a jumped-bool for the}/{walker)annotnav.go— cross-file annotation navigation (}/{): builds the flat annotation list, computes adjacent target via exact-match or insertion-point fallback, retries through non-jumpable targets so a hidden annotation cannot trap the walkereditor.go—$EDITORhandoffs for annotation temp-file editing and source-file opening:openEditor()/openSourceEditor()wrapapp/editor.Editorintea.ExecProcess, capture target state, route completion, and refresh the current file after a clean source-editor exitoutput.go— in-session annotation flush and optional post-flush command handoff through injectedPostFlushHook+tea.ExecProcessthemeselect.go— theme selector operations: open, preview, confirm, apply (via injectedThemeCatalog)filepicker.go— file picker open and selected-path jump integration; delegates visible-order/filter ownership toFileTreeComponentand loading to the guarded file loadersearch.go— search input handling, match computation, navigationmouse.go— mouse event routing:handleMousedispatch,hitTestpane classification (hitZone), wheel/left-click helpers (clickTree,clickDiff), layout helpers (statusBarHeight,diffTopRow,treeTopRow). Diff-pane wheel events defer both the cursor pin and theSetContent(renderDiff())call via a single in-flighttea.Tick(wheelRenderDelay)debounce (issue #179) —wheelState.tickInFlightensures one tick at a time across an entire burst (subsequent wheels just bumpgen); stale ticks reschedule, matching ticks flush.flushWheelPending()is called fromhandleWheelDebounce,handleKey,handleResize, andhandleBlameLoaded(any path that runssyncViewportToCursoror readsm.nav.diffCursormust flush first). Mouse tracking is enabled program-wide viatea.WithMouseCellMotion()inapp/main.gounless--no-mouse/REVDIFF_NO_MOUSEis set
Each source file has a matching _test.go.
Model state grouping — Model fields are organized into explicit sub-structs by concern:
modelConfigState(m.cfg) — immutable session config:ref,staged,only,noColors,tabSpaces, etc.layoutState(m.layout) — viewport and pane geometry:viewport,focus,treeHidden,width,height,scrollXloadedFileState(m.file) — current file's loaded state:lines,highlighted,intraRanges,blameData,mdTOC,singleFilemodeState(m.modes) — user-togglable view modes:wrap,collapsed,compact,compactContext,lineNumbers,wordDiff,showBlamenavigationState(m.nav) — cursor position:diffCursor,pendingHunkJumpsearchState(m.search) — search lifecycle:active,term,matches,cursor,input,matchSet,history,historyIdxannotationState(m.annot) — annotation input lifecycle and visual-row cache:annotating,fileAnnotating,cursorOnAnnotation,input,rowCachewheelState(m.wheel) — diff-pane wheel coalescing (issue #179):gen,renderPending,tickInFlight
Methods remain on Model — the sub-structs group mutable state for clarity, not to create
mini-models.
Theme boundary — app/ui does not import app/theme or app/fsutil. Theme discovery and
persistence are accessed through the ThemeCatalog interface (defined in model.go), with a
concrete adapter wired in app/themes.go.
Owns all hex-to-ANSI conversion, lipgloss style construction, SGR state tracking, HSL color math, and semantic color accessors, plus the shared filename-display helpers every path-rendering surface routes through.
Three main types:
Resolver— static and runtime style/color lookups. Methods:Color(),Style(),LineBg(),LineStyle(),WordDiffBg(),IndicatorBg()Renderer— compound ANSI rendering for elements that need raw ANSI (not lipgloss). Methods:AnnotationInline(),DiffCursor(),StatusBarSeparator(),FileStatusMark(),FileReviewedMark(),FileAnnotationMark()SGR— ANSI SGR stream processor.Reemit()re-prepends active fg/bg/bold/italic state at continuation line starts (needed for wrap mode becauseansi.Wrapdoesn't preserve SGR across newlines)
display.go holds two package-level functions rather than methods on those types:
SanitizeFilenameForDisplay() strips control, ANSI/OSC, and bidi sequences out of
repository-supplied filenames, and TruncateLeftToWidth() left-truncates with an ellipsis. Both are
shared by the diff-pane header, the status bar, and the file picker; any new filename-rendering
surface must route through them.
Two independent component types, both with cursor/offset management, rendering, and keyboard navigation:
FileTree— file tree sidebar. Supports navigation (Move/StepFile), filtering (annotated-only), semantic-fingerprint reviewed tracking, directory grouping. File-list reloads revalidate only paths reviewed before the load; marks added during the load are reconciled when that file's refreshed diff arrives.VisibleFiles()exposes file paths in rendered order after active filters for consumers such as the file picker.TOC— markdown table-of-contents. Activated for single-file full-context markdown. Active section tracking, header-level navigation
Both constructed via factory closures in main.go, consumed through
FileTreeComponent/TOCComponent interfaces.
Layered popup system with mutual exclusivity (one overlay at a time).
Manager— coordinator. Routes key events, manages open/close lifecycle,Compose()renders popup on top of background using ANSI-aware compositing (overlayCenter())helpOverlay— scrollable keybinding help popup. Clamps toterm_w - 4×term_h - 4(issue #304 — it previously sized to its content and got clipped on all four sides). Prefers two columns because they roughly halve the height, and falls back to a single column when the two-column form is wider than the terminal allows; anything still too wide after that is truncated. A body taller than the viewport scrolls (j/k, page/half-page,Home/End,g/G, wheel) and spends its last row on a muted↑/↓ scroll · N-M of Thint — the only cue that sections exist past the fold. That hint row is whypageSize()exists: paging must step by the last rendered viewport height, not byusableHeight(), or every page boundary skips the row the hint displaced. Rows are padded to the width of the whole body rather than of the visible slice, so the box does not resize while scrollingannotListOverlay— scrollable annotation list with cross-file jumpthemeSelectOverlay— theme picker with fzf-style filter, live swatch previewfilePickerOverlay— type-to-filter visible-file picker with current-file positioning, arrow-key navigation, mouse selection, and basename-preserving path truncationinfoOverlay(info.go) — unified info popup (description + session details + commit log). Description prose comes from--description/--description-file, sanitized to strip ANSI/control bytes, then highlighted via the markdown chroma path once atNewModeltime and cached onreviewInfoState.descriptionHighlighted(the description is static, so re-highlighting on every overlay refresh would just produce identical bytes). Session metadata (mode, scope, filters, file/status counts, aggregate+/-) lives in the popup's top/bottom borders; commit log shows subject + body of every commit in the current ref range. Commits are populated eagerly at startup vialoadCommits()in parallel withloadFiles()undertea.Batch; re-fetched onRreload.handleInfoalways opens the popup; if the user pressesibefore the fetch lands, the commits section renders an inline "loading commits…" placeholder which flips to the rendered list whencommitsLoadedMsgarrives (refreshInfoOverlaypushes a fresh spec into the open overlay). Sized viaclamp(term_w * 0.9, 30, 90)×term_h - 4, wraps body text at word boundaries usingansi.Wrapfromcharmbracelet/x/ansi(ANSI-aware, preserves inline escapes). Renders "no commits in range" centered for the empty-list case and a truncated, italicized one-liner for fetch errors (infoErrMaxLencaps total length to keep the popup bounded against megabyte stderr).
Manager.HandleKey() returns an Outcome — Model switches on OutcomeKind to perform side effects
(file jumps, theme apply/persist). This keeps overlay package free of Model dependencies.
Manager.HandleMouse() mirrors the same shape for wheel and click events:
app/ui/mouse.go::handleOverlayMouse delegates when an overlay is active so info and help scroll,
annotlist/themeselect move their cursors (themeselect emits OutcomeThemePreview to restyle the
background live). Left-click in annotlist selects the clicked row and emits
OutcomeAnnotationChosen; left-click in themeselect emits OutcomeThemeConfirmed on entry rows
(filter and blank separator are no-ops). Click hit-testing uses the last-composed popup bounds
recorded in Manager.bounds during Compose(); clicks outside the popup rectangle are swallowed so
accidental clicks don't dismiss the overlay.
The file picker follows the same outcome flow: wheel input moves its cursor, and left-click emits
OutcomeFileChosen for the selected path.
Single stateless type Differ grouping all word-diff algorithms:
PairLines()— matches add/remove lines within hunks for comparisonComputeIntraRanges()— token-level LCS diff producing byte-offset rangesInsertHighlightMarkers()— ANSI-aware highlight insertion, shared by both word-diff and search highlighting
30% similarity gate discards ranges for dissimilar pairs. Cost is guarded by a budget on the LCS
table size (token count of one line times the other), with a byte pre-filter that keeps minified
input out of the tokenizer — line length alone is not the cost driver. Ranges are byte offsets on
tab-replaced content, aligning with prepareLineContent output.
Chroma-based syntax highlighter. Produces foreground-only ANSI output (no backgrounds) so that diff
line backgrounds from the style system are preserved. Highlighted lines pre-computed once per file
load, stored parallel to diffLines.
~30 Action constants (e.g., ActionDown, ActionQuit). Keymap type maps key strings to
actions. Loaded from file (map <key> <action> / unmap <key> format) or defaults.
Handlers use m.keymap.Resolve(msg.String()) instead of raw key strings. Modal text-entry keys
(annotation input, search input, confirm discard) stay hardcoded. Overlay key dispatch uses keymap
actions for j/k/up/down but keeps enter and esc hardcoded.
Two-stage chord bindings (kitty-style, e.g. map ctrl+w>x mark_reviewed) are supported with a
ctrl+/alt+ leader restriction. Storage is flat strings in bindings ("ctrl+w>x" → Action); a lazy
chordPrefixCache provides O(1) IsChordLeader lookups. Load resolves conflicts by dropping a
standalone whose key is also a chord leader. ResolveChord applies the same Latin layout-resolve
fallback as Resolve for the second-stage key. Chord dispatch lives in app/ui on keyState
(handleChordSecond, clearPendingInputState) and flows through the shared dispatchAction path
so chord-resolved actions share handlers with single-key actions.
Help overlay dynamically rendered from m.keymap.HelpSections().
Catalog-centric API with two types: Theme (data + serialization) and Catalog (directory-aware
operations). Zero standalone functions — all logic lives as methods on Theme or Catalog.
Theme — color palette data with metadata. Methods: Dump(io.Writer) for serialization.
Catalog — theme discovery, loading, installation, and gallery access. Created via
NewCatalog(themesDir). Public methods: Entries, Load, Resolve, InitBundled, InitAll,
Install, PrintList, ActiveName, OptionalColorKeys.
File layout:
theme.go—Themestruct,Dump, package-level vars (colorKeys,optionalColorKeys)catalog.go—Catalogstruct,NewCatalog, all catalog methods (discovery, loading, installation, gallery)
Bundled themes: revdiff, catppuccin-mocha, catppuccin-latte, dracula, gruvbox, nord, solarized-dark.
Community themes live in themes/gallery/.
23 color keys mapped via colorFieldPtrs() in app/themes.go — single source of truth for color
key to struct field mapping.
In-memory store for annotations. Each Annotation has file, line, text, and optional EndLine for
hunk range headers (triggered when comment contains "hunk" keyword). Structured output formatting
for export. FormatOutput escapes body lines that start with ## (with trailing space, matching
the record-header form) by prefixing a single space so downstream parsers cannot confuse a comment
line for a new record header. Lines starting with ### or ## without a space are left unchanged.
WriteFile(path) formats once via FormatOutput, persists atomically by delegating to
fsutil.AtomicWriteFile (temp file + rename, mode 0o600), and returns the exact snapshot written
for the optional post-flush hook; it backs the in-session O flush. The exit-time file write calls
fsutil.AtomicWriteFile directly with the already-formatted output, so both paths share the same
atomic writer and a concurrent reader never sees a truncated file.
Prepares external editor processes for annotation temp-file editing and source-file opening.
TUI-agnostic — the caller wraps the returned *exec.Cmd with bubbletea's tea.ExecProcess (or runs
it directly).
Single stateless type Editor bundling all behavior as methods (no standalone functions):
Command(content)— writes content to arevdiff-annot-*.mdtemp file, resolves the editor ($EDITOR→$VISUAL→vi, whitespace-split socode --waitworks), returns*exec.Cmd+ acomplete(runErr) (string, error)function.completereads the file, removes it regardless of outcome, and preservesrunErr— content is still returned alongside a non-nilrunErrso callers can keep user work on soft editor failures.SourceCommand(path string, line int)— checks that the source path exists and is a regular file as part of preparing an editor command, resolves the same editor chain, and returns an editor command for the existing source file. Known editors receive line-navigation arguments:vi,vim,nvim, andnanouse+N, whilecode,code-insiders,codium, andcursoruse--goto path:N. Unknown editors receive only the file path.
Consumed by app/ui via the ExternalEditor interface (defined in app/ui/editor.go, consumer
side). The default wiring is editor.Editor{} injected through ModelConfig.Editor.
Prepares the user-configured shell command for an explicit O handoff. New returns nil for an
empty command; a constructed runner's Prepare(content) is infallible, supplies the exact
annotation snapshot on stdin, and suppresses stdout so commands such as tee do not overwrite the
TUI. stderr remains attached when app/ui runs the command through tea.ExecProcess, and a command
may write OSC 52 directly through /dev/tty. The optional PostFlushHook interface is defined on
the UI consumer side and wired at the composition root only when --post-flush-command is set. The
hook is an independent O export target; it does not require an output file.
Save(Params) writes review session as markdown to ~/.config/revdiff/history/. Includes header,
annotations, and git diff for annotated files.
On a signal-delivered exit (a SIGHUP from a dropped SSH/tmux client, or a SIGTERM) finalize() in
main.go invokes this save as a crash-recovery net and stops there — history only, never the -o
output. This is a deliberate semantic change: a signal-delivered SIGTERM no longer writes -o,
because a signal is not the deliberate handoff that q/O perform. The wiring lives at the
composition root — shutdownGuard in app/signal.go plus tea.WithoutSignalHandler() so revdiff
owns SIGHUP/SIGTERM instead of bubbletea. SIGINT is caught and drained so a Ctrl-C during an
external $EDITOR does not quit revdiff. The guard is stopped (default signal disposition restored
for all three) before finalize() runs, so a slow or hung finalize (saveHistory shells out to
git) stays killable by a second signal.
All consumer-side — defined in app/ui/model.go, not in implementor packages (exception:
diff.Renderer is a local mirror exported for moq generation). This is idiomatic Go: interfaces
belong to the consumer.
Renderer—ChangedFiles(),FileDiff(); implemented bydiff.Git,diff.Hg,diff.FileReader,diff.DirectoryReader,diff.StdinReader,diff.FallbackRenderer,diff.ExcludeFilter,diff.IncludeFiltercommitLogSource—CommitLog(ref); implemented bydiff.Git,diff.Hg,diff.Jj(viadiff.CommitLoggercapability; resolved at Model construction by type-assertion on the Renderer whenModelConfig.CommitLogis nil)SyntaxHighlighter—HighlightLines(),SetStyle(),StyleName(); implemented byhighlight.HighlighterBlamer—FileBlame(); implemented bydiff.Git,diff.HgstyleResolver—Color(),Style(),LineBg(),LineStyle(),WordDiffBg(),IndicatorBg(); implemented bystyle.ResolverstyleRenderer—AnnotationInline(),DiffCursor(),StatusBarSeparator(),FileStatusMark(),FileReviewedMark(),FileAnnotationMark(); implemented bystyle.RenderersgrProcessor—Reemit(); implemented bystyle.SGRwordDiffer—ComputeIntraRanges(),PairLines(),InsertHighlightMarkers(); implemented byworddiff.DifferFileTreeComponent— 27 methods (navigation, query, mutation, scroll-state, render); implemented bysidepane.FileTreeTOCComponent— 9 methods (navigation, cursor/section query+set, scroll-state, render); implemented bysidepane.TOCoverlayManager—Active(),Kind(),OpenHelp(),OpenAnnotList(),OpenThemeSelect(),OpenFilePicker(),OpenInfo(),UpdateInfo(),Close(),HandleKey(),HandleMouse(),Compose(); implemented byoverlay.ManagerThemeCatalog—Entries(),Resolve(),Persist(); implemented bythemeCatalogadapter inapp/themes.go(composestheme.Catalog+ config persistence)ExternalEditor—Command(content)for annotation temp-file editing,SourceCommand(path string, line int)for opening source files; implemented byeditor.Editor(default wiring viaModelConfig.Editor; stubbed in tests)PostFlushHook—Prepare(content); implemented byhandoff.Runner(optional wiring viaModelConfig.PostFlushHook)
main() [main.go]
→ parseArgs() [config.go] → config file + CLI flags + env vars
→ handleThemes() [themes.go] → theme resolution, apply
→ run() [main.go]
→ prepareStdinMode [stdin.go] (if --stdin)
→ setupVCSRenderer [renderer_setup.go] (otherwise)
→ construct style, theme catalog adapter, all dependencies
→ ui.NewModel(ModelConfig{...})
→ tea.NewProgram(model, WithoutSignalHandler).Run() [shutdownGuard owns SIGHUP/SIGTERM; SIGINT drained]
→ finalize() [main.go] → saveHistory() on non-discarded, non-empty exit; -o output only on graceful (non-signal) exit
Model.Init() → loadFiles cmd
→ Renderer.ChangedFiles() → filesLoadedMsg
→ handleFilesLoaded drops stale msg (seq mismatch), else m.filesLoaded = true
→ (on success) tree.Rebuild(entries)
→ auto-select first file → loadFileDiff cmd
→ Renderer.FileDiff() → fileLoadedMsg
→ highlight.HighlightLines() → highlightedLines
→ (optional) loadBlame cmd → blameLoadedMsg
View() is gated on two flags so the user never sees an empty two-pane layout during async
initialisation: it returns the literal "loading..." while !m.ready (before the first
WindowSizeMsg), then "loading files..." while m.ready && !m.filesLoaded (after resize, before
an accepted filesLoadedMsg). filesLoaded flips to true on every accepted filesLoadedMsg
(success or error), so the loading screen always exits once the current in-flight load returns.
Stale responses (msg.seq != m.filesLoadSeq, e.g. an older load still in flight after
toggleUntracked bumped the sequence) are dropped and do not flip the flag or rebuild the tree.
diffLines + highlightedLines
→ renderDiff() dispatches by mode:
├── expanded: renderDiffLine() per line
│ → prepareLineContent() (tab replacement)
│ → styleDiffContent() (syntax-highlighted or plain, with line bg)
│ → applyIntraLineHighlight() (word-diff ranges, if active)
│ → highlightSearchMatches() (search bg overlay, if active)
│ → lineNumGutter() (if line numbers on)
│ → blameGutter() (if blame on)
│ → applyHorizontalScroll() (if not wrapped)
│ → extendLineBg() (pad to full width)
│ → wrapContent() + sgr.Reemit() (if wrapped)
│
└── collapsed: renderCollapsedDiff()
→ skip removed lines
→ buildModifiedSet() for modify vs pure-add styling
→ viewport.SetContent()
→ View():
├── truncateHeaderTitle() (sanitize + left-truncate filename to 1 row)
├── lipgloss.JoinVertical(header, viewport.View())
├── padContentBg() (pre-render: pane bg fill on assembled content)
├── lipgloss.Render() with Border() + Width()/Height()
└── applyScrollbar() (post-render: thumb glyph on diff right-border rows)
sidepane.Render() (file tree or markdown TOC)
→ padContentBg()
→ lipgloss.Render() with Border() + Width()/Height()
→ applyNavigationScrollbar() (post-render: thumb glyph on navigation right-border rows)
→ terminal
Each rendering feature (line numbers, blame, word-diff, search, wrap, collapsed) is orthogonal — can be independently toggled.
User presses 'a' on diff line
→ annotating = true, annotateInput focused
→ Enter → store.Add(file, line, text) (single-line fast path)
→ Ctrl+E → openEditor()
→ editor.Editor.Command(seed) (app/editor)
→ tea.ExecProcess(cmd, complete) (suspends bubbletea, hands over tty)
→ editorFinishedMsg{content, err, target...}
→ handleEditorFinished:
err != nil → log, keep annotation mode open, preserve input
content == "" → cancelAnnotation (preserve existing annotation)
otherwise → saveComment(content, fileLevel, line, type)
→ re-render shows annotation (multi-line aware) below diff line
→ 'O' (flush_output, requires --output and/or PostFlushHook):
→ empty store: status hint, no export
→ optional store.WriteFile(path) → atomic file snapshot
→ optional PostFlushHook.Prepare(snapshot) → tea.ExecProcess → command reads snapshot from stdin
→ revdiff stays open (annotate → flush → hand off → 'R' reload loop)
→ on quit: store.FormatOutput() → structured output to stdout/file (file branch uses store.WriteFile)
→ (optional) history.Save() → markdown to ~/.config/revdiff/history/ (best-effort warnings only)
→ if --exit-code-on-annotations is enabled and output is non-empty: exit 10
User presses 'e' in diff pane
→ openSourceEditor()
→ sourceEditorTarget() chooses file path and optional worktree line
→ editor.Editor.SourceCommand(path, line) prepares the source-file command
→ tea.ExecProcess(cmd, complete) (suspends bubbletea, hands over tty)
→ sourceEditorFinishedMsg{err, refreshPolicy}
→ handleSourceEditorFinished:
err != nil → log, show hint, do not reload
err == nil and worktree refresh policy
→ reloadCurrentFile() reloads current file only
err == nil and no-refresh policy
→ return to revdiff without reloading
Source line navigation is best effort for all worktree-backed reviews. Staged and ref reviews still request the focused line when revdiff can derive one from the loaded diff, but clean editor exits from staged and ref reviews do not reload the displayed diff. Removed rows target the nearest line that still exists in the current file; when previous and next current-file rows are equally near, the previous row wins.
User presses '?' / '@' / 'T' / 'P' / 'i'
→ Model calls overlay.OpenHelp/OpenAnnotList/OpenThemeSelect/OpenFilePicker/OpenInfo
(for 'i': review scope is assembled from ReviewInfoConfig and current
file-load state; aggregate +/- stats are fetched lazily on first open
via loadReviewStats() and pushed into the open popup with UpdateInfo().
Commits are fetched eagerly at startup via loadCommits(), running in
parallel with loadFiles() under tea.Batch from Init(); triggerReload()
re-fires both together. handleCommitsLoaded caches the result under a
seq-guard (m.commits.loadSeq) and refreshes the open popup. The info
popup always opens; modes without a meaningful commit range hide the
commits section instead of treating `i` as a no-op.)
→ overlay.Manager activates popup, blocks other overlays
→ key events route through Manager.HandleKey() → Outcome
→ Model switches on OutcomeKind:
OutcomeAnnotationChosen → load target file, position cursor
OutcomeThemePreview → preview theme colors, update resolver
OutcomeThemeConfirmed → apply theme, persist to config file
OutcomeThemeCanceled → restore original theme
OutcomeFileChosen → reveal path in tree, focus diff, load if changed
OutcomeClosed → close overlay, resume normal mode
→ Manager.Compose() renders popup over background content
Precedence: CLI flags > env vars > config file > built-in defaults
- Config file:
~/.config/revdiff/config(INI format via go-flags IniParser) - Theme files:
~/.config/revdiff/themes/(auto-created on first run) - Keybindings:
~/.config/revdiff/keybindings(map/unmapformat) - History:
~/.config/revdiff/history/(auto-save dir)
Theme precedence: --theme overwrites all 23 color fields + chroma-style, ignoring --color-*
flags or env vars. Applied via applyTheme() in app/themes.go which directly overwrites
opts.Colors.* fields after parseArgs().
Adding a new color requires changes in three places: theme.go colorKeys + options struct +
colorFieldPtrs() in app/themes.go.
Theme ownership is split by concern: app/theme owns discovery/loading/installation via Catalog,
app/ui consumes a ThemeCatalog interface for selector/preview/apply, and app/themes.go wires a
thin adapter composing theme.Catalog + config file persistence.
Several mutually exclusive input sources, validated at parse time:
| Mode | Flag | Renderer | Notes |
|---|---|---|---|
| VCS diff (default) | [base] [against] |
Git or Hg |
Detects VCS, runs diff |
| Staged changes | --staged |
Git or Hg |
Cannot combine with refs |
| All tracked files | --all-files / -A |
DirectoryReader |
Git only, not with refs/staged/only |
| Single file(s) | --only / -F |
FileReader |
Not with include |
| Stdin (raw text) | --stdin |
StdinReader |
Sniff fails or returns ErrNotUnifiedDiff |
| Stdin (multi-file diff) | --stdin |
MultiFileStdinReader |
Sniffs the unified-diff signature |
MultiFileStdinReader parses each section via parseUnifiedDiff; any per-section parse error fails
the whole call, so the caller falls back to StdinReader for the entire input.
Filters stack: --include narrows first, then --exclude removes. Both wrap any renderer as
decorators.
Single Model struct with split files — bubbletea's architecture centers on one Model. Splitting methods across files by concern keeps each file manageable while avoiding the complexity of multiple coordinating models.
Consumer-side interfaces — all interfaces defined in app/ui/model.go, not in implementor
packages. Idiomatic Go pattern that keeps packages decoupled and makes the dependency direction
explicit.
Factory closures for sidepane components — NewFileTree and ParseTOC are factory closures,
not direct constructor calls, because they need runtime parameters from main.go that Model
shouldn't know about.
Raw ANSI instead of lipgloss for inline elements — lipgloss Render() emits \033[0m (full
reset) which breaks outer backgrounds. Elements rendered inside a lipgloss container (status bar
separators, cursor markers, annotation text) use raw ANSI sequences via the style sub-package.
Overlay Outcome pattern — overlays return Outcome values instead of directly modifying Model
state. Keeps overlay package independent from Model, makes side effects explicit and testable.
Foreground-only syntax highlighting — chroma output limited to foreground colors so diff line backgrounds (add/remove/modify) from the style system are preserved without conflict.
Loaded-file state object — diffLines, highlightedLines, intraRanges, and related per-file
metadata are grouped into a single loadedFileState struct (m.file). This makes the
synchronization invariant explicit — all parallel arrays and derived data for the current file are
co-located rather than scattered across top-level Model fields.
Model state sub-structs — Model fields are grouped into named sub-structs (cfg, layout,
file, modes, nav, search, annot) by concern. Methods remain on Model — the sub-structs
make state ownership explicit without splitting into mini-models.
| Library | Purpose |
|---|---|
charmbracelet/bubbletea |
TUI framework (Elm architecture) |
charmbracelet/lipgloss |
Terminal styling |
charmbracelet/bubbles |
TUI components (viewport, textinput) |
jessevdk/go-flags |
CLI flag parsing with INI config support |
alecthomas/chroma/v2 |
Syntax highlighting |
stretchr/testify |
Test assertions |
matryer/moq |
Mock generation |