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
}