diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 00bc49f04..6027049f8 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -44,3 +44,4 @@ jobs: env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/docs/src/content/docs/getting-started/keybindings/preview.mdx b/docs/src/content/docs/getting-started/keybindings/preview.mdx index 5565d5950..acc1a6b7e 100644 --- a/docs/src/content/docs/getting-started/keybindings/preview.mdx +++ b/docs/src/content/docs/getting-started/keybindings/preview.mdx @@ -29,3 +29,7 @@ Press [ to move to the next tab in the preview sidebar, if one exists ## `]` - Previous Preview Tab Press ] to move to the previous tab in the preview sidebar, if one exists. + +## `P` - Reset Preview Width + +Press Shift+p to reset the preview pane width back to the configured default. diff --git a/internal/config/state.go b/internal/config/state.go new file mode 100644 index 000000000..a3ca0589e --- /dev/null +++ b/internal/config/state.go @@ -0,0 +1,79 @@ +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const StateFileName = "state.yml" +const DEFAULT_XDG_STATE_DIRNAME = ".local/state" + +// State holds runtime state that should persist between sessions +type State struct { + PreviewWidth int `yaml:"previewWidth,omitempty"` +} + +// GetStatePath returns the path to the state file +// State files are stored in $XDG_STATE_HOME (defaults to ~/.local/state) +func GetStatePath() (string, error) { + stateDir := os.Getenv("XDG_STATE_HOME") + if stateDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + stateDir = filepath.Join(homeDir, DEFAULT_XDG_STATE_DIRNAME) + } + return filepath.Join(stateDir, DashDir, StateFileName), nil +} + +// LoadState loads the state from the state file +func LoadState() (State, error) { + state := State{} + + statePath, err := GetStatePath() + if err != nil { + return state, err + } + + data, err := os.ReadFile(statePath) + if err != nil { + if os.IsNotExist(err) { + return state, nil // No state file yet, return empty state + } + return state, err + } + + err = yaml.Unmarshal(data, &state) + return state, err +} + +// SaveState saves the state to the state file +func SaveState(state State) error { + statePath, err := GetStatePath() + if err != nil { + return err + } + + // Ensure directory exists + dir := filepath.Dir(statePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := yaml.Marshal(state) + if err != nil { + return err + } + + return os.WriteFile(statePath, data, 0644) +} + +// SavePreviewWidth saves just the preview width to state +func SavePreviewWidth(width int) error { + state, _ := LoadState() // Ignore error, start fresh if needed + state.PreviewWidth = width + return SaveState(state) +} diff --git a/internal/tui/components/carousel/carousel.go b/internal/tui/components/carousel/carousel.go index b23d04de4..760e39399 100644 --- a/internal/tui/components/carousel/carousel.go +++ b/internal/tui/components/carousel/carousel.go @@ -1,14 +1,19 @@ package carousel import ( + "fmt" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + zone "github.com/lrstanley/bubblezone" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" ) +const TabZonePrefix = "carousel-tab-" + // Model defines a state for the carousel widget. type Model struct { KeyMap KeyMap @@ -24,6 +29,7 @@ type Model struct { showSeparators bool separator string styles Styles + zonePrefix string // Unique prefix for zone IDs to avoid conflicts between views content string start int @@ -168,6 +174,18 @@ func WithKeyMap(km KeyMap) Option { } } +// WithZonePrefix sets a unique prefix for zone IDs to avoid conflicts between views. +func WithZonePrefix(prefix string) Option { + return func(m *Model) { + m.zonePrefix = prefix + } +} + +// SetZonePrefix sets a unique prefix for zone IDs. +func (m *Model) SetZonePrefix(prefix string) { + m.zonePrefix = prefix +} + // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.focus { @@ -215,6 +233,7 @@ func (m Model) View() string { // UpdateSize updates the carousel size based on the previously defined // items and width. func (m *Model) UpdateSize() { + // First pass: render items with zone markers to ensure they're clickable even when truncated leftover := m.width itemsContent := "" @@ -263,6 +282,8 @@ func (m *Model) UpdateSize() { l -= lipgloss.Width(roIndicator) } + // Apply truncation to the content. Zone markers remain intact through truncation, + // ensuring items stay clickable even when partially displayed if loIndicator != "" { truncate := lipgloss.Width(itemsContent) - l + 1 itemsContent = ansi.TruncateLeft(itemsContent, truncate, "") @@ -358,6 +379,9 @@ func (m *Model) MoveRight() { m.UpdateSize() } +// renderItem renders an item with zone marking for click detection. +// Zone markers are applied to the content and persist through truncation, +// ensuring items remain clickable even when partially displayed. func (m *Model) renderItem(itemID int, maxWidth int) string { var item string if itemID == m.cursor { @@ -376,10 +400,26 @@ func (m *Model) renderItem(itemID int, maxWidth int) string { } if m.showSeparators && itemID != len(m.items)-1 { - return lipgloss.JoinHorizontal(lipgloss.Center, item, m.styles.Separator.Render(m.separator)) + item = lipgloss.JoinHorizontal(lipgloss.Center, item, m.styles.Separator.Render(m.separator)) } - return item + // Wrap the item in a zone for click detection + return zone.Mark(m.tabZoneID(itemID), item) +} + +// HandleClick checks if a mouse click event is on a tab and returns the tab index if so +// Returns -1 if no tab was clicked +func (m *Model) HandleClick(msg tea.MouseMsg) int { + for i := range m.items { + if zone.Get(m.tabZoneID(i)).InBounds(msg) { + return i + } + } + return -1 +} + +func (m *Model) tabZoneID(itemID int) string { + return fmt.Sprintf("%s%s%d", TabZonePrefix, m.zonePrefix, itemID) } func max(a, b int) int { diff --git a/internal/tui/components/carousel/carousel_test.go b/internal/tui/components/carousel/carousel_test.go new file mode 100644 index 000000000..12505dadc --- /dev/null +++ b/internal/tui/components/carousel/carousel_test.go @@ -0,0 +1,164 @@ +package carousel + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + zone "github.com/lrstanley/bubblezone" +) + +func init() { + zone.NewGlobal() +} + +func TestCarousel(t *testing.T) { + t.Run("Should create carousel with items", func(t *testing.T) { + items := []string{"Tab 1", "Tab 2", "Tab 3"} + c := New(WithItems(items), WithWidth(100)) + + if len(c.Items()) != len(items) { + t.Errorf("Expected %d items, got %d", len(items), len(c.Items())) + } + + if c.Cursor() != 0 { + t.Errorf("Expected cursor at 0, got %d", c.Cursor()) + } + }) + + t.Run("Should set cursor position", func(t *testing.T) { + items := []string{"Tab 1", "Tab 2", "Tab 3"} + c := New(WithItems(items), WithWidth(100)) + + c.SetCursor(2) + if c.Cursor() != 2 { + t.Errorf("Expected cursor at 2, got %d", c.Cursor()) + } + + // Should clamp to valid range + c.SetCursor(10) + if c.Cursor() != 2 { + t.Errorf("Expected cursor clamped to 2, got %d", c.Cursor()) + } + + c.SetCursor(-1) + if c.Cursor() != 0 { + t.Errorf("Expected cursor clamped to 0, got %d", c.Cursor()) + } + }) + + t.Run("Should move left and right", func(t *testing.T) { + items := []string{"Tab 1", "Tab 2", "Tab 3"} + c := New(WithItems(items), WithWidth(100)) + + c.SetCursor(1) + c.MoveLeft() + if c.Cursor() != 0 { + t.Errorf("Expected cursor at 0 after MoveLeft, got %d", c.Cursor()) + } + + c.MoveRight() + if c.Cursor() != 1 { + t.Errorf("Expected cursor at 1 after MoveRight, got %d", c.Cursor()) + } + + // Should not go below 0 + c.SetCursor(0) + c.MoveLeft() + if c.Cursor() != 0 { + t.Errorf("Expected cursor to stay at 0, got %d", c.Cursor()) + } + + // Should not go above max + c.SetCursor(2) + c.MoveRight() + if c.Cursor() != 2 { + t.Errorf("Expected cursor to stay at 2, got %d", c.Cursor()) + } + }) + + t.Run("Should render items with zone markers", func(t *testing.T) { + items := []string{"Tab 1", "Tab 2", "Tab 3"} + c := New(WithItems(items), WithWidth(100)) + c.UpdateSize() + + view := c.View() + if view == "" { + t.Error("Expected non-empty view") + } + + // The view should contain the items (after zone.Scan) + scanned := zone.Scan(view) + for _, item := range items { + if !strings.Contains(scanned, item) { + t.Errorf("Expected view to contain %q", item) + } + } + }) + + t.Run("HandleClick should return index for clicked tab", func(t *testing.T) { + items := []string{"Tab 1", "Tab 2", "Tab 3"} + c := New(WithItems(items), WithWidth(200)) + c.UpdateSize() + + // Render the view to set up zones + view := c.View() + _ = zone.Scan(view) + + // Click inside the first tab's zone + // Zone IDs now include the zonePrefix, so construct it the same way as tabZoneID() + zoneID := fmt.Sprintf("%s%s%d", TabZonePrefix, c.zonePrefix, 0) + z := zone.Get(zoneID) + if z.IsZero() { + t.Fatal("Expected zone to be registered") + } + msg := tea.MouseMsg{ + X: z.StartX, + Y: z.StartY, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + } + + result := c.HandleClick(msg) + if result != 0 { + t.Errorf("Expected tab index 0, got %d", result) + } + }) + + t.Run("HandleClick should return -1 for empty items", func(t *testing.T) { + c := New(WithItems([]string{}), WithWidth(100)) + c.UpdateSize() + + msg := tea.MouseMsg{ + X: 1, + Y: 1, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + } + + result := c.HandleClick(msg) + if result != -1 { + t.Errorf("Expected -1 for empty items, got %d", result) + } + }) + + t.Run("HandleClick should return -1 for click outside tabs", func(t *testing.T) { + items := []string{"Tab 1", "Tab 2", "Tab 3"} + c := New(WithItems(items), WithWidth(100)) + c.UpdateSize() + + // Click way outside any tab area + msg := tea.MouseMsg{ + X: 1000, + Y: 1000, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + } + + result := c.HandleClick(msg) + if result != -1 { + t.Errorf("Expected -1 for click outside tabs, got %d", result) + } + }) +} diff --git a/internal/tui/components/listviewport/listviewport.go b/internal/tui/components/listviewport/listviewport.go index b8c71b0fd..79efdfce9 100644 --- a/internal/tui/components/listviewport/listviewport.go +++ b/internal/tui/components/listviewport/listviewport.go @@ -4,7 +4,6 @@ import ( "time" "github.com/charmbracelet/bubbles/viewport" - "github.com/charmbracelet/lipgloss" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" @@ -75,7 +74,13 @@ func (m *Model) getNumPrsPerPage() int { } func (m *Model) ResetCurrItem() { + m.resetCurrItem() +} + +func (m *Model) resetCurrItem() { m.currId = 0 + m.topBoundId = 0 + m.bottomBoundId = 0 m.viewport.GotoTop() } @@ -121,19 +126,51 @@ func (m *Model) LastItem() int { return m.currId } +func (m *Model) SetCurrItem(index int) { + if m.NumCurrentItems == 0 { + m.resetCurrItem() + return + } + + index = utils.Max(0, utils.Min(index, m.NumCurrentItems-1)) + itemsPerPage := m.getNumPrsPerPage() + + if itemsPerPage <= 0 { + m.resetCurrItem() + m.currId = index + return + } + + if index < m.topBoundId { + diff := m.topBoundId - index + m.viewport.ScrollUp(diff * m.ListItemHeight) + m.topBoundId = index + m.bottomBoundId = utils.Min(m.topBoundId+itemsPerPage-1, m.NumCurrentItems-1) + } else if index > m.bottomBoundId { + diff := index - m.bottomBoundId + m.viewport.ScrollDown(diff * m.ListItemHeight) + m.bottomBoundId = index + m.topBoundId = utils.Max(0, m.bottomBoundId-itemsPerPage+1) + } + + m.currId = index + if m.currId == 0 && m.topBoundId > 0 { + m.topBoundId = 0 + m.bottomBoundId = utils.Min(itemsPerPage-1, m.NumCurrentItems-1) + m.viewport.GotoTop() + } +} + func (m *Model) SetDimensions(dimensions constants.Dimensions) { m.viewport.Height = max(0, dimensions.Height) m.viewport.Width = max(0, dimensions.Width) } func (m *Model) View() string { - viewport := m.viewport.View() - return lipgloss.NewStyle(). - Width(m.viewport.Width). - MaxWidth(m.viewport.Width). - Render( - viewport, - ) + // Return viewport content directly without additional Width/MaxWidth styling. + // The viewport already constrains content to its width, and applying + // Width/MaxWidth to content containing zone markers can cause visual artifacts. + return m.viewport.View() } func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { diff --git a/internal/tui/components/prview/prview.go b/internal/tui/components/prview/prview.go index 5fb00dc8c..d9af9660b 100644 --- a/internal/tui/components/prview/prview.go +++ b/internal/tui/components/prview/prview.go @@ -445,6 +445,8 @@ func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { Selected: lipgloss.NewStyle().Padding(0, 1).Bold(true), }, ) + // Set unique zone prefix based on current view to avoid zone conflicts + m.carousel.SetZonePrefix(fmt.Sprintf("%s-prview-", ctx.View)) } func (m *Model) shouldCancelComment() bool { diff --git a/internal/tui/components/section/section.go b/internal/tui/components/section/section.go index 6438aeac0..e42e5d9f7 100644 --- a/internal/tui/components/section/section.go +++ b/internal/tui/components/section/section.go @@ -127,6 +127,7 @@ func NewModel( "Loading...", false, ) + m.Table.SetSectionId(options.Id) return m } @@ -159,6 +160,7 @@ type Table interface { NumRows() int GetCurrRow() data.RowData CurrRow() int + SetCurrRow(index int) NextRow() int PrevRow() int FirstItem() int @@ -168,6 +170,7 @@ type Table interface { ResetRows() GetIsLoading() bool SetIsLoading(val bool) + HandleRowClick(msg tea.MouseMsg) int } type Search interface { @@ -289,6 +292,10 @@ func (m *BaseModel) CurrRow() int { return m.Table.GetCurrItem() } +func (m *BaseModel) SetCurrRow(index int) { + m.Table.SetCurrItem(index) +} + func (m *BaseModel) NextRow() int { return m.Table.NextItem() } @@ -305,6 +312,10 @@ func (m *BaseModel) LastItem() int { return m.Table.LastItem() } +func (m *BaseModel) HandleRowClick(msg tea.MouseMsg) int { + return m.Table.HandleClick(msg) +} + func (m *BaseModel) IsSearchFocused() bool { return m.IsSearching } diff --git a/internal/tui/components/sidebar/sidebar.go b/internal/tui/components/sidebar/sidebar.go index cb26dd094..48a24d3b3 100644 --- a/internal/tui/components/sidebar/sidebar.go +++ b/internal/tui/components/sidebar/sidebar.go @@ -1,23 +1,45 @@ package sidebar import ( - "fmt" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" "github.com/dlvhdr/gh-dash/v4/internal/tui/keys" + "github.com/dlvhdr/gh-dash/v4/internal/utils" +) + +const ( + ResizeZoneID = "sidebar-resize" + MinPreviewWidth = 30 + MaxPreviewWidth = 150 + ResizeHandleChar = "│" + ScrollbarWidth = 1 + ScrollThumbChar = "┃" + ScrollTrackChar = "║" ) +// ResizeMsg is sent when the sidebar is resized via mouse drag +type ResizeMsg struct { + NewWidth int +} + +// ResizeStartMsg indicates the start of a resize drag operation +type ResizeStartMsg struct{} + +// ResizeEndMsg indicates the end of a resize drag operation +type ResizeEndMsg struct{} + type Model struct { IsOpen bool data string viewport viewport.Model ctx *context.ProgramContext emptyState string + isResizing bool } func NewModel() Model { @@ -43,34 +65,192 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, keys.Keys.PageUp): m.viewport.HalfPageUp() } + + case tea.MouseMsg: + return m.handleMouseMsg(msg) } return m, nil } +func (m Model) handleMouseMsg(msg tea.MouseMsg) (Model, tea.Cmd) { + if !m.IsOpen || m.ctx == nil { + return m, nil + } + + // Handle scroll wheel in the sidebar area + sidebarStartX := m.ctx.ScreenWidth - m.ctx.PreviewWidth + if m.ctx.PreviewWidth <= 0 { + sidebarStartX = m.ctx.ScreenWidth - m.ctx.Config.Defaults.Preview.Width + } + if msg.X >= sidebarStartX { + switch msg.Button { + case tea.MouseButtonWheelUp: + m.viewport.LineUp(3) + return m, nil + case tea.MouseButtonWheelDown: + m.viewport.LineDown(3) + return m, nil + } + } + + // Handle resize zone interactions + if zone.Get(ResizeZoneID).InBounds(msg) { + switch msg.Action { + case tea.MouseActionPress: + if msg.Button == tea.MouseButtonLeft { + m.isResizing = true + return m, func() tea.Msg { return ResizeStartMsg{} } + } + } + } + + // Handle drag while resizing + if m.isResizing { + switch msg.Action { + case tea.MouseActionMotion: + // Calculate new width based on mouse position + // Mouse X is relative to the terminal, sidebar is on the right + // New width = ScreenWidth - MouseX + newWidth := m.ctx.ScreenWidth - msg.X + newWidth = max(newWidth, MinPreviewWidth) + newWidth = min(newWidth, MaxPreviewWidth) + // Don't let the sidebar take more than 70% of the screen + maxWidth := int(float64(m.ctx.ScreenWidth) * 0.7) + newWidth = min(newWidth, maxWidth) + return m, func() tea.Msg { return ResizeMsg{NewWidth: newWidth} } + + case tea.MouseActionRelease: + m.isResizing = false + return m, func() tea.Msg { return ResizeEndMsg{} } + } + } + + return m, nil +} + +// IsResizing returns whether a resize operation is in progress +func (m Model) IsResizing() bool { + return m.isResizing +} + +// SetResizing sets the resizing state +func (m *Model) SetResizing(resizing bool) { + m.isResizing = resizing +} + func (m Model) View() string { if !m.IsOpen { return "" } height := m.ctx.MainContentHeight - style := m.ctx.Styles.Sidebar.Root. - Height(height). - Width(m.ctx.Config.Defaults.Preview.Width). - MaxWidth(m.ctx.Config.Defaults.Preview.Width) + width := m.ctx.PreviewWidth + if width <= 0 { + width = m.ctx.Config.Defaults.Preview.Width + } + handleWidth := lipgloss.Width(ResizeHandleChar) + var content string if m.data == "" { - return style.Align(lipgloss.Center).Render( + // Content style without the left border and scrollbar + contentStyle := lipgloss.NewStyle(). + Height(height). + Width(width - handleWidth) + content = contentStyle.Align(lipgloss.Center).Render( lipgloss.PlaceVertical(height, lipgloss.Center, m.emptyState), ) + } else { + // Render scrollbar + scrollbar := m.renderScrollbar(height) + + // Note: Avoid using MaxWidth() on content that may contain zone markers + // as it can truncate them and cause visual artifacts. + viewportContent := m.viewport.View() + + // Join content and scrollbar + content = lipgloss.JoinHorizontal(lipgloss.Top, viewportContent, scrollbar) + } + + // Normalize heights so the resize handle and content align without truncation. + contentHeight := lipgloss.Height(content) + if height > contentHeight { + content = lipgloss.PlaceVertical(height, lipgloss.Top, content) + contentHeight = lipgloss.Height(content) + } + actualHeight := utils.Max(height, contentHeight) + resizeHandle := m.renderResizeHandle(actualHeight) + if contentHeight < actualHeight { + content = lipgloss.PlaceVertical(actualHeight, lipgloss.Top, content) } - return style.Render(lipgloss.JoinVertical( - lipgloss.Top, - m.viewport.View(), - m.ctx.Styles.Sidebar.PagerStyle. - Render(fmt.Sprintf("%d%%", int(m.viewport.ScrollPercent()*100))), - )) + // Join the resize handle and content horizontally + return lipgloss.JoinHorizontal(lipgloss.Top, resizeHandle, content) +} + +func (m Model) renderScrollbar(height int) string { + if height <= 0 { + return "" + } + + totalLines := m.viewport.TotalLineCount() + visibleLines := m.viewport.Height + + // If all content fits, no scrollbar needed + if totalLines <= visibleLines { + return lipgloss.NewStyle(). + Width(ScrollbarWidth). + Height(height). + Render("") + } + + // Calculate thumb size (minimum 1 line) + thumbSize := max(1, (visibleLines*height)/totalLines) + + // Calculate thumb position + scrollPercent := m.viewport.ScrollPercent() + maxThumbPos := height - thumbSize + thumbPos := int(float64(maxThumbPos) * scrollPercent) + + // Build scrollbar string + scrollbar := "" + trackStyle := lipgloss.NewStyle().Foreground(m.ctx.Theme.SecondaryBorder).Bold(true) + thumbStyle := lipgloss.NewStyle().Foreground(m.ctx.Theme.PrimaryText).Bold(true) + + for i := 0; i < height; i++ { + if i >= thumbPos && i < thumbPos+thumbSize { + scrollbar += thumbStyle.Render(ScrollThumbChar) + } else { + scrollbar += trackStyle.Render(ScrollTrackChar) + } + if i < height-1 { + scrollbar += "\n" + } + } + + return lipgloss.NewStyle(). + Width(ScrollbarWidth). + Height(height). + Render(scrollbar) +} + +func (m Model) renderResizeHandle(height int) string { + // Create a vertical line as the resize handle + handleStyle := lipgloss.NewStyle(). + Foreground(m.ctx.Theme.PrimaryBorder). + Width(1). + Height(height) + + // Build the handle string (vertical line) + handle := "" + for i := 0; i < height; i++ { + handle += ResizeHandleChar + if i < height-1 { + handle += "\n" + } + } + + return zone.Mark(ResizeZoneID, handleStyle.Render(handle)) } func (m *Model) SetContent(data string) { @@ -79,10 +259,14 @@ func (m *Model) SetContent(data string) { } func (m *Model) GetSidebarContentWidth() int { - if m.ctx.Config == nil { + if m.ctx == nil || m.ctx.Config == nil { return 0 } - return m.ctx.Config.Defaults.Preview.Width - m.ctx.Styles.Sidebar.BorderWidth + width := m.ctx.PreviewWidth + if width <= 0 { + width = m.ctx.Config.Defaults.Preview.Width + } + return width - m.ctx.Styles.Sidebar.BorderWidth } func (m *Model) ScrollToTop() { @@ -98,6 +282,6 @@ func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { return } m.ctx = ctx - m.viewport.Height = m.ctx.MainContentHeight - m.ctx.Styles.Sidebar.PagerHeight - m.viewport.Width = m.GetSidebarContentWidth() + m.viewport.Height = m.ctx.MainContentHeight + m.viewport.Width = m.GetSidebarContentWidth() - 1 // Account for resize handle } diff --git a/internal/tui/components/sidebar/sidebar_test.go b/internal/tui/components/sidebar/sidebar_test.go new file mode 100644 index 000000000..65ef2c480 --- /dev/null +++ b/internal/tui/components/sidebar/sidebar_test.go @@ -0,0 +1,215 @@ +package sidebar + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + zone "github.com/lrstanley/bubblezone" + + "github.com/dlvhdr/gh-dash/v4/internal/config" + "github.com/dlvhdr/gh-dash/v4/internal/tui/context" + "github.com/dlvhdr/gh-dash/v4/internal/tui/theme" +) + +func init() { + zone.NewGlobal() +} + +func TestSidebar(t *testing.T) { + t.Run("Should return empty string when not open", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + m.UpdateProgramContext(ctx) + m.IsOpen = false + + view := m.View() + if view != "" { + t.Errorf("Expected empty string when sidebar is closed, got %q", view) + } + }) + + t.Run("Should set and get resizing state", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + m.UpdateProgramContext(ctx) + m.IsOpen = true + + // Test SetResizing and IsResizing + if m.IsResizing() { + t.Error("Expected IsResizing to be false initially") + } + + m.SetResizing(true) + if !m.IsResizing() { + t.Error("Expected IsResizing to be true after SetResizing(true)") + } + + m.SetResizing(false) + if m.IsResizing() { + t.Error("Expected IsResizing to be false after SetResizing(false)") + } + }) + + t.Run("Should calculate new width on mouse motion during resize", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + m.UpdateProgramContext(ctx) + m.IsOpen = true + m.SetResizing(true) + + // Simulate mouse motion + newX := 60 // This should result in width = ScreenWidth - 60 = 40 + msg := tea.MouseMsg{ + X: newX, + Y: 10, + Button: tea.MouseButtonNone, + Action: tea.MouseActionMotion, + } + + _, cmd := m.Update(msg) + + if cmd == nil { + t.Error("Expected a ResizeMsg command during drag") + } + + // Execute the command to get the message + result := cmd() + resizeMsg, ok := result.(ResizeMsg) + if !ok { + t.Errorf("Expected ResizeMsg, got %T", result) + } + + expectedWidth := ctx.ScreenWidth - newX + if resizeMsg.NewWidth != expectedWidth { + t.Errorf("Expected new width %d, got %d", expectedWidth, resizeMsg.NewWidth) + } + }) + + t.Run("Should clamp resize width to minimum", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + m.UpdateProgramContext(ctx) + m.IsOpen = true + m.SetResizing(true) + + // Simulate mouse motion far to the right (would result in very small width) + msg := tea.MouseMsg{ + X: ctx.ScreenWidth - 10, // Would result in width = 10 + Y: 10, + Button: tea.MouseButtonNone, + Action: tea.MouseActionMotion, + } + + _, cmd := m.Update(msg) + + result := cmd() + resizeMsg, ok := result.(ResizeMsg) + if !ok { + t.Errorf("Expected ResizeMsg, got %T", result) + } + + if resizeMsg.NewWidth < MinPreviewWidth { + t.Errorf("Width should be at least %d, got %d", MinPreviewWidth, resizeMsg.NewWidth) + } + }) + + t.Run("Should clamp resize width to maximum", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + m.UpdateProgramContext(ctx) + m.IsOpen = true + m.SetResizing(true) + + // Simulate mouse motion far to the left (would result in very large width) + msg := tea.MouseMsg{ + X: 0, // Would result in width = ScreenWidth + Y: 10, + Button: tea.MouseButtonNone, + Action: tea.MouseActionMotion, + } + + _, cmd := m.Update(msg) + + result := cmd() + resizeMsg, ok := result.(ResizeMsg) + if !ok { + t.Errorf("Expected ResizeMsg, got %T", result) + } + + maxWidth := int(float64(ctx.ScreenWidth) * 0.7) + if resizeMsg.NewWidth > maxWidth && resizeMsg.NewWidth > MaxPreviewWidth { + t.Errorf("Width should be at most %d or %d, got %d", maxWidth, MaxPreviewWidth, resizeMsg.NewWidth) + } + }) + + t.Run("Should stop resizing on mouse release", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + m.UpdateProgramContext(ctx) + m.IsOpen = true + m.SetResizing(true) + + msg := tea.MouseMsg{ + X: 50, + Y: 10, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + } + + updatedModel, cmd := m.Update(msg) + m = updatedModel + + if m.IsResizing() { + t.Error("Expected IsResizing to be false after mouse release") + } + + if cmd == nil { + t.Error("Expected a ResizeEndMsg command on release") + } + }) + + t.Run("GetSidebarContentWidth should use PreviewWidth", func(t *testing.T) { + m := NewModel() + ctx := createTestContext() + ctx.PreviewWidth = 80 + m.UpdateProgramContext(ctx) + + width := m.GetSidebarContentWidth() + expectedWidth := ctx.PreviewWidth - ctx.Styles.Sidebar.BorderWidth + if width != expectedWidth { + t.Errorf("Expected content width %d, got %d", expectedWidth, width) + } + }) +} + +func createTestContext() *context.ProgramContext { + cfg := config.Config{ + Defaults: config.Defaults{ + Preview: config.PreviewConfig{ + Open: true, + Width: 50, + }, + }, + Theme: &config.ThemeConfig{ + Ui: config.UIThemeConfig{ + SectionsShowCount: true, + Table: config.TableUIThemeConfig{ + ShowSeparator: true, + Compact: false, + }, + }, + }, + } + + ctx := &context.ProgramContext{ + Config: &cfg, + ScreenWidth: 100, + ScreenHeight: 30, + PreviewWidth: 50, + } + + ctx.Theme = theme.ParseTheme(ctx.Config) + ctx.Styles = context.InitStyles(ctx.Theme) + + return ctx +} diff --git a/internal/tui/components/table/table.go b/internal/tui/components/table/table.go index 9b102a3f0..9771ce336 100644 --- a/internal/tui/components/table/table.go +++ b/internal/tui/components/table/table.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + zone "github.com/lrstanley/bubblezone" "github.com/dlvhdr/gh-dash/v4/internal/tui/common" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/listviewport" @@ -15,8 +16,18 @@ import ( "github.com/dlvhdr/gh-dash/v4/internal/tui/context" ) +// RowClickedMsg is sent when a table row is clicked +type RowClickedMsg struct { + RowIndex int + SectionId int +} + +// RowZonePrefix is used to create unique zone IDs for table rows +const RowZonePrefix = "table-row-" + type Model struct { ctx context.ProgramContext + sectionId int Columns []Column Rows []Row EmptyState *string @@ -149,6 +160,11 @@ func (m *Model) LastItem() int { return currItem } +func (m *Model) SetCurrItem(index int) { + m.rowsViewport.SetCurrItem(index) + m.SyncViewPortContent() +} + func (m *Model) cacheColumnWidths() { columns := m.renderHeaderColumns() for i, col := range columns { @@ -308,10 +324,16 @@ func (m *Model) renderRow(rowId int, headerColumns []string) string { headerColId++ } - return m.ctx.Styles.Table.RowStyle. + row := m.ctx.Styles.Table.RowStyle. BorderBottom(m.ctx.Config.Theme.Ui.Table.ShowSeparator). MaxWidth(m.dimensions.Width). Render(lipgloss.JoinHorizontal(lipgloss.Top, renderedColumns...)) + + return zone.Mark(m.rowZoneID(rowId), row) +} + +func (m *Model) rowZoneID(rowID int) string { + return fmt.Sprintf("%s%d-%d", RowZonePrefix, m.sectionId, rowID) } func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { @@ -338,3 +360,17 @@ func (m *Model) UpdateTotalItemsCount(count int) { func (m *Model) IsLoading() bool { return m.isLoading } + +func (m *Model) SetSectionId(id int) { + m.sectionId = id +} + +// HandleClick checks if a row was clicked and returns the row index, or -1 if no row was clicked +func (m *Model) HandleClick(msg tea.MouseMsg) int { + for i := range m.Rows { + if zone.Get(m.rowZoneID(i)).InBounds(msg) { + return i + } + } + return -1 +} diff --git a/internal/tui/components/tabs/tabs.go b/internal/tui/components/tabs/tabs.go index d8ab9e510..1a28af673 100644 --- a/internal/tui/components/tabs/tabs.go +++ b/internal/tui/components/tabs/tabs.go @@ -106,6 +106,8 @@ func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { Separator: ctx.Styles.Tabs.TabSeparator, }) + // Set unique zone prefix based on current view to avoid zone conflicts + m.carousel.SetZonePrefix(fmt.Sprintf("%s-", ctx.View)) m.carousel.SetWidth(ctx.ScreenWidth - lipgloss.Width(m.viewLogo())) } @@ -170,3 +172,21 @@ func (m *Model) SetAllLoading() []tea.Cmd { return cmds } + +// TabClickedMsg is sent when a tab is clicked +type TabClickedMsg struct { + TabIndex int +} + +// HandleClick checks if a mouse click event is on a tab and handles it +// Returns a command if a tab was clicked, nil otherwise +func (m *Model) HandleClick(msg tea.MouseMsg) tea.Cmd { + clickedTab := m.carousel.HandleClick(msg) + if clickedTab >= 0 && clickedTab < len(m.sectionTabs) { + m.carousel.SetCursor(clickedTab) + return func() tea.Msg { + return TabClickedMsg{TabIndex: clickedTab} + } + } + return nil +} diff --git a/internal/tui/components/tabs/tabs_test.go b/internal/tui/components/tabs/tabs_test.go index 68688151f..4e9d86fa0 100644 --- a/internal/tui/components/tabs/tabs_test.go +++ b/internal/tui/components/tabs/tabs_test.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" "github.com/charmbracelet/x/exp/teatest" + zone "github.com/lrstanley/bubblezone" "github.com/muesli/termenv" "github.com/dlvhdr/gh-dash/v4/internal/config" @@ -127,6 +128,7 @@ func TestTabs(t *testing.T) { } func init() { + zone.NewGlobal() lipgloss.SetColorProfile(termenv.Ascii) if d := os.Getenv("DEBUG"); d != "" { log.SetLevel(log.DebugLevel) @@ -220,5 +222,5 @@ func (m testModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m testModel) View() string { - return m.tabs.View() + return zone.Scan(m.tabs.View()) } diff --git a/internal/tui/components/tabs/testdata/test_section.go b/internal/tui/components/tabs/testdata/test_section.go index cccb6c40d..892a21b20 100644 --- a/internal/tui/components/tabs/testdata/test_section.go +++ b/internal/tui/components/tabs/testdata/test_section.go @@ -25,6 +25,11 @@ func (t *TestSection) CurrRow() int { panic("unimplemented") } +// SetCurrRow implements section.Section. +func (t *TestSection) SetCurrRow(index int) { + panic("unimplemented") +} + // FetchNextPageSectionRows implements section.Section. func (t *TestSection) FetchNextPageSectionRows() []tea.Cmd { panic("unimplemented") @@ -115,6 +120,11 @@ func (t *TestSection) LastItem() int { panic("unimplemented") } +// HandleRowClick implements section.Section. +func (t *TestSection) HandleRowClick(msg tea.MouseMsg) int { + return -1 +} + // MakeSectionCmd implements section.Section. func (t *TestSection) MakeSectionCmd(cmd tea.Cmd) tea.Cmd { panic("unimplemented") diff --git a/internal/tui/context/context.go b/internal/tui/context/context.go index 6df8c986c..f45f779f3 100644 --- a/internal/tui/context/context.go +++ b/internal/tui/context/context.go @@ -36,6 +36,7 @@ type ProgramContext struct { ScreenWidth int MainContentWidth int MainContentHeight int + PreviewWidth int // Current preview pane width (can be resized dynamically) Config *config.Config ConfigFlag string Version string diff --git a/internal/tui/keys/keys.go b/internal/tui/keys/keys.go index 7b48a0a8f..3c7ca7835 100644 --- a/internal/tui/keys/keys.go +++ b/internal/tui/keys/keys.go @@ -11,25 +11,26 @@ import ( ) type KeyMap struct { - viewType config.ViewType - Up key.Binding - Down key.Binding - FirstLine key.Binding - LastLine key.Binding - TogglePreview key.Binding - OpenGithub key.Binding - Refresh key.Binding - RefreshAll key.Binding - Redraw key.Binding - PageDown key.Binding - PageUp key.Binding - NextSection key.Binding - PrevSection key.Binding - Search key.Binding - CopyUrl key.Binding - CopyNumber key.Binding - Help key.Binding - Quit key.Binding + viewType config.ViewType + Up key.Binding + Down key.Binding + FirstLine key.Binding + LastLine key.Binding + TogglePreview key.Binding + ResetPreviewWidth key.Binding + OpenGithub key.Binding + Refresh key.Binding + RefreshAll key.Binding + Redraw key.Binding + PageDown key.Binding + PageUp key.Binding + NextSection key.Binding + PrevSection key.Binding + Search key.Binding + CopyUrl key.Binding + CopyNumber key.Binding + Help key.Binding + Quit key.Binding } func CreateKeyMapForView(viewType config.ViewType) help.KeyMap { @@ -94,6 +95,7 @@ func (k KeyMap) AppKeys() []key.Binding { k.Refresh, k.RefreshAll, k.TogglePreview, + k.ResetPreviewWidth, k.OpenGithub, k.CopyNumber, k.CopyUrl, @@ -126,6 +128,10 @@ var Keys = &KeyMap{ key.WithKeys("p"), key.WithHelp("p", "open in Preview"), ), + ResetPreviewWidth: key.NewBinding( + key.WithKeys("P"), + key.WithHelp("P", "reset preview width"), + ), OpenGithub: key.NewBinding( key.WithKeys("o"), key.WithHelp("o", "open in GitHub"), @@ -243,6 +249,8 @@ func rebindUniversal(universal []config.Keybinding) error { key = &Keys.LastLine case "togglePreview": key = &Keys.TogglePreview + case "resetPreviewWidth": + key = &Keys.ResetPreviewWidth case "openGithub": key = &Keys.OpenGithub case "refresh": diff --git a/internal/tui/ui.go b/internal/tui/ui.go index fe20d41c1..40a8dba49 100644 --- a/internal/tui/ui.go +++ b/internal/tui/ui.go @@ -41,20 +41,20 @@ import ( ) type Model struct { - keys *keys.KeyMap - sidebar sidebar.Model - prView prview.Model - issueSidebar issueview.Model - branchSidebar branchsidebar.Model - currSectionId int - footer footer.Model - repo section.Section - prs []section.Section - issues []section.Section - tabs tabs.Model - ctx *context.ProgramContext - taskSpinner spinner.Model - tasks map[string]context.Task + keys *keys.KeyMap + sidebar sidebar.Model + prView prview.Model + issueSidebar issueview.Model + branchSidebar branchsidebar.Model + currSectionId int + footer footer.Model + repo section.Section + prs []section.Section + issues []section.Section + tabs tabs.Model + ctx *context.ProgramContext + taskSpinner spinner.Model + tasks map[string]context.Task } func NewModel(location config.Location) Model { @@ -245,6 +245,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sidebar.IsOpen = !m.sidebar.IsOpen m.syncMainContentWidth() + case key.Matches(msg, m.keys.ResetPreviewWidth): + // Reset preview width to config default + m.ctx.PreviewWidth = m.ctx.Config.Defaults.Preview.Width + m.syncMainContentWidth() + m.syncSidebar() + // Clear the saved state + go config.SavePreviewWidth(0) + case key.Matches(msg, m.keys.Refresh): currSection.ResetFilters() currSection.ResetRows() @@ -534,6 +542,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ctx.Theme = theme.ParseTheme(m.ctx.Config) m.ctx.Styles = context.InitStyles(m.ctx.Theme) m.ctx.View = m.ctx.Config.Defaults.View + // Initialize preview width from saved state, fallback to config default + m.ctx.PreviewWidth = msg.Config.Defaults.Preview.Width + if state, err := config.LoadState(); err == nil && state.PreviewWidth > 0 { + m.ctx.PreviewWidth = state.PreviewWidth + } m.currSectionId = m.getCurrentViewDefaultSection() m.sidebar.IsOpen = msg.Config.Defaults.Preview.Open m.syncMainContentWidth() @@ -614,25 +627,115 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseMsg: - if msg.Action != tea.MouseActionRelease || msg.Button != tea.MouseButtonLeft { - return m, nil + // Handle sidebar resize and scroll - pass mouse events to sidebar when it's open + if m.sidebar.IsOpen { + var sidebarCmd tea.Cmd + m.sidebar, sidebarCmd = m.sidebar.Update(msg) + cmds = append(cmds, sidebarCmd) + // If resizing is in progress, don't process other mouse events + if m.sidebar.IsResizing() { + return m, tea.Batch(cmds...) + } + // If sidebar handled a scroll event, return early to avoid also scrolling the list. + if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { + // Check if mouse is in sidebar area + sidebarStartX := m.ctx.ScreenWidth - m.ctx.PreviewWidth + if m.ctx.PreviewWidth <= 0 { + sidebarStartX = m.ctx.ScreenWidth - m.ctx.Config.Defaults.Preview.Width + } + if msg.X >= sidebarStartX { + return m, tea.Batch(cmds...) + } + } + } + + // Handle scroll wheel in the list area (main content) + if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { + // Only handle if mouse is in the main content area (not sidebar) + sidebarStartX := m.ctx.ScreenWidth + if m.sidebar.IsOpen { + if m.ctx.PreviewWidth > 0 { + sidebarStartX = m.ctx.ScreenWidth - m.ctx.PreviewWidth + } else { + sidebarStartX = m.ctx.ScreenWidth - m.ctx.Config.Defaults.Preview.Width + } + } + if msg.X < sidebarStartX && msg.Y >= common.TabsHeight { + section := m.getCurrSection() + if section != nil { + if msg.Button == tea.MouseButtonWheelUp { + section.PrevRow() + } else { + prevRow := section.CurrRow() + nextRow := section.NextRow() + // Fetch more if we're near the bottom + if prevRow != nextRow && nextRow >= section.NumRows()-3 && m.ctx.View != config.RepoView { + cmds = append(cmds, section.FetchNextPageSectionRows()...) + } + } + cmd = m.onViewedRowChanged() + } + } } - if zone.Get("donate").InBounds(msg) { - log.Info("Donate clicked", "msg", msg) - openCmd := func() tea.Msg { - b := browser.New("", os.Stdout, os.Stdin) - err := b.Browse("https://github.com/sponsors/dlvhdr") - if err != nil { - return constants.ErrMsg{Err: err} + + // Handle tab clicks + if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft { + // Check for tab clicks + if tabCmd := m.tabs.HandleClick(msg); tabCmd != nil { + cmds = append(cmds, tabCmd) + // If a tab was clicked, update the section + if m.tabs.CurrSectionId() != m.currSectionId { + m.setCurrSectionId(m.tabs.CurrSectionId()) + cmds = append(cmds, m.onViewedRowChanged()) + } + return m, tea.Batch(cmds...) + } + + // Check for row clicks in the main content area + section := m.getCurrSection() + if section != nil { + clickedRow := section.HandleRowClick(msg) + if clickedRow >= 0 && clickedRow < section.NumRows() { + currRow := section.CurrRow() + if clickedRow != currRow { + section.SetCurrRow(clickedRow) + cmd = m.onViewedRowChanged() + } + } + } + } + + // Handle donate button click + if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft { + if zone.Get("donate").InBounds(msg) { + log.Info("Donate clicked", "msg", msg) + openCmd := func() tea.Msg { + b := browser.New("", os.Stdout, os.Stdin) + err := b.Browse("https://github.com/sponsors/dlvhdr") + if err != nil { + return constants.ErrMsg{Err: err} + } + return nil } - return nil + cmds = append(cmds, openCmd) } - cmds = append(cmds, openCmd) } case tea.WindowSizeMsg: m.onWindowSizeChanged(msg) + case sidebar.ResizeMsg: + // Update the preview width + m.ctx.PreviewWidth = msg.NewWidth + m.syncMainContentWidth() + m.syncSidebar() + + case sidebar.ResizeEndMsg: + // Save the preview width to state file when resize ends + if m.ctx.PreviewWidth > 0 { + go config.SavePreviewWidth(m.ctx.PreviewWidth) + } + case updateFooterMsg: cmds = append(cmds, cmd, m.doUpdateFooterAtInterval()) @@ -808,7 +911,12 @@ func (m *Model) updateCurrentSection(msg tea.Msg) (cmd tea.Cmd) { func (m *Model) syncMainContentWidth() { sideBarOffset := 0 if m.sidebar.IsOpen { - sideBarOffset = m.ctx.Config.Defaults.Preview.Width + // Use dynamic preview width if set, otherwise use config default + if m.ctx.PreviewWidth > 0 { + sideBarOffset = m.ctx.PreviewWidth + } else { + sideBarOffset = m.ctx.Config.Defaults.Preview.Width + } } m.ctx.MainContentWidth = m.ctx.ScreenWidth - sideBarOffset }