Skip to content
Open
Show file tree
Hide file tree
Changes from all 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"
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) {
Comment thread
hjanuschka marked this conversation as resolved.
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) {
Comment thread
hjanuschka marked this conversation as resolved.
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