Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ jobs:
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4 changes: 4 additions & 0 deletions docs/src/content/docs/getting-started/keybindings/preview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ Press <kbd>[</kbd> to move to the next tab in the preview sidebar, if one exists
## `]` - Previous Preview Tab

Press <kbd>]</kbd> to move to the previous tab in the preview sidebar, if one exists.

## `P` - Reset Preview Width

Press <kbd>Shift</kbd>+<kbd>p</kbd> to reset the preview pane width back to the configured default.
79 changes: 79 additions & 0 deletions internal/config/state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package config

import (
"os"
"path/filepath"

"gopkg.in/yaml.v3"
)

const StateFileName = "state.yml"

Check failure on line 10 in internal/config/state.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gofumpt)
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)
}
44 changes: 42 additions & 2 deletions internal/tui/components/carousel/carousel.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 := ""

Expand Down Expand Up @@ -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, "")
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
164 changes: 164 additions & 0 deletions internal/tui/components/carousel/carousel_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading
Loading