diff --git a/CHANGELOG.md b/CHANGELOG.md index db4e692..d4369b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ pre-1.0). ### Added +- Mouse support on every screen: the wheel scrolls the pane under the pointer + (lists scroll by moving the selection), a left click selects the row it + lands on and focuses the pane it hit, and clicking the menu's selected + entry launches it. Dragging across a diff preview selects display lines — + highlighted as you drag — and releasing copies them to the clipboard, + ANSI-stripped and without borders or gutters (OSC 52 fallback over SSH). + `RIFT_MOUSE=off` disables tracking for terminals where you prefer native + text selection (most terminals also bypass tracking with shift-click/drag). + ### Fixed - `rift diff --watch` live-reloads the browser as the working tree changes — diff --git a/CLAUDE.md b/CLAUDE.md index 4da817e..f62e407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,6 @@ design intent only — do not assume the packages or tools exist: - **Checkpoints:** shadow-commit snapshots on hidden refs (`internal/checkpoint`) - **Risk classification** in review — today `internal/review` only tracks reviewed marks and extracts test cases - **Inline agent annotations:** `rift annotate` writing to a JSON store next to the reviewed marks (`.git/rift/annotations.json`), rendered in the diff preview and picked up by `--watch`. File-based on purpose — no daemon/socket (see the design doc's Future Considerations) -- **Mouse support:** wheel scroll + click-to-select via bubbletea mouse events; must keep terminal text selection workable (see design doc) - **Moved-line diff view:** a config option swapping the git engine's flags to `--color-moved=zebra --color-moved-ws=allow-indentation-change` (mutually exclusive with the default `--word-diff=color` — git silently ignores `--color-moved` under word-diff, so it can't be a combined default; see `displayFlags` in `internal/diff`) ## Workflow diff --git a/README.md b/README.md index 1869df2..54670b8 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ A new cohort of review-first diff viewers — [hunk](https://github.com/modem-de | Transient — invoke, act, return to shell | ✅ | ✅ | ❌ | ✅ | — | — | | Single static binary | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | | Inline agent annotations | 🔜 | ✅ | ❌ | ❌ | ❌ | ❌ | -| Mouse support | 🔜 | ✅ | ✅ | ❌ | — | — | +| Mouse support | ✅ | ✅ | ✅ | ❌ | — | — | ✅ yes · ❌ no · — not applicable · 🔜 planned. delta and difftastic are diff renderers/pagers rather than browsers, so several rows don't apply — rift uses difftastic as its diff engine and falls back to a built-in line diff without it. @@ -167,10 +167,15 @@ Every work screen (`diff`, `log`, `stash`, `stage`, and the tests lens) shares t The `menu` launchpad is a plain list: `⏎` selects, `/` filters, and `esc`/`q` quit. Destructive actions (`stash` pop/drop, `log` cherry-pick/revert) confirm inline with `y`/`n`. +**Mouse** + +The mouse works everywhere the keyboard does: the wheel scrolls the pane under the pointer (over a list it moves the selection), a click selects the row it lands on and focuses the pane it hit, and in the `menu` a click on the selected entry launches it. Drag across a diff preview to select lines — they highlight as you drag, and releasing copies them to the clipboard (ANSI-stripped, no borders or gutters; OSC 52 fallback over SSH). Set `RIFT_MOUSE=off` if you'd rather keep the terminal's native text selection — and note most terminals bypass mouse tracking with shift-click/drag either way. + ### Environment - `RIFT_THEME=light|dark` — force the color palette instead of detecting the terminal's background. Unset, rift asks the terminal and falls back to the dark palette when there's no reply. - `RIFT_ICONS=ascii` (or `none`) — suppress the Nerd Font file-type icons for terminals without a patched font; the icon column drops out cleanly. +- `RIFT_MOUSE=off` — disable mouse tracking (wheel/click), keeping the terminal's native text selection. - `NO_COLOR` — disable all ANSI color. - `VISUAL` / `EDITOR` — the editor `o` opens (falling back to `vi`). diff --git a/git-flux-design.md b/git-flux-design.md index 60dec49..488a19e 100644 --- a/git-flux-design.md +++ b/git-flux-design.md @@ -1279,8 +1279,6 @@ The following are recorded for future exploration, not V1 scope. They become int **Moved-line diff view (config).** git's `--color-moved=zebra` classifies relocated code and renders it in distinct colors — answering "moved or rewritten?" during refactor review. It cannot ship as part of the default git view because git silently ignores `--color-moved` when `--word-diff` is active (verified empirically), and word-level emphasis is the better everyday default. Ship it as a config option (e.g. `diff.git_view = "words" | "moved"` in config.toml) rather than a UI toggle — one best-effort default, configurable when someone asks. -**Mouse support.** Click to select a row, click a pane to focus it, wheel to scroll the preview — bubbletea already delivers the events. The care point is that enabling terminal mouse tracking captures the pointer, breaking native text selection (shift-click escape hatches vary by terminal emulator), and copy-from-the-diff matters in a review tool. So: wheel scrolling and click-to-select on by default, everything else conservative, and a config/env switch to disable tracking entirely. - **Agent orchestration (`git flux agent`).** A dedicated subcommand for spawning agent worktrees with task metadata, monitoring progress across a dashboard, and managing the review→merge lifecycle. This would include `.git-flux-agent.toml` files for provenance, agent-aware status bars, and pre-flight merge checks. Worth building once the parallel-agent-worktree pattern stabilizes and we understand what orchestration agents actually need vs. what they handle themselves. **MCP server mode (`git flux mcp`).** Exposing git-flux as a Model Context Protocol server (stdio + HTTP transport) so agents can call tools like `flux_diff`, `flux_review_state`, and `flux_conflict_check` via structured protocol instead of shelling out. This is compelling but the composable `--json` CLI already serves most agent needs. MCP becomes valuable if agents need live subscriptions (e.g., "notify me when review state changes") or if the ecosystem converges on MCP as the standard agent↔tool protocol. diff --git a/internal/tui/layout.go b/internal/tui/layout.go index cd39e08..d61e294 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -1,5 +1,7 @@ package tui +import tea "charm.land/bubbletea/v2" + // CollapsedListWidth is the width of the list pane when the diff pane is // active and the list collapses to a narrow strip. const CollapsedListWidth = 12 @@ -59,3 +61,45 @@ func ComputeSplitLayout(width, height int, collapsed bool, minList, maxList int) } return l } + +// ScreenView wraps rendered screen content in the standard full-screen +// tea.View: alt-screen on, and mouse tracking requested unless the user +// opted out (see MouseEnabled). Every top-level screen builds its view here +// so terminal modes stay consistent across them. +func ScreenView(content string) tea.View { + v := tea.NewView(content) + v.AltScreen = true + if MouseEnabled() { + v.MouseMode = tea.MouseModeCellMotion + } + return v +} + +// WheelDelta maps a wheel message to a vertical scroll direction: -1 up, +// 1 down, 0 for horizontal wheel events (which the screens don't use). +func WheelDelta(msg tea.MouseWheelMsg) int { + switch msg.Button { + case tea.MouseWheelUp: + return -1 + case tea.MouseWheelDown: + return 1 + } + return 0 +} + +// ClickedRow maps a click on a list's inner row (0-based, panel borders +// already subtracted) to the item index it lands on, through the same window +// the list renders with (see ListWindow). ok is false when the click misses — +// a border, past the last item — or lands on the already-selected item, so +// callers treat it as "selection changed". +func ClickedRow(row, innerH, selected, total int) (int, bool) { + if row < 0 || row >= innerH || total == 0 { + return 0, false + } + offset, _ := ListWindow(selected, total, innerH) + idx := offset + row + if idx >= total || idx == selected { + return 0, false + } + return idx, true +} diff --git a/internal/tui/log/model.go b/internal/tui/log/model.go index eb28d83..c88e4be 100644 --- a/internal/tui/log/model.go +++ b/internal/tui/log/model.go @@ -142,13 +142,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.updateDrilled(dm.msg) } - if key, ok := msg.(tea.KeyPressMsg); ok && m.drilling { - return m.updateDrilled(key) - } - // The drilled tests view can open an editor (`o`); its close message must reach - // that view (to re-read the edited file), not the hidden commit list below. - if _, ok := msg.(tui.EditorClosedMsg); ok && m.drilling { - return m.updateDrilled(msg) + // Input (keys, mouse) and editor-close messages belong to whichever view is + // visible: while drilled they must reach the drilled view, not the hidden + // commit list — a mouse event landing there would silently move its + // selection, and a later esc/cherry-pick/revert would act on the drifted + // commit. + switch msg.(type) { + case tea.KeyPressMsg, tea.MouseMsg, tui.EditorClosedMsg: + if m.drilling { + return m.updateDrilled(msg) + } } switch msg := msg.(type) { diff --git a/internal/tui/menu/model.go b/internal/tui/menu/model.go index b4c684a..b9d09a1 100644 --- a/internal/tui/menu/model.go +++ b/internal/tui/menu/model.go @@ -75,6 +75,8 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) case tea.KeyPressMsg: return m.handleKey(msg) case tea.WindowSizeMsg: @@ -89,6 +91,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// handleMouse maps mouse input onto the menu list: the wheel moves the +// selection, a click selects the row under the pointer, and a click on the +// already-selected row activates it (the menu is a launcher, so a click means +// "choose this"). +func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.showHelp || !m.ready { + return m, nil + } + switch msg := msg.(type) { + case tea.MouseWheelMsg: + m.moveSelection(tui.WheelDelta(msg)) // horizontal wheel maps to 0: a no-op + return m, nil + case tea.MouseClickMsg: + if msg.Button != tea.MouseLeft { + return m, nil + } + // Rows render un-windowed from the panel's first inner line — and the + // panel clips at its inner height, so a click must land on a row that + // is both real and visible (a border/footer click on a short terminal + // must not select, let alone launch, a clipped-away entry). + idx := msg.Y - tui.HeaderRows - 1 + if idx < 0 || idx >= len(m.filtered) || idx >= m.contentHeight()-2 { + return m, nil + } + if idx == m.selectedIdx { + return m, m.activate(idx) + } + m.selectedIdx = idx + return m, nil + } + return m, nil +} + +// contentHeight is the vertical space between header and footer — the panel's +// outer height. The render and the click hit-test both derive from it, so the +// two can't desync. +func (m Model) contentHeight() int { + return m.height - tui.HeaderRows - tui.FooterRows +} + +// activate launches the command at idx — the single path shared by the enter +// key and a click on the selected row. +func (m Model) activate(idx int) tea.Cmd { + cmd := m.filtered[idx] + return func() tea.Msg { return SelectedMsg{Command: cmd.Name} } +} + func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit @@ -122,10 +171,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": if len(m.filtered) > 0 { - cmd := m.filtered[m.selectedIdx] - return m, func() tea.Msg { - return SelectedMsg{Command: cmd.Name} - } + return m, m.activate(m.selectedIdx) } case "up", "k": m.moveSelection(-1) @@ -193,14 +239,12 @@ func (m *Model) moveSelection(delta int) { func (m Model) View() tea.View { if !m.ready { - return tea.NewView("Loading...") + return tui.ScreenView("Loading...") } if m.showHelp { - contentH := m.height - tui.HeaderRows - tui.FooterRows - v := tea.NewView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH)) - v.AltScreen = true - return v + contentH := m.contentHeight() + return tui.ScreenView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH)) } var items strings.Builder @@ -215,7 +259,7 @@ func (m Model) View() tea.View { items.WriteString(row + "\n") } - contentH := m.height - tui.HeaderRows - tui.FooterRows + contentH := m.contentHeight() panel := tui.Panel("commands", "", items.String(), m.width, contentH, true, tui.Scrollbar{}) header := tui.Header("", "a composable fuzzy git tool", m.width) @@ -230,7 +274,5 @@ func (m Model) View() tea.View { footer = tui.Footer(m.width, fmt.Sprintf("%d/%d", m.selectedIdx+1, len(m.filtered)), menuHints) } - v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, header, panel, footer)) - v.AltScreen = true - return v + return tui.ScreenView(lipgloss.JoinVertical(lipgloss.Left, header, panel, footer)) } diff --git a/internal/tui/splitlist.go b/internal/tui/splitlist.go index cb83c96..e8e6a4f 100644 --- a/internal/tui/splitlist.go +++ b/internal/tui/splitlist.go @@ -107,6 +107,7 @@ type SplitList[T any] struct { anchored bool // the preview has been scrolled to the selection's PreviewLine spinner spinner.Model + peek bool // reading layout: the strip collapsed to a one-row peek (⇥/⏎) loading bool // a preview is being loaded for the current selection showHelp bool // the keybinding overlay is open flash string // transient footer message (e.g. "copied …"), cleared on nav @@ -115,6 +116,13 @@ type SplitList[T any] struct { cache map[string]string // rendered previews, keyed by cfg.CacheKey reqID int // increments per preview request; stale results are dropped + // Mouse drag selection over the preview, in display-line indices. anchor is + // -1 when no selection is in progress; dragging turns true on the first + // motion event, so a plain click never highlights or copies. + selAnchor int + selEnd int + dragging bool + width, height int ready bool } @@ -122,13 +130,14 @@ type SplitList[T any] struct { // NewSplitList builds a SplitList for the given items. func NewSplitList[T any](cfg SplitConfig[T], items []T) SplitList[T] { return SplitList[T]{ - cfg: cfg, - items: items, - filtered: items, - viewport: viewport.New(), - filter: NewFilterInput(), - spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), - cache: map[string]string{}, + cfg: cfg, + items: items, + filtered: items, + viewport: viewport.New(), + filter: NewFilterInput(), + spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), + cache: map[string]string{}, + selAnchor: -1, } } @@ -155,6 +164,7 @@ func (m SplitList[T]) requestPreview() (SplitList[T], tea.Cmd) { m.prevErr = nil // a new selection clears any previous load error m.flash = "" // and any transient message (e.g. "copied …") m.anchored = false // a new selection re-anchors its preview + m.clearDrag() // and any in-progress mouse selection reqID := m.reqID // Every selection change signals the parent so it cancels the previous item's // stream; CacheHit tells it the content is already shown, so start no new load. @@ -393,6 +403,8 @@ func (m SplitList[T]) SelectedKey() string { func (m SplitList[T]) Update(msg tea.Msg) (SplitList[T], tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.ready = true @@ -483,12 +495,13 @@ func (m SplitList[T]) handleKey(msg tea.KeyPressMsg) (SplitList[T], tea.Cmd) { switch msg.String() { case "tab": - m.active = 1 - m.active - return m.relayout() + target := 1 - m.active + m.peek = target == splitPreviewPane + return m.focusPane(target) case "enter": if m.active == splitListPane { - m.active = splitPreviewPane - return m.relayout() + m.peek = true + return m.focusPane(splitPreviewPane) } case "up", "k": return m.navigate(-1) @@ -572,6 +585,153 @@ func (m SplitList[T]) stepList(delta int) (SplitList[T], tea.Cmd) { return m.requestPreview() } +// handleMouse routes a mouse event by position: the wheel scrolls whichever +// pane the pointer is over (the preview by lines, the list by selection), and +// a left click selects the row under the pointer and focuses the pane it hit. +// Modal states (help overlay, an inline confirm prompt) ignore the mouse the +// same way they capture the keyboard; wheel events still work while filtering +// since the filter only captures text input. +func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { + if m.showHelp || m.prompt != "" || !m.ready { + return m, nil + } + pos := msg.Mouse() + previewH, navH := m.stackLayout() + listTop, previewTop := HeaderRows, HeaderRows+navH + inList := pos.Y >= listTop && pos.Y < previewTop // empty range when there is no strip + inPreview := pos.Y >= previewTop && pos.Y < previewTop+previewH + + switch msg := msg.(type) { + case tea.MouseWheelMsg: + if inList { + // A horizontal wheel maps to delta 0, which stepList no-ops. + return m.stepList(WheelDelta(msg)) + } + if inPreview { + // The viewport handles wheel messages natively (vertical and + // horizontal), same as the keyboard scrolling path. + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + return m, nil + case tea.MouseClickMsg: + if msg.Button != tea.MouseLeft { + return m, nil + } + switch { + case inList: + m.clearDrag() + // Resolve the clicked row against the layout the user clicked on, + // then focus — focusing can expand a one-row peek into the full + // strip, which would re-window the rows under the pointer. + sel, cmd := m.selectRow(pos.Y-listTop-1, navH-2) + focused, fcmd := sel.focusPane(splitListPane) + return focused, tea.Batch(cmd, fcmd) + case inPreview: + // Anchor a possible drag selection at the pressed display line. Focus + // waits for the release: focusing can collapse the strip and shift + // the content under the pointer mid-drag. + m.selAnchor = m.previewLineAt(pos.Y, previewTop) + m.selEnd = m.selAnchor + return m, nil + } + case tea.MouseMotionMsg: + // Cell-motion tracking only reports motion while a button is held, so + // this is a drag: extend the selection to the line under the pointer. + if m.selAnchor >= 0 && msg.Button == tea.MouseLeft && inPreview { + m.dragging = true + m.selEnd = m.previewLineAt(pos.Y, previewTop) + } + return m, nil + case tea.MouseReleaseMsg: + if m.selAnchor < 0 { + return m, nil + } + if !m.dragging { + // A clean click (press + release, no motion): focus the preview. + m.clearDrag() + return m.focusPane(splitPreviewPane) + } + a, b := m.selAnchor, m.selEnd + m.clearDrag() + return m.copySelection(a, b) + } + return m, nil +} + +// clearDrag abandons any in-progress mouse selection. +func (m *SplitList[T]) clearDrag() { + m.selAnchor, m.selEnd, m.dragging = -1, -1, false +} + +// previewLineAt maps a screen row inside the preview pane to its display-line +// index (accounting for the panel's top border and the scroll offset), clamped +// to the content; -1 when there is nothing to select. +func (m SplitList[T]) previewLineAt(y, previewTop int) int { + lines := m.vim.Lines() + if len(lines) == 0 { + return -1 + } + return clampIndex(m.viewport.YOffset()+y-previewTop-1, len(lines)) +} + +// copySelection copies the drag-selected display lines to the clipboard, +// ANSI-stripped and right-trimmed — the mouse counterpart of `y`, for lifting +// code out of a diff without the borders and gutters a terminal-native +// selection would grab. +func (m SplitList[T]) copySelection(a, b int) (SplitList[T], tea.Cmd) { + lines := m.vim.Lines() + if len(lines) == 0 || a < 0 || b < 0 { + return m, nil + } + if a > b { + a, b = b, a + } + a, b = clampIndex(a, len(lines)), clampIndex(b, len(lines)) + stripped := make([]string, 0, b-a+1) + for _, l := range lines[a : b+1] { + stripped = append(stripped, strings.TrimRight(ansi.Strip(l), " ")) + } + var cmd tea.Cmd + _, cmd = YankToClipboard(strings.Join(stripped, "\n")) + m.flash = fmt.Sprintf("copied %d lines", b-a+1) + if b == a { + m.flash = "copied 1 line" + } + return m, cmd +} + +// focusPane switches the focused pane through the same relayout path as the +// keyboard, so the width-keyed-cache contract lives in one place. Focusing the +// list always leaves the peek layout (clicking the peek row expands the strip); +// focusing the preview never enters it — the collapse stays a keyboard gesture +// (callers set peek for ⇥/⏎). +func (m SplitList[T]) focusPane(p splitPane) (SplitList[T], tea.Cmd) { + wasPeek := m.peek + if p == splitListPane { + m.peek = false + } + if m.active == p && m.peek == wasPeek { + return m, nil + } + m.active = p + return m.relayout() +} + +// selectRow selects the item at the given inner list row (0-based, borders +// already subtracted), mapping through the same window the list rendered with +// (innerH is the clicked layout's inner strip height). Clicks on the border or +// past the last item keep the current selection. +func (m SplitList[T]) selectRow(row, innerH int) (SplitList[T], tea.Cmd) { + idx, ok := ClickedRow(row, innerH, m.selected, len(m.filtered)) + if !ok { + return m, nil + } + m.selected = idx + return m.requestPreview() +} + func (m SplitList[T]) applyFilter() (SplitList[T], tea.Cmd) { // Keep the selection on the same item when it survives the new filter (matched // by the same identity key SetItemsSelecting uses); reset to the top otherwise. @@ -610,9 +770,11 @@ func (m SplitList[T]) stackLayout() (previewH, navH int) { if contentH < 2*panelMin { return contentH, 0 } - // Reading: the strip shrinks to a one-row peek of the current item, the - // focused preview takes the rest. - if m.active == splitPreviewPane { + // Reading (entered with ⇥/⏎): the strip shrinks to a one-row peek of the + // current item, the focused preview takes the rest. A mouse click focuses + // without collapsing — re-layouting the screen under the pointer is + // jarring — so this keys off peek, not focus. + if m.peek { return contentH - panelMin, panelMin } capPct := m.cfg.NavFraction @@ -870,7 +1032,29 @@ func (m SplitList[T]) previewHeader() string { // previewBodyView is the viewport content plus its scrollbar. The current file // is shown in the legend, not the body — see currentSection / content. func (m SplitList[T]) previewBodyView() (string, Scrollbar) { - return m.viewport.View(), ScrollbarFor(&m.viewport) + body := m.viewport.View() + if m.selAnchor >= 0 && m.dragging { + body = highlightRows(body, m.viewport.YOffset(), m.selAnchor, m.selEnd) + } + return body, ScrollbarFor(&m.viewport) +} + +// highlightRows renders the drag selection: visible rows whose display-line +// index falls in [a, b] get reverse video, re-asserted after any SGR reset +// inside the line so styled diff content stays highlighted end to end. +func highlightRows(body string, yOffset, a, b int) string { + if a > b { + a, b = b, a + } + lines := strings.Split(body, "\n") + for i := range lines { + if line := yOffset + i; line >= a && line <= b { + l := strings.ReplaceAll(lines[i], "\x1b[0m", "\x1b[0m\x1b[7m") + l = strings.ReplaceAll(l, "\x1b[m", "\x1b[m\x1b[7m") + lines[i] = "\x1b[7m" + l + "\x1b[27m" + } + } + return strings.Join(lines, "\n") } // footerView renders the bottom bar: a parent-set prompt (e.g. an inline y/n @@ -997,7 +1181,5 @@ func (m SplitList[T]) content() string { // TeaView wraps the rendered screen in a full-screen tea.View. Parent models // delegate their View() to this. func (m SplitList[T]) TeaView() tea.View { - v := tea.NewView(m.View()) - v.AltScreen = true - return v + return ScreenView(m.View()) } diff --git a/internal/tui/splitlist_mouse_test.go b/internal/tui/splitlist_mouse_test.go new file mode 100644 index 0000000..8627b08 --- /dev/null +++ b/internal/tui/splitlist_mouse_test.go @@ -0,0 +1,98 @@ +package tui + +import ( + "strings" + "testing" +) + +func testList(items []string) SplitList[string] { + cfg := SplitConfig[string]{ + Row: func(s string, _ int, _ bool) string { return s }, + Match: func(s string) string { return s }, + } + m := NewSplitList(cfg, items) + m.width, m.height = 80, 24 + m.ready = true + return m +} + +func TestSelectRow(t *testing.T) { + m := testList([]string{"a", "b", "c", "d", "e", "f", "g", "h"}) + _, navH := m.stackLayout() + innerH := navH - 2 + if innerH < 3 { + t.Fatalf("test needs a strip of at least 3 rows, got %d", innerH) + } + + // Un-scrolled window: row r is item r. + got, _ := m.selectRow(2, innerH) + if got.selected != 2 { + t.Errorf("selectRow(2) selected %d, want 2", got.selected) + } + + // Selection past the window scrolls the list; a click maps through the + // same offset the rows rendered with. + m.selected = len(m.items) - 1 + offset, _ := ListWindow(m.selected, len(m.items), innerH) + if offset == 0 { + t.Fatalf("expected a scrolled window for selected=%d innerH=%d", m.selected, innerH) + } + got, _ = m.selectRow(0, innerH) + if got.selected != offset { + t.Errorf("scrolled selectRow(0) selected %d, want %d", got.selected, offset) + } + + // Border and past-the-end clicks keep the selection. + for _, row := range []int{-1, innerH, len(m.items) + 5} { + got, _ = m.selectRow(row, innerH) + if got.selected != m.selected { + t.Errorf("selectRow(%d) moved selection to %d, want unchanged %d", row, got.selected, m.selected) + } + } +} + +func TestFocusPaneRelayouts(t *testing.T) { + m := testList([]string{"a", "b", "c"}) + _, surveyNav := m.stackLayout() + + // A mouse click focuses the preview without entering the peek layout — + // re-layouting the screen under the pointer is jarring. + m, _ = m.focusPane(splitPreviewPane) + if m.active != splitPreviewPane { + t.Fatal("focusPane did not switch panes") + } + if _, nav := m.stackLayout(); nav != surveyNav { + t.Errorf("click focus must not collapse the strip: %d -> %d", surveyNav, nav) + } + + // The keyboard gesture (⇥/⏎) sets peek before focusing: that collapses. + m.peek = true + if _, nav := m.stackLayout(); nav >= surveyNav { + t.Errorf("peek layout should collapse the strip: %d -> %d", surveyNav, nav) + } + + // Focusing the list (click on the peek row) leaves the peek layout. + m, _ = m.focusPane(splitListPane) + if m.peek { + t.Error("focusing the list should clear the peek layout") + } + if _, nav := m.stackLayout(); nav != surveyNav { + t.Errorf("strip should expand when the list takes focus: got %d, want %d", surveyNav, nav) + } +} + +func TestHighlightRows(t *testing.T) { + body := "one\n\x1b[31mtwo\x1b[0m tail\nthree" + got := highlightRows(body, 10, 11, 11) // only the middle visible row (line 11) + lines := strings.Split(got, "\n") + if strings.Contains(lines[0], "\x1b[7m") || strings.Contains(lines[2], "\x1b[7m") { + t.Errorf("unselected rows must not be highlighted: %q", got) + } + if !strings.HasPrefix(lines[1], "\x1b[7m") { + t.Errorf("selected row missing reverse video: %q", lines[1]) + } + // The reset inside the styled segment must re-assert reverse video. + if !strings.Contains(lines[1], "\x1b[0m\x1b[7m") { + t.Errorf("reset inside selected row not re-asserted: %q", lines[1]) + } +} diff --git a/internal/tui/stage/model.go b/internal/tui/stage/model.go index b4b6a16..b58e19b 100644 --- a/internal/tui/stage/model.go +++ b/internal/tui/stage/model.go @@ -50,8 +50,9 @@ type Model struct { // hunkCache memoizes rendered hunk sets by path+width so navigation and filter // keystrokes don't re-run the whole difftastic pipeline. Invalidated per path on // stage/unstage and editor close, and wholesale on a width change. - hunkCache map[string][]displayHunk - diffReqID int // increments per diff load; stale hunkDiffsMsg results are dropped + hunkCache map[string][]displayHunk + diffReqID int // increments per diff load; stale hunkDiffsMsg results are dropped + cancelDiff context.CancelFunc // stops the superseded in-flight diff load's subprocesses viewport viewport.Model filter textinput.Model @@ -140,6 +141,8 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) case tea.KeyPressMsg: return m.handleKey(msg) case tui.EditorClosedMsg: @@ -239,9 +242,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.activePane == filePane { window := m.layout().ContentHeight - 2 if next, ok := m.vim.HandleListKey(msg, m.selectedIdx, len(m.filteredFiles), window); ok { - if next == m.selectedIdx { - return m, nil // consumed with no move (e.g. a pending 'g') - } + // selectTo no-ops when next == selectedIdx (e.g. a pending 'g'). return m.selectTo(next) } } @@ -262,15 +263,12 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case "tab": if m.activePane == filePane { - m.activePane = diffPane - } else { - m.activePane = filePane + return m.focusPane(diffPane) } - return m.applyLayout() + return m.focusPane(filePane) case "enter": if m.activePane == filePane { - m.activePane = diffPane - return m.applyLayout() + return m.focusPane(diffPane) } case "up", "k": return m.navigate(-1) @@ -431,6 +429,74 @@ func (m Model) reloadFiles() tea.Cmd { } } +// handleMouse routes mouse input by pane: the wheel scrolls the hunk view or +// steps the file selection, a click selects the file row under the pointer and +// focuses the pane it hit. Clicks on an already-focused pane that change +// nothing are no-ops — re-running the layout would snap the diff scroll back +// to the selected hunk. The row is resolved against the layout the user +// clicked on, before any focus change re-widens the panes. +func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.showHelp || !m.ready { + return m, nil + } + pos := msg.Mouse() + l := m.layout() + inContent := pos.Y >= tui.HeaderRows && pos.Y < tui.HeaderRows+l.ContentHeight + inFiles := inContent && pos.X < l.ListWidth + inDiff := inContent && !inFiles + + switch msg := msg.(type) { + case tea.MouseWheelMsg: + if inFiles { + // A horizontal wheel maps to delta 0, which selectTo no-ops. + return m.moveSelection(tui.WheelDelta(msg)) + } + if inDiff { + // The viewport handles wheel messages natively (vertical and + // horizontal), same as the keyboard scrolling fallthrough. + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + return m, nil + case tea.MouseClickMsg: + if msg.Button != tea.MouseLeft { + return m, nil + } + switch { + case inFiles: + target, ok := tui.ClickedRow(pos.Y-tui.HeaderRows-1, l.ContentHeight-2, m.selectedIdx, len(m.filteredFiles)) + if m.activePane != filePane { + if ok { + // Apply the selection before the layout's own diff request, + // so the pane flip and the new file cost one load, not two. + m.selectedIdx = target + m.hunkIdx = 0 + } + return m.focusPane(filePane) + } + if ok { + return m.selectTo(target) + } + return m, nil + case inDiff: + return m.focusPane(diffPane) + } + } + return m, nil +} + +// focusPane switches the focused pane and re-lays-out (the split widths depend +// on focus); a click or key targeting the already-active pane is a no-op, so +// it can't snap the diff scroll back to the selected hunk. +func (m Model) focusPane(p pane) (tea.Model, tea.Cmd) { + if m.activePane == p { + return m, nil + } + m.activePane = p + return m.applyLayout() +} + func (m Model) applyLayout() (tea.Model, tea.Cmd) { l := m.layout() if l.DiffWidth != m.viewport.Width() { @@ -502,6 +568,9 @@ func (m Model) selectTo(idx int) (tea.Model, tea.Cmd) { if idx >= len(m.filteredFiles) { idx = len(m.filteredFiles) - 1 } + if idx == m.selectedIdx { + return m, nil // already there (or clamped back to the boundary); nothing to reload + } m.selectedIdx = idx m.hunkIdx = 0 // reset hunk selection on file change cmd := m.requestDiff() @@ -531,6 +600,13 @@ func (m *Model) invalidatePath(path string) { func (m *Model) requestDiff() tea.Cmd { m.diffReqID++ m.flash = "" // a new selection / reload clears any transient message + if m.cancelDiff != nil { + // A newer selection or layout supersedes the in-flight load: kill its + // subprocesses instead of letting them run to a discarded result (a + // wheel flick would otherwise pile up concurrent difftastic runs). + m.cancelDiff() + m.cancelDiff = nil + } if len(m.filteredFiles) == 0 { m.setHunks(nil) return nil @@ -540,7 +616,9 @@ func (m *Model) requestDiff() tea.Cmd { m.setHunks(hunks) return nil } - return m.loadSelectedDiff(m.diffReqID) + ctx, cancel := context.WithCancel(context.Background()) + m.cancelDiff = cancel + return m.loadSelectedDiff(ctx, m.diffReqID) } // setHunks installs a rendered hunk set and positions the viewport on the @@ -559,7 +637,7 @@ func (m *Model) setHunks(hunks []displayHunk) { } } -func (m Model) loadSelectedDiff(reqID int) tea.Cmd { +func (m Model) loadSelectedDiff(ctx context.Context, reqID int) tea.Cmd { if len(m.filteredFiles) == 0 { return nil } @@ -576,25 +654,29 @@ func (m Model) loadSelectedDiff(reqID int) tea.Cmd { raw, err := diff.RawNewFileDiff(repoRoot, f.Path) if err == nil && raw != "" { result = append(result, buildDisplayHunks( - engine, raw, f.Path, repoRoot, false, color, width-2, + ctx, engine, raw, f.Path, repoRoot, false, color, width-2, )...) } } else { raw, err := diff.RawUnifiedDiff(repoRoot, false, f.Path) if err == nil && raw != "" { result = append(result, buildDisplayHunks( - engine, raw, f.Path, repoRoot, false, color, width-2, + ctx, engine, raw, f.Path, repoRoot, false, color, width-2, )...) } raw, err = diff.RawUnifiedDiff(repoRoot, true, f.Path) if err == nil && raw != "" { result = append(result, buildDisplayHunks( - engine, raw, f.Path, repoRoot, true, color, width-2, + ctx, engine, raw, f.Path, repoRoot, true, color, width-2, )...) } } + if ctx.Err() != nil { + return nil // superseded mid-load; the result would be dropped anyway + } + // Order by new-side position so staged and unstaged hunks interleave in file // order rather than grouping by state — staging one then doesn't jump its row. sort.SliceStable(result, func(i, j int) bool { @@ -605,7 +687,7 @@ func (m Model) loadSelectedDiff(reqID int) tea.Cmd { } } -func buildDisplayHunks(engine diff.Engine, raw, path, repoRoot string, staged, color bool, width int) []displayHunk { +func buildDisplayHunks(ctx context.Context, engine diff.Engine, raw, path, repoRoot string, staged, color bool, width int) []displayHunk { fileDiffs := diff.ParseUnifiedDiff(raw) var allHunks []diff.Hunk for _, fd := range fileDiffs { @@ -615,7 +697,7 @@ func buildDisplayHunks(engine diff.Engine, raw, path, repoRoot string, staged, c return nil } base, _ := diff.BaseContent(repoRoot, staged, path) - rendered := engine.DiffHunks(context.Background(), allHunks, path, base, color, width) + rendered := engine.DiffHunks(ctx, allHunks, path, base, color, width) var result []displayHunk flatIdx := 0 @@ -708,15 +790,13 @@ func (m *Model) renderHunks() { func (m Model) View() tea.View { if !m.ready { - return tea.NewView("Loading...") + return tui.ScreenView("Loading...") } l := m.layout() if m.showHelp { - v := tea.NewView(tui.HelpView("stage", tui.HeaderContext(m.branch, m.engines.Name()), m.hints, stageNavKeys, m.width, l.ContentHeight)) - v.AltScreen = true - return v + return tui.ScreenView(tui.HelpView("stage", tui.HeaderContext(m.branch, m.engines.Name()), m.hints, stageNavKeys, m.width, l.ContentHeight)) } collapsed := m.activePane == diffPane @@ -778,9 +858,7 @@ func (m Model) View() tea.View { footer = tui.Footer(m.width, status, m.hints) } - v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, header, content, footer)) - v.AltScreen = true - return v + return tui.ScreenView(lipgloss.JoinVertical(lipgloss.Left, header, content, footer)) } func findFileIndex(files []git.StatusFile, path string) int { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 9a650ba..79e7d5f 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -503,3 +503,15 @@ func maxInt(a, b int) int { } return b } + +// MouseEnabled reports whether mouse tracking should be requested. On by +// default; RIFT_MOUSE set to off, 0, none, or false disables it for users who +// prefer the terminal's native text selection without the shift-click escape +// hatch. +func MouseEnabled() bool { + switch os.Getenv("RIFT_MOUSE") { + case "off", "0", "none", "false": + return false + } + return true +} diff --git a/internal/tui/vimnav.go b/internal/tui/vimnav.go index c1f8d91..45bb0c8 100644 --- a/internal/tui/vimnav.go +++ b/internal/tui/vimnav.go @@ -17,6 +17,10 @@ type VimNav struct { lines []string // finalized display lines (banners stripped), the render source } +// Lines returns the finalized display lines — exactly what the viewport +// renders, banner-stripped. The mouse selection reads them to copy content. +func (v *VimNav) Lines() []string { return v.lines } + // section is a file boundary in the preview: the displayed line where the file // begins, and its label (path) taken from the non-displayed "── path ──" banner. type section struct {