Mouse navigation, clickable chrome, and UX fixes - #1
Open
tomatg1 wants to merge 35 commits into
Open
Conversation
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
force-pushed
the
mouse-nav-and-ux
branch
from
July 10, 2026 18:54
427023b to
f9b9d70
Compare
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".
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Local fork enhancements on top of upstream
v4.25.0(49f37e4) — full mousenavigation, 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 vetclean,-raceclean.Mouse capture, made configurable
d4a6ff9defaults.mouseMode(cellMotiondefault /allMotion/none).nonereleases the mouse so the terminal's native text selection works.Mouse navigation
6815868clickable section tabs + rows, wheel scrolling (upstream Add mouse support: resize preview, click tabs & rows dlvhdr/gh-dash#722)5d6569bdrag to select text, copy on release2ac3d2fopen on double-click or clicking the#number; a bare click only selects; CI/review/comments icons open their sub-pages6762327clickable footer view switcher (PRs/Issues/Notifications),? helptoggle, and preview detail tabs (Overview/Activity/Commits/Checks/Files)Layout
9265f8fdrag the preview divider to resize, remembered across runs (per config+repo, in$XDG_STATE_HOME/gh-dash/layout.json)Fixes
a63ef7fdon'tlog.Fatalwhen launched outside a git repo (upstream v4.25.x regression — still present on upstreamv4.25.1)54b35c6? helpclick reflows immediately and restores pane sizes on close; help renders above the status bar so the toggle target doesn't movea3c069aa sloppy click (trackpad jitter) no longer registers as a drag — it selects cleanly instead of leaving a stray selectionf6080e4selecting a row clears the previously-selected row's highlight immediately (was stale until an async fetch)Version string
bdc6be0logo shows an-ldflags-injectable version instead of "dev"5354d7dsuppress the "Update available!" nag on local/fork builds1b1177fwrap the version at the first hyphen, bottom-justified beside the logoNotes
ghextension is installed as a local symlink to this checkout; build with the versioned-ldflags(see the local build script) so the logo showsgit describerather than "dev".MaxWidthstyle so a marker can't be clipped.