Skip to content

Mouse navigation, clickable chrome, and UX fixes - #1

Open
tomatg1 wants to merge 35 commits into
mainfrom
mouse-nav-and-ux
Open

Mouse navigation, clickable chrome, and UX fixes#1
tomatg1 wants to merge 35 commits into
mainfrom
mouse-nav-and-ux

Conversation

@tomatg1

@tomatg1 tomatg1 commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Local fork enhancements on top of upstream v4.25.0 (49f37e4) — full mouse
navigation, clickable UI chrome, a resizable/persisted preview divider, several
render/UX fixes, and an injectable version string in the logo.

Nothing here changes default behavior unless you opt in (defaults.mouseMode)
or use the mouse. Every commit is independently tested: 24 packages pass, go vet clean, -race clean.

Mouse capture, made configurable

  • d4a6ff9 defaults.mouseMode (cellMotion default / allMotion / none). none releases the mouse so the terminal's native text selection works.

Mouse navigation

  • 6815868 clickable section tabs + rows, wheel scrolling (upstream Add mouse support: resize preview, click tabs & rows dlvhdr/gh-dash#722)
  • 5d6569b drag to select text, copy on release
  • 2ac3d2f open on double-click or clicking the #number; a bare click only selects; CI/review/comments icons open their sub-pages
  • 6762327 clickable footer view switcher (PRs/Issues/Notifications), ? help toggle, and preview detail tabs (Overview/Activity/Commits/Checks/Files)

Layout

  • 9265f8f drag the preview divider to resize, remembered across runs (per config+repo, in $XDG_STATE_HOME/gh-dash/layout.json)

Fixes

  • a63ef7f don't log.Fatal when launched outside a git repo (upstream v4.25.x regression — still present on upstream v4.25.1)
  • 54b35c6 ? help click reflows immediately and restores pane sizes on close; help renders above the status bar so the toggle target doesn't move
  • a3c069a a sloppy click (trackpad jitter) no longer registers as a drag — it selects cleanly instead of leaving a stray selection
  • f6080e4 selecting a row clears the previously-selected row's highlight immediately (was stale until an async fetch)

Version string

  • bdc6be0 logo shows an -ldflags-injectable version instead of "dev"
  • 5354d7d suppress the "Update available!" nag on local/fork builds
  • 1b1177f wrap the version at the first hyphen, bottom-justified beside the logo

Notes

  • The gh extension is installed as a local symlink to this checkout; build with the versioned -ldflags (see the local build script) so the logo shows git describe rather than "dev".
  • Two implementation subtleties worth a look in review: bubblezone markers are zero-width CSI sequences (safe through the carousel's truncation and background styles), and row zones are marked outside the row's MaxWidth style so a marker can't be clipped.

tomatg1 added 28 commits July 8, 2026 15:23
gh-dash unconditionally set MouseModeCellMotion, so the terminal's mouse was
always captured. Capture is what enables clickable UI, but it also intercepts
click-drag -- which is exactly what terminals use for native text selection.
Users who mostly want to read and copy text had no way to opt out.

Expose it as `defaults.mouseMode`:

  cellMotion  clicks, release, wheel, drag  (default -- prior behavior)
  allMotion   also motion with no button held
  none        no capture; native text selection works again

Default stays cellMotion, so behavior is unchanged unless opted out. The
Config==nil early-return path keeps its hardcoded cellMotion default.

Tests cover both the unset default (guarding the zero-value trap, since
MouseModeNone is iota 0) and the `none` override.
Mouse capture was already on and bubblezone was already a dependency, but the
only marked zone was the donate button -- so tabs and rows did nothing.

- tabs: mark each title's zone; clicking a tab switches to that section.
- table: mark each row's zone; clicking a row selects it, and clicking the
  already-selected row opens it on GitHub. Opening on the second click keeps a
  stray click from launching a browser.
- wheel up/down moves the row selection.

Two things that are easy to get wrong here:

Row zones are marked OUTSIDE the row style. That style applies MaxWidth, and a
zone marker sliced in half is never scanned back out. Tab titles, by contrast,
are marked before the carousel, which runs lipgloss.Width and ansi.Truncate
over them -- safe only because bubblezone's markers are private CSI sequences
that both treat as zero-width. TestMouseZones_TabSurvivesCarouselTruncation
pins that, since it holds by construction of a third-party library.

Marking happens during render, and render happens where the program never runs
(tests, headless snapshots) and nobody called zone.NewGlobal(). zone.Mark
panics on a nil global manager, so common.MarkZone degrades to the unmarked
string instead -- an unclickable zone is exactly right when there is no mouse.

listviewport.SetCurrItem walks via Next/PrevItem rather than recomputing the
scroll bounds, so topBoundId/bottomBoundId/viewport offset stay consistent.
The walk is bounded by the visible rows.

Known limitation: a row only half-scrolled into view can have one of its two
markers clipped, so its zone may not register until it is fully visible.
getCurrentGitAndGitHubRepos() legitimately errors when gh-dash is launched
outside a git repo. That error is already logged and already handled -- the
gitRepo/ghRepo nil checks below it warn and carry on with an empty repo path.

But `err` was then re-checked a few lines later and passed to log.Fatal under a
copy-pasted "Cannot parse debug flag" message, so every invocation whose cwd was
not a repo died at startup:

    FATA Cannot parse debug flag failed to run git: fatal: not a git repository
    ... error: exit status 128  ="missing value"

(The trailing `="missing value"` is charmbracelet/log receiving `err` as a key
rather than a key/value pair -- a second symptom of the same stray line.)

Drop the re-check. Running from $HOME, /tmp, or anywhere else now works again,
falling back to the global config exactly as it did before.
Click-to-open and highlight-to-copy looked mutually exclusive: enabling mouse
reporting makes the terminal hand drags to the program and stop drawing its own
selection, so text selection needed an Option-drag to bypass capture.

They're only exclusive if the program refuses to do the work. The program does
receive press, motion and release -- enough to tell a click from a drag, and to
render the selection itself. So:

  press              arms a maybe-drag; nothing is actioned yet
  motion while held  it's a drag -> extend + draw the selection
  release, no motion it was a click -> select the row and open it on GitHub
  release, dragged   copy the selected text; never open

Dragging back onto the press cell still counts as a drag, so a wandering click
can't launch a browser.

highlightFrame() reverse-videos the selected cells of the already-composed
frame. The span is stripped before being reversed, because an SGR reset nested
inside it would cancel the reverse attribute partway through; stripping removes
only zero-width escapes, so cell widths -- and the layout -- are untouched.
TestHighlightFrame_PreservesTextAndWidth pins that invariant.

selectedText() reads the same frame, so what gets copied is exactly what was
highlighted. View() has a value receiver and cannot write to the model, hence
frameBuf is a *string.

The highlight is dropped on the next press, on any keystroke, and on wheel
scroll -- all three move what sits under it.

`defaults.mouseMode: none` still releases the mouse entirely for anyone who
prefers the terminal's native selection.
…ects

Single-click-to-open made every click a browser launch, which is the wrong
default for a list you navigate with the mouse. Opening now needs intent:

  click the "#1234"    open the PR
  click the CI icon    open <pr>/checks
  click the review icon open <pr>/files
  click the comments   open the conversation
  double-click a row   open the PR
  click anywhere else  select the row, nothing more

Row sub-regions are marked as their own bubblezone zones and tested before the
row-wide zone, since they mean something more specific than "this row". The
"#1234" is not a table cell -- it lives inside renderExtendedTitle's top line --
so only that substring is marked, not the whole line. Compact rows have no
extended title and so no number zone; they still open on double-click.

Bubbletea reports presses and releases but no click count, so the double-click
window is measured in registerRowClick(). A completed pair is consumed, so a
third click starts a fresh one rather than re-opening. Clicking an icon does not
arm a pairing. lastClickRow starts at -1 because a zero value would read as
"row 0 was just clicked".

rowClickAction() is kept pure: the gesture -> URL mapping is unit-tested with
exact URLs, which executing a tea.Cmd could not do without launching a browser.
The divider between the list and a bottom-docked preview is that preview's
one-row top border, so its screen row is just the tabs plus the current list
height. Press it to resize instead of starting a text selection; drag moves it;
release persists it.

Both panes are floored at one row. A zero-height pane would be unrecoverable
with the mouse, because its own divider would then be unreachable.

Remembered across runs in $XDG_STATE_HOME/gh-dash/layout.json (else
~/.local/state/...), written via temp-file + rename so a crash mid-write cannot
leave a half-parsed layout behind. A corrupt file reads as "nothing remembered".

On what "each instance" means: no per-window identity survives a restart -- a
tty is recycled, a terminal session id is minted fresh. So the key is what
actually defines the dashboard: its config path and the repo it was launched in.
Export GH_DASH_INSTANCE to give one particular window its own remembered layout.

A height saved on a taller terminal is clamped on load, so a big preview can
never swallow the whole list.

Only bottom-docked previews have an up/down divider; a right-docked one would
need a left/right drag, which this does not add.
Extends mouse activation to the remaining chrome:

  footer PRs/Issues/Notifications  switch straight to that view
  footer "? help"                  toggle the full help
  preview Overview/Activity/...    switch the detail tab

Footer view buttons switch directly rather than cycling: switchSelectedView's
tail is factored into applyViewChange(), shared by the keyboard cycle and a new
setSelectedView(target) that a click uses. Clicking the active view is a no-op.

The preview tabs live in the same carousel component as the top section tabs, so
they mark cleanly. But the body dispatch was `switch carousel.SelectedItem()`
against the raw labels -- embedding zone markers in the items would have broken
that string compare, so the switch is now keyed on carousel.Cursor() and
SelectedTab() returns the clean label by index. Preview-tab clicks are ignored
when the sidebar is closed.

footer.MarkZone/prview marking go through common.MarkZone, so rendering in a
test (or any path before zone.NewGlobal) degrades to an unmarked, unclickable
string instead of panicking.
Two bugs, one user-visible glitch ("clicking ? help doesn't always activate,
and closing doesn't give the space back").

1. The mouse handler returned early, skipping the syncProgramContext() at the
   end of Update that the keyboard help path falls through to. So ShowAll
   flipped but the section viewports were never resized: the frame grew, the
   content wasn't restored on close, and -- because the frame grew -- the
   "? help" bar moved out from under the pointer. The click branch now runs both
   syncMainContentDimensions() and syncProgramContext(), matching the keyboard.

2. Help rendered below the status bar, and the footer is bottom-anchored, so
   expanding help pushed the bar (and the "? help" toggle, and the view switcher)
   upward -- a moving target you can't click twice in the same place. Help now
   renders above the bar, pinning the bar to the bottom line.

The view-switcher click had the same early-return gap and gets the same
syncProgramContext() fix.

Tests pin both: the "? help" zone's row is unchanged across expand, and the list
and preview heights are byte-restored after open+close.
…glitch)

Any pointer motion while the button was down turned a click into a drag. A
trackpad tap almost always jitters a cell or two, so an ordinary row click would
instead copy a stray character and leave a lingering one- or two-cell
reverse-video selection stuck on the row -- the "split highlight" that stayed put
until the next click. And because it was a "drag", the click never selected or
opened the row.

Add a drag threshold: motion within 2 columns / 1 row of the press point stays a
click, so the release selects the row cleanly with nothing copied and no overlay
left behind. Past the threshold it is a real drag and selects text as before.
Vertical counts double because rows are two cells tall. Once a drag, always a
drag, so wandering back near the anchor can't re-arm a click.

Proven first with a failing probe: a 1-cell jitter produced dragged=true,
sel.active=true, copied="@". Tests now cover 1-2 cell jitter in each axis
(selects, no copy, no overlay) and a real drag (selects + copies).
Clicking a row left the previously selected row still highlighted (and the newly
clicked row split), until an async fetch "caught up" a few seconds later.

BuildRows() bakes the selected styling into each row's cells. The section
rebuilds its rows at the tail of its Update, on any message it processes -- which
the keyboard nav path reaches (via updateCurrentSection at the end of the model
Update) but this early-returning mouse-click path skipped. So a click moved the
viewport cursor and re-ran SyncViewPortContent over rows whose selection was
baked for the OLD cursor: the old row kept its highlight and the new row was
drawn with cell-level selection over stale body content (the visible "split").

Forward the release msg through updateCurrentSection after selecting, exactly as
the keyboard path does, so the section rebuilds immediately. table.Update ignores
mouse events, so this only triggers the rebuild.
The logo computed its version only from module build info, which a plain
`go build` doesn't populate, so a source build always read "dev". Add a
tui.Version var (default "dev") set at build time via -ldflags, used as the
logo's version with the build-info path kept as fallback. The fork's build
recipe injects `git describe`, so the logo shows which local build is running.
A fork build is ahead of the upstream release, so its version always differs
from the latest tag -- the nag would show permanently, and following it would
throw away the fork. Skip it when the version looks local (dev, contains
"local"/"dirty", or a git-describe "-g<sha>" suffix).
"v4.25.0-local.2" ran off to the right of the two-line logo. Split it at the
first hyphen so it stacks as "v4.25.0" / "-local.2" beside the logo, bottom
line aligned to the logo bottom. Only the first hyphen wraps, so a git-describe
suffix still stays two lines.
refetchIntervalMinutes is whole-minute granularity, so intervals under a minute
were impossible. Add refetchIntervalSeconds, which takes precedence when set,
and drive the refresh tick off the effective seconds value. Minutes still work
unchanged; 0 on both disables auto-refresh.
Local fork documentation (mouse nav, clickable chrome, divider persistence,
sub-minute refresh, version string, build/install, surrounding tooling). Kept
separate from upstream README.md so the project readme is untouched.
Move FORK_NOTES.md into tooling/ and add the reproducible setup scripts next to
it, so the fork is self-contained (clone + tooling/setup.sh):

- setup.sh          — idempotent orchestrator (build, install, font/profile/launcher, ghd-repo link)
- build.sh          — versioned build; derives the repo from its own path
- setup-terminal.sh — macOS: Nerd Font + gh-dash Terminal profile (AppleScript) + ghd launcher
- ghd.zsh           — the ghd launcher function, sourced from ~/.zshrc
- ghd-repo.py       — per-repo tab manager (was ~/.local/lib), symlinked to ~/.bin/ghd-repo

Under tooling/, not docs/: upstream's docs/ is the Starlight source for the
gh-dash.dev website, not a general docs folder. All scripts are machine-agnostic
(no hard-coded paths) since this is a public repo.
By default gh-dash opens URLs in the OS default browser. Add
defaults.urlOpenCommand: when set, it's rendered as a text/template with
{{.URL}} and run via `sh -c`, so links can be routed to a specific browser or
profile — e.g. a Chrome profile already signed in to the right GitHub account:

  urlOpenCommand: '"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --profile-directory="Profile 3" "{{.URL}}"'

New internal/urlopen package wraps the choice; the PR-open paths (o / click /
double-click / number / icons) route through it. Empty command = unchanged
default-browser behavior. stdout/stderr discarded so launcher noise can't
corrupt the TUI.
A refresh (interval or manual) rebuilds each section via FetchAllSections, which
creates fresh section models — cursor 0 — so the selection snapped to the top
row every refresh.

Stash the selected item's URL when the section is rebuilt, and reselect that URL
once the fresh data lands (in the *FetchedMsg handler, after SetRows). URL is the
key because it's on every RowData and unique per item (notifications have no
number). If the item is gone (merged/closed) the cursor is left as-is.

Added on section.BaseModel (SetPendingSelection/RestoreSelection) so all three
views share it: PRs, issues, notifications. issuessection.FetchAllSections now
takes the old sections (it didn't) so it can read the previous selection.
An "instance" scopes persisted state (layout + selection). Resolution:
  --instance <file>  use that config
  --instance <dir>   use <dir>/.gh-dash/config.yml (created if missing)
  --instance .       use $PWD/.gh-dash/config.yml (created if missing)
  (none) + local     use $PWD/.gh-dash/config.yml if it already exists
  (none)             global config; state keyed implicitly by the launch dir

So two dashboards launched from different directories are automatically distinct
instances without naming them; --instance . bootstraps a hidden per-dir config.
The instance name is synthetic (project dir + hash of the config path).
The wheel-to-selection mapping felt reversed on macOS natural scrolling. Default
is now tuned for it: a two-finger-down gesture (reported as a wheel-up event
there) moves the selection DOWN the list. Set defaults.mouseWheelReverse: true
for a classic mouse / non-natural setup.
Remembers the selected item's URL per section per instance in the state file,
and restores it on the next launch: the section re-selects that URL once its
first fetch lands (reusing the refresh-restore path). Saved on every selection
change, scoped by the instance key (launch dir or --instance name). Section
interface gains SetPendingSelection.
…id cache

Launching the Chrome binary with --profile-directory to hand off a URL takes
~4s; AppleScript to the running Chrome is ~0.2s but cannot target a profile.
Cache the target profile window id and add a tab to it; use the slow binary
launch only to bootstrap/repair the cache. Point urlOpenCommand at it.
When urlOpenCommand is set, open the first configured repo's PR list on launch
so the target browser/profile window is ready — and open-url.sh's window-id
cache is warm — before the first real open, making it instant. It also leaves a
useful PR-list tab open.

Runs as a tea.Cmd (its own goroutine), so it never blocks startup. The URL is
derived from the instance's PR sections: first repo: found, else first org:,
else the user's PR inbox. Set defaults.disableBrowserPrewarm: true to skip it.
The startup pre-warm opened a fresh PR-list tab on every launch. Now it sets
GH_DASH_PREWARM=1 (urlopen.Prewarm), and open-url.sh skips when its cached
window is still open — so a tab is only opened when the cache is actually cold
(first launch, or after that Chrome window was closed). Frequent restarts no
longer accumulate tabs.
The list section can't render shorter than its search box, the table
header, and one empty/loading row (common.MinListHeight = 7). The layout,
though, let the preview take all but one row, so a high divider — or
expanding help, which steals ~16 rows from the content area and the clamp
took them all out of the list — squeezed the list below its floor. The
section then overflowed its allotment and pushed the footer, and with it
the "? help" toggle, off the bottom of the alt-screen.

Reserve MinListHeight for the list when sizing the preview, in both the
drag conversion and the dimension sync, so the list never goes below what
it can render.

The regression test drives a real WindowSizeMsg (so the section renders its
true styled minimum) with a preview tall enough to fill its allotment, and
checks for the "donate" indicator — which only appears in the status bar,
not in the expanded help keymap where "? help" also appears.
@tomatg1
tomatg1 force-pushed the mouse-nav-and-ux branch from 427023b to f9b9d70 Compare July 10, 2026 18:54
On a repo with a merge queue but "Allow auto-merge" disabled, `gh pr merge`
fails ("Auto merge is not allowed for this repository") — GitHub routes
queue-add through the enablePullRequestAutoMerge mutation, which that
setting rejects. So `m` couldn't enqueue at all on such repos.

Add `defaults.mergeQueueRepos` ("owner/name", or "*"). For a listed repo,
`m` drives the NATIVE merge queue instead of `gh pr merge` — the same path
as the web "Merge when ready" button, needing no extra permission:

- PR out of the queue -> enqueuePullRequest(input:{pullRequestId})
- PR already queued  -> dequeuePullRequest(input:{id})   (asymmetric field)

The action (enqueue/dequeue/merge) is chosen at press-time from the row's
IsInMergeQueue state; the confirmation prompt and the row's merge-queue icon
reflect it, flipped optimistically and confirmed on the next fetch. Both
mutations verified end-to-end (enqueue -> position 1 -> dequeue) against a
live merge-queue repo.

Also make fireTask capture the command's stderr so a failure surfaces gh's
real message in the footer instead of a bare "exit status 1".
tomatg1 added 6 commits July 10, 2026 23:30
Clicking a PR link added the tab to the right Chrome profile window but
focus landed on the previously-focused window instead of the one that just
opened the link. Two AppleScript bugs in add_tab:

- It referenced the window via a `repeat with w in windows` loop variable,
  which is positional (item i). `activate` reorders the windows, so the
  later `set index of w to 1` raised whatever window had slid into slot i --
  the one you were last on. Reference `window id <n>` instead; it stays
  pinned to the intended window across the reorder.
- It set the window index before `activate`; after `make new tab` that
  doesn't stick. Set the index last, after activate.

Also select the newly added tab so the link is what shows. Verified: with a
different Chrome window focused first, the target window is Chrome's front
window on every run.
If the target window already has the URL open, select that tab rather than
appending a second one. A tab matches when its URL, minus any #fragment, is
the target or a sub-page of it -- so the PR you're already reading on /files,
or scrolled to an #issuecomment anchor, is reused instead of duplicated. The
"/" and "?" bounds keep ...dlvhdr/pull/627 from matching .../pull/6270.

Verified live: re-opening the same PR adds no tab; a tab sitting on the PR's
/files view is reused for the base PR URL; a different PR still opens its own
tab.
…active

`activate` is app-level: it pulls every Chrome window (all profiles) above
other apps. The script called it unconditionally, so opening a link while
the target profile window was already Chrome's front window needlessly
surfaced the other-profile windows.

Skip `activate`/`set index` when the target is already `front window`.
Verified: with the target already front, opening a link leaves the whole
window z-order unchanged; when another window is front the target is still
raised (earlier focus fix intact). `make new tab` still brings Chrome
forward on its own (Chrome's behavior), but no other window comes above the
target, and the reuse path doesn't activate Chrome at all.
A refresh rebuilds each PR section from scratch (fresh table, cursor 0)
carrying the old rows, and only restored the selection once the fetch
landed. In between, the list rendered with the cursor on the top row --
jarring, and a keypress in that window (e.g. `m`) acted on the wrong PR.

Place the cursor immediately in FetchAllSections: build the carried-over
rows and set the cursor to the previously-selected index right away, so the
first frame after a refresh is already correct. The pending-selection URL
stays armed so restoreSelection re-pins the cursor after the fresh, possibly
reordered data arrives.

Regression test drives FetchAllSections + the pre-fetch render and asserts
the cursor stays on the selected PR (fails before the fix: cursor 0).
Extend the refresh selection-carryover fix to the issues and notifications
sections. Both rebuilt from scratch on refresh and only restored the
selection once the fetch landed: notifications flashed the cursor to the top
(it carried the old rows), and issues blanked entirely (it didn't carry rows
at all) before repopulating. Either way a keypress mid-refresh could act on
the wrong item.

FetchAllSections now carries the old rows AND cursor over immediately
(issues also gains the row copy it was missing), so the first frame after a
refresh is already correct. The pending-selection URL stays armed to re-pin
by URL once the fresh, possibly reordered data lands.

Regression tests for both fail before the fix (cursor 0 / empty list) and
pass after.
Two related PR-list visibility features.

Merge-queue state: the list is fetched via GitHub's search API, which does
not populate isInMergeQueue (an expensive computed field — false, like
mergeStateStatus is UNKNOWN), so the queue icon never triggered. Enrich the
list after each fetch from the authoritative repository.mergeQueue.entries,
scoped to defaults.mergeQueueRepos (one query per distinct repo+base present,
so an org: tab doesn't fan out). data/mergequeue.go.

Recently-merged: a PR filtered by is:open vanishes the instant it merges.
Add defaults.showMergedFor (Go duration, empty/"0" = off), overridable per
section. When set, each PR section runs a second search — its filter with
is:open→is:merged plus merged:>=<now-window> — and appends the results
(merged icon). First page only, so pagination doesn't re-append.

Unit tests cover MergedSinceQuery and ResolveMergedWindow (global default +
per-section override, off cases). The enrichment query and merged-since
search were verified live against the API.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant