diff --git a/internal/app/app_input_mouse.go b/internal/app/app_input_mouse.go index bbf8703c..2e27ef14 100644 --- a/internal/app/app_input_mouse.go +++ b/internal/app/app_input_mouse.go @@ -91,9 +91,35 @@ func (a *App) handleMouseMsg(msg tea.Msg) tea.Cmd { // routeMouseWheel routes mouse wheel events to the appropriate pane. func (a *App) routeMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { - // Route wheel input by keyboard focus; child models currently ignore wheel - // while unfocused. + if a.prefixPaletteContainsPoint(msg.X, msg.Y) { + // Palette wheel input is currently non-interactive; consume it so hidden + // panes cannot scroll or steal focus while prefix mode is active. + return nil + } + targetPane := a.focusedPane + // Modal overlays and toast overlays do not consume wheel today, so preserve + // focused-pane routing instead of hit-testing obscured panes beneath them. + if !a.overlayVisible() && !a.toastCoversPoint(msg.X, msg.Y) { + // Route wheel input by pointer target when possible so hovered panes + // scroll without requiring a prior click. Fall back to keyboard focus + // when the pointer is outside interactive pane geometry. + hoverPane, hasTarget := a.paneForPoint(msg.X, msg.Y) + if hasTarget { + // Dashboard wheel handling activates rows, so do not retarget passive + // hover wheel input into it from another pane. + if hoverPane != messages.PaneDashboard || a.focusedPane == messages.PaneDashboard { + if a.canRetargetWheelToPane(hoverPane) { + targetPane = hoverPane + } + } + } + } + + var focusCmd tea.Cmd + if targetPane != a.focusedPane { + focusCmd = a.focusPaneOnWheel(targetPane) + } switch targetPane { case messages.PaneDashboard: @@ -104,7 +130,7 @@ func (a *App) routeMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { } newDashboard, cmd := a.dashboard.Update(adjusted) a.dashboard = newDashboard - return cmd + return common.SafeBatch(focusCmd, cmd) case messages.PaneCenter: adjusted := msg if a.layout != nil { @@ -112,11 +138,11 @@ func (a *App) routeMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { } newCenter, cmd := a.center.Update(adjusted) a.center = newCenter - return cmd + return common.SafeBatch(focusCmd, cmd) case messages.PaneSidebarTerminal: newTerm, cmd := a.sidebarTerminal.Update(msg) a.sidebarTerminal = newTerm - return cmd + return common.SafeBatch(focusCmd, cmd) case messages.PaneSidebar: adjusted := msg if a.layout != nil { @@ -124,11 +150,24 @@ func (a *App) routeMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { } newSidebar, cmd := a.sidebar.Update(adjusted) a.sidebar = newSidebar - return cmd + return common.SafeBatch(focusCmd, cmd) } return nil } +func (a *App) canRetargetWheelToPane(pane messages.PaneType) bool { + switch pane { + case messages.PaneCenter: + return a.center != nil && a.center.CanConsumeWheel() + case messages.PaneSidebar: + return a.sidebar != nil && a.sidebar.CanConsumeWheel() + case messages.PaneSidebarTerminal: + return a.sidebarTerminal != nil && a.sidebarTerminal.CanConsumeWheel() + default: + return false + } +} + // routeMouseMotion routes mouse motion events to the appropriate pane. func (a *App) routeMouseMotion(msg tea.MouseMotionMsg) tea.Cmd { // Keep left-button drag motion bound to the pane focused on mouse-down. diff --git a/internal/app/app_input_mouse_test.go b/internal/app/app_input_mouse_test.go index c2ecab3c..56093310 100644 --- a/internal/app/app_input_mouse_test.go +++ b/internal/app/app_input_mouse_test.go @@ -1,13 +1,18 @@ package app import ( + "fmt" + "strings" "testing" tea "charm.land/bubbletea/v2" "github.com/andyrewlee/amux/internal/config" + "github.com/andyrewlee/amux/internal/data" + "github.com/andyrewlee/amux/internal/git" "github.com/andyrewlee/amux/internal/messages" "github.com/andyrewlee/amux/internal/ui/center" + "github.com/andyrewlee/amux/internal/ui/common" "github.com/andyrewlee/amux/internal/ui/dashboard" "github.com/andyrewlee/amux/internal/ui/layout" "github.com/andyrewlee/amux/internal/ui/sidebar" @@ -136,3 +141,326 @@ func TestRouteMouseClick_PrefixPaletteConsumesClicks(t *testing.T) { t.Fatalf("expected focus to remain dashboard, got %v", app.focusedPane) } } + +func TestRouteMouseWheel_PrefixPaletteConsumesWheel(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + app := &App{ + prefixActive: true, + width: 140, + height: 40, + layout: l, + focusedPane: messages.PaneDashboard, + dashboard: dashboard.New(), + center: center.New(&config.Config{}), + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + } + + sidebarStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + l.CenterWidth() + l.GapX() + _, paletteHeight := viewDimensions(app.renderPrefixPalette()) + if paletteHeight <= 0 { + t.Fatal("expected prefix palette to render a non-zero height") + } + y := app.height - paletteHeight + if y < l.TopGutter() { + y = l.TopGutter() + } + + cmd := app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: sidebarStartX + 3, + Y: y, + }) + if cmd != nil { + t.Fatal("expected palette wheel input to be consumed without command") + } + if app.focusedPane != messages.PaneDashboard { + t.Fatalf("expected focus to remain dashboard, got %v", app.focusedPane) + } +} + +func TestRouteMouseWheel_FocusesHoveredSidebarAndScrollsChanges(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + app := &App{ + layout: l, + dashboard: dashboard.New(), + center: center.New(&config.Config{}), + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + } + app.updateLayout() + + ws := data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + app.sidebar.SetWorkspace(ws) + status := &git.StatusResult{Clean: false} + for i := 0; i < 40; i++ { + status.Unstaged = append(status.Unstaged, git.Change{ + Path: fmt.Sprintf("file-%02d.txt", i), + Kind: git.ChangeModified, + }) + } + app.sidebar.SetGitStatus(status) + + app.focusPane(messages.PaneCenter) + + before := app.sidebar.ContentView() + if strings.Contains(before, "file-20.txt") { + t.Fatalf("expected file-20.txt to start off-screen") + } + + sidebarStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + l.CenterWidth() + l.GapX() + wheel := tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: sidebarStartX + 3, + Y: l.TopGutter() + 2, + } + for i := 0; i < 24; i++ { + app.routeMouseWheel(wheel) + } + + after := app.sidebar.ContentView() + if app.focusedPane != messages.PaneSidebar { + t.Fatalf("expected wheel to focus sidebar, got %v", app.focusedPane) + } + if !strings.Contains(after, "file-20.txt") { + t.Fatalf("expected hovered sidebar wheel scroll to reveal later files; got view:\n%s", after) + } +} + +func TestRouteMouseWheel_HoverSidebarTerminalSkipsFocusSideEffects(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + app := &App{ + layout: l, + dashboard: dashboard.New(), + center: center.New(&config.Config{}), + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + } + app.updateLayout() + + ws := data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + app.sidebarTerminal.SetWorkspacePreview(ws) + app.focusPane(messages.PaneCenter) + + sidebarStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + l.CenterWidth() + l.GapX() + topPaneHeight, _ := sidebarPaneHeights(l.Height()) + cmd := app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: sidebarStartX + 3, + Y: l.TopGutter() + topPaneHeight + 2, + }) + if cmd != nil { + t.Fatal("expected empty sidebar terminal hover wheel to avoid terminal-creation command") + } + if app.focusedPane != messages.PaneCenter { + t.Fatalf("expected focus to remain center for empty sidebar terminal, got %v", app.focusedPane) + } +} + +func TestRouteMouseWheel_HoverCenterPreservesDetachedReattach(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + cfg := &config.Config{ + Assistants: map[string]config.AssistantConfig{ + "codex": {Command: "codex"}, + }, + } + centerModel := center.New(cfg) + app := &App{ + layout: l, + dashboard: dashboard.New(), + center: centerModel, + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + } + app.updateLayout() + + ws := data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + ws.OpenTabs = []data.TabInfo{{ + Assistant: "codex", + Name: "Codex", + SessionName: "amux-test-detached", + Status: "detached", + }} + centerModel.SetWorkspace(ws) + if cmd := centerModel.RestoreTabsFromWorkspace(ws); cmd != nil { + t.Fatal("expected detached tab restore to be synchronous") + } + app.focusPane(messages.PaneDashboard) + + centerStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + cmd := app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: centerStartX + 3, + Y: l.TopGutter() + 2, + }) + if cmd == nil { + t.Fatal("expected wheel focus retarget into center to queue detached-tab reattach") + } + if app.focusedPane != messages.PaneCenter { + t.Fatalf("expected wheel to retarget focus to center, got %v", app.focusedPane) + } +} + +func TestRouteMouseWheel_HoverDashboardDoesNotRetargetFromFocusedPane(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + app := &App{ + layout: l, + dashboard: dashboard.New(), + center: center.New(&config.Config{}), + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + } + app.updateLayout() + app.focusPane(messages.PaneCenter) + + cmd := app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: l.LeftGutter() + 1, + Y: l.TopGutter() + 2, + }) + if cmd != nil { + t.Fatal("expected dashboard hover wheel to avoid activating dashboard rows") + } + if app.focusedPane != messages.PaneCenter { + t.Fatalf("expected focus to remain center, got %v", app.focusedPane) + } +} + +func TestRouteMouseWheel_HoverEmptyCenterDoesNotStealFocus(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + app := &App{ + layout: l, + dashboard: dashboard.New(), + center: center.New(&config.Config{}), + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + } + app.updateLayout() + app.focusPane(messages.PaneDashboard) + + centerStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + _ = app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: centerStartX + 3, + Y: l.TopGutter() + 2, + }) + if app.focusedPane != messages.PaneDashboard { + t.Fatalf("expected focus to remain dashboard for empty center hover, got %v", app.focusedPane) + } +} + +func TestRouteMouseWheel_DialogOverlayPreventsRetarget(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + app := &App{ + layout: l, + dashboard: dashboard.New(), + center: center.New(&config.Config{}), + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + dialog: common.NewConfirmDialog("quit", "Quit", "Confirm?"), + } + app.dialog.Show() + app.updateLayout() + app.focusPane(messages.PaneDashboard) + + centerStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + _ = app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: centerStartX + 3, + Y: l.TopGutter() + 2, + }) + if app.focusedPane != messages.PaneDashboard { + t.Fatalf("expected dialog overlay to preserve dashboard focus, got %v", app.focusedPane) + } +} + +func TestRouteMouseWheel_ToastOverlayPreventsRetarget(t *testing.T) { + l := layout.NewManager() + l.Resize(140, 40) + + cfg := &config.Config{ + Assistants: map[string]config.AssistantConfig{ + "codex": {Command: "codex"}, + }, + } + centerModel := center.New(cfg) + app := &App{ + width: 140, + height: 40, + layout: l, + dashboard: dashboard.New(), + center: centerModel, + sidebar: sidebar.NewTabbedSidebar(), + sidebarTerminal: sidebar.NewTerminalModel(), + toast: common.NewToastModel(), + } + app.updateLayout() + + ws := data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + ws.OpenTabs = []data.TabInfo{{ + Assistant: "codex", + Name: "Codex", + SessionName: "amux-test-toast-detached", + Status: "detached", + }} + centerModel.SetWorkspace(ws) + if cmd := centerModel.RestoreTabsFromWorkspace(ws); cmd != nil { + t.Fatal("expected detached tab restore to be synchronous") + } + app.focusPane(messages.PaneDashboard) + + _ = app.toast.ShowInfo(strings.Repeat("toast ", 12)) + toastView := app.toast.View() + if toastView == "" { + t.Fatal("expected visible toast") + } + toastWidth, toastHeight := viewDimensions(toastView) + toastX := (app.width - toastWidth) / 2 + toastY := app.height - 2 + + centerStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + centerEndX := centerStartX + l.CenterWidth() + x := toastX + if x < centerStartX { + x = centerStartX + } + if x >= centerEndX { + t.Fatal("expected toast to overlap the center pane in test setup") + } + y := toastY + if y >= l.TopGutter()+l.Height() { + t.Fatal("expected toast to overlap pane height in test setup") + } + if !app.toastCoversPoint(x, y) { + t.Fatal("expected wheel point to land inside toast overlay") + } + if toastHeight < 1 { + t.Fatal("expected toast height to be positive") + } + + cmd := app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: x, + Y: y, + }) + if cmd != nil { + t.Fatal("expected toast-covered wheel input to avoid retarget side effects") + } + if app.focusedPane != messages.PaneDashboard { + t.Fatalf("expected toast overlay to preserve dashboard focus, got %v", app.focusedPane) + } +} diff --git a/internal/app/app_ui.go b/internal/app/app_ui.go index c887b4ec..bf0e1bb5 100644 --- a/internal/app/app_ui.go +++ b/internal/app/app_ui.go @@ -10,12 +10,17 @@ import ( "github.com/andyrewlee/amux/internal/ui/common" ) -// focusPane changes focus to the specified pane -func (a *App) focusPane(pane messages.PaneType) tea.Cmd { +// setFocusedPane updates pane focus state without triggering pane-specific side effects. +func (a *App) setFocusedPane(pane messages.PaneType) { a.focusedPane = pane // Keep focus transitions fail-safe for partially initialized App instances // used in lightweight tests. a.syncPaneFocusFlags() +} + +// focusPane changes focus to the specified pane +func (a *App) focusPane(pane messages.PaneType) tea.Cmd { + a.setFocusedPane(pane) switch pane { case messages.PaneCenter: // Seamless UX: when center regains focus, attempt reattach for detached active tab. @@ -31,6 +36,17 @@ func (a *App) focusPane(pane messages.PaneType) tea.Cmd { return nil } +// focusPaneOnWheel updates focus for hover-wheel routing and preserves only the +// center-pane detached-tab reattach behavior. It intentionally skips other +// focus-time side effects such as lazy sidebar terminal creation. +func (a *App) focusPaneOnWheel(pane messages.PaneType) tea.Cmd { + a.setFocusedPane(pane) + if pane == messages.PaneCenter && a.center != nil { + return a.center.ReattachActiveTabIfDetached() + } + return nil +} + // focusPaneLeft moves focus one pane to the left, respecting layout visibility. func (a *App) focusPaneLeft() tea.Cmd { switch a.focusedPane { diff --git a/internal/app/app_view_overlays.go b/internal/app/app_view_overlays.go index 1ba30df7..27c8ac9b 100644 --- a/internal/app/app_view_overlays.go +++ b/internal/app/app_view_overlays.go @@ -58,7 +58,7 @@ func (a *App) composeOverlays(canvas *lipgloss.Canvas) { } // Toast notification - if a.toast.Visible() { + if a.toast != nil && a.toast.Visible() { toastView := a.toast.View() if toastView != "" { toastWidth := lipgloss.Width(toastView) @@ -214,7 +214,7 @@ func (a *App) overlayVisible() bool { } func (a *App) toastCoversPoint(x, y int) bool { - if a == nil || !a.toast.Visible() { + if a == nil || a.toast == nil || !a.toast.Visible() { return false } toastView := a.toast.View() diff --git a/internal/ui/center/model_lifecycle.go b/internal/ui/center/model_lifecycle.go index f10e756f..ef9b9eab 100644 --- a/internal/ui/center/model_lifecycle.go +++ b/internal/ui/center/model_lifecycle.go @@ -33,6 +33,7 @@ func (m *Model) Focus() { } m.focused = true m.setActiveTerminalCursorVisibility(true) + m.syncActiveDiffViewerFocus(true) } // Blur removes focus. @@ -42,6 +43,7 @@ func (m *Model) Blur() { } m.focused = false m.setActiveTerminalCursorVisibility(false) + m.syncActiveDiffViewerFocus(false) } // Focused returns whether the center pane is focused. @@ -171,6 +173,23 @@ func (m *Model) setActiveTerminalCursorVisibility(visible bool) { tab.cachedRestrictCursor = false } +func (m *Model) syncActiveDiffViewerFocus(focused bool) { + tabs := m.getTabs() + activeIdx := m.getActiveTabIdx() + if activeIdx < 0 || activeIdx >= len(tabs) { + return + } + tab := tabs[activeIdx] + if tab == nil || tab.isClosed() { + return + } + tab.mu.Lock() + defer tab.mu.Unlock() + if tab.DiffViewer != nil { + tab.DiffViewer.SetFocused(focused) + } +} + // Close cleans up all resources. func (m *Model) Close() { for _, tabs := range m.tabsByWorkspace { diff --git a/internal/ui/center/model_lifecycle_focus_test.go b/internal/ui/center/model_lifecycle_focus_test.go new file mode 100644 index 00000000..43cd2dd6 --- /dev/null +++ b/internal/ui/center/model_lifecycle_focus_test.go @@ -0,0 +1,37 @@ +package center + +import ( + "testing" + + "github.com/andyrewlee/amux/internal/config" + "github.com/andyrewlee/amux/internal/data" + "github.com/andyrewlee/amux/internal/ui/diff" +) + +func TestFocusSyncsActiveDiffViewerFocus(t *testing.T) { + m := New(&config.Config{}) + ws := data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + m.SetWorkspace(ws) + + dv := &diff.Model{} + dv.SetFocused(false) + tab := &Tab{ + ID: generateTabID(), + Name: "diff", + Workspace: ws, + DiffViewer: dv, + } + wsID := string(ws.ID()) + m.tabsByWorkspace[wsID] = []*Tab{tab} + m.activeTabByWorkspace[wsID] = 0 + + m.Focus() + if !dv.Focused() { + t.Fatal("expected active diff viewer to become focused with center pane") + } + + m.Blur() + if dv.Focused() { + t.Fatal("expected active diff viewer to blur with center pane") + } +} diff --git a/internal/ui/center/model_wheel.go b/internal/ui/center/model_wheel.go new file mode 100644 index 00000000..9a492db7 --- /dev/null +++ b/internal/ui/center/model_wheel.go @@ -0,0 +1,37 @@ +package center + +// CanConsumeWheel reports whether the active center tab can meaningfully handle +// mouse-wheel input. Detached chat tabs count so wheel-driven focus can trigger +// reattach; otherwise a tab must have scrollable diff or terminal content. +func (m *Model) CanConsumeWheel() bool { + if m == nil { + return false + } + tabs := m.getTabs() + activeIdx := m.getActiveTabIdx() + if len(tabs) == 0 || activeIdx < 0 || activeIdx >= len(tabs) { + return false + } + tab := tabs[activeIdx] + if tab == nil || tab.isClosed() { + return false + } + + tab.mu.Lock() + defer tab.mu.Unlock() + + detached := tab.Detached + reattachInFlight := tab.reattachInFlight + isChat := m.isChatTabLocked(tab) + + if detached && !reattachInFlight && isChat { + return true + } + if tab.DiffViewer != nil { + return tab.DiffViewer.CanConsumeWheel() + } + if tab.Terminal != nil { + return tab.Terminal.MaxViewOffset() > 0 + } + return false +} diff --git a/internal/ui/diff/wheel.go b/internal/ui/diff/wheel.go new file mode 100644 index 00000000..eeed5b50 --- /dev/null +++ b/internal/ui/diff/wheel.go @@ -0,0 +1,10 @@ +package diff + +// CanConsumeWheel reports whether the diff viewer has enough content for +// mouse-wheel scrolling to have an effect. +func (m *Model) CanConsumeWheel() bool { + if m == nil { + return false + } + return m.maxScroll() > 0 +} diff --git a/internal/ui/sidebar/wheel.go b/internal/ui/sidebar/wheel.go new file mode 100644 index 00000000..25e700fb --- /dev/null +++ b/internal/ui/sidebar/wheel.go @@ -0,0 +1,56 @@ +package sidebar + +// CanConsumeWheel reports whether the active sidebar tab has meaningful wheel +// scroll content. This avoids hover-wheel focus steals from empty panes. +func (m *TabbedSidebar) CanConsumeWheel() bool { + if m == nil { + return false + } + switch m.activeTab { + case TabChanges: + return m.changes.canConsumeWheel() + case TabProject: + return m.projectTree.canConsumeWheel() + default: + return false + } +} + +// CanConsumeWheel reports whether the active terminal has scrollback to view. +func (m *TerminalModel) CanConsumeWheel() bool { + if m == nil { + return false + } + tabs := m.getTabs() + activeIdx := m.getActiveTabIdx() + if len(tabs) == 0 || activeIdx < 0 || activeIdx >= len(tabs) { + return false + } + tab := tabs[activeIdx] + if tab == nil || tab.State == nil { + return false + } + tab.State.mu.Lock() + defer tab.State.mu.Unlock() + return tab.State.VTerm != nil && tab.State.VTerm.MaxViewOffset() > 0 +} + +func (m *Model) canConsumeWheel() bool { + if m == nil || m.gitStatus == nil || m.gitStatus.Clean || len(m.displayItems) == 0 { + return false + } + selectable := 0 + for _, item := range m.displayItems { + if !item.isHeader { + selectable++ + } + } + return selectable > 1 +} + +func (m *ProjectTree) canConsumeWheel() bool { + if m == nil || m.workspace == nil || len(m.flatNodes) == 0 { + return false + } + return len(m.flatNodes) > 1 +} diff --git a/internal/ui/sidebar/wheel_test.go b/internal/ui/sidebar/wheel_test.go new file mode 100644 index 00000000..9fd476da --- /dev/null +++ b/internal/ui/sidebar/wheel_test.go @@ -0,0 +1,66 @@ +package sidebar + +import ( + "testing" + + "github.com/andyrewlee/amux/internal/data" + "github.com/andyrewlee/amux/internal/git" +) + +func TestChangesCanConsumeWheelWithShortSelectableList(t *testing.T) { + m := New() + m.SetSize(80, 20) + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: []git.Change{ + {Path: "a.go", Kind: git.ChangeModified}, + {Path: "b.go", Kind: git.ChangeModified}, + }, + }) + + if !m.canConsumeWheel() { + t.Fatal("expected short changes list with multiple files to consume wheel") + } +} + +func TestChangesCanConsumeWheelIgnoresHeaderOnlyOverflow(t *testing.T) { + m := New() + m.SetSize(80, 1) + m.SetGitStatus(&git.StatusResult{ + Clean: false, + Unstaged: []git.Change{ + {Path: "only.go", Kind: git.ChangeModified}, + }, + }) + + if m.canConsumeWheel() { + t.Fatal("expected single-file changes list to ignore header-only overflow") + } +} + +func TestProjectTreeCanConsumeWheelWithShortList(t *testing.T) { + tree := NewProjectTree() + tree.SetSize(80, 20) + tree.workspace = data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + tree.flatNodes = []*ProjectTreeNode{ + {Name: "root", Path: "/tmp/repo/feature", IsDir: true}, + {Name: "main.go", Path: "/tmp/repo/feature/main.go", IsDir: false}, + } + + if !tree.canConsumeWheel() { + t.Fatal("expected short project tree with multiple nodes to consume wheel") + } +} + +func TestProjectTreeCannotConsumeWheelWithSingleNode(t *testing.T) { + tree := NewProjectTree() + tree.SetSize(80, 1) + tree.workspace = data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") + tree.flatNodes = []*ProjectTreeNode{ + {Name: "root", Path: "/tmp/repo/feature", IsDir: true}, + } + + if tree.canConsumeWheel() { + t.Fatal("expected single-node project tree not to consume wheel") + } +}