diff --git a/internal/tui/ui/components/help_overlay.go b/internal/tui/ui/components/help_overlay.go index 85d1e2b..aaa7922 100644 --- a/internal/tui/ui/components/help_overlay.go +++ b/internal/tui/ui/components/help_overlay.go @@ -76,7 +76,7 @@ func (h *HelpOverlay) View() tea.View { // Card style cardStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(lipgloss.NormalBorder()). BorderForeground(styles.ColorPrimary()). Padding(1, 2) diff --git a/internal/tui/ui/components/items.go b/internal/tui/ui/components/items.go index 8f38f24..e6f172b 100644 --- a/internal/tui/ui/components/items.go +++ b/internal/tui/ui/components/items.go @@ -1,8 +1,6 @@ package components import ( - "fmt" - "github.com/deeploy-sh/deeploy/internal/shared/model" ) @@ -12,10 +10,6 @@ type ProjectItem struct { PodCount int } -func (i ProjectItem) Title() string { return i.Project.Title } -func (i ProjectItem) Suffix() string { return fmt.Sprintf("(%d)", i.PodCount) } -func (i ProjectItem) FilterValue() string { return i.Project.Title } - // ProjectsToItems converts a slice of Projects to ScrollItems func ProjectsToItems(projects []model.Project, pods []model.Pod) []ScrollItem { // Count pods per project @@ -36,9 +30,6 @@ type PodItem struct { model.Pod } -func (i PodItem) Title() string { return i.Pod.Title } -func (i PodItem) FilterValue() string { return i.Pod.Title } - // PodsToItems converts a slice of Pods to ScrollItems func PodsToItems(pods []model.Pod) []ScrollItem { items := make([]ScrollItem, len(pods)) diff --git a/internal/tui/ui/components/scrolllist.go b/internal/tui/ui/components/scrolllist.go index da9107a..33baa30 100644 --- a/internal/tui/ui/components/scrolllist.go +++ b/internal/tui/ui/components/scrolllist.go @@ -3,34 +3,21 @@ package components import ( "strings" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" "github.com/deeploy-sh/deeploy/internal/tui/ui/styles" ) -// ScrollItem interface für Items in der Liste -type ScrollItem interface { - Title() string - FilterValue() string -} +// ScrollItem is a marker interface for list row payloads. +type ScrollItem interface{} -// PrefixedItem optionales Interface für Items mit Prefix (●, [P], etc.) -type PrefixedItem interface { - Prefix() string -} - -// SuffixedItem optionales Interface für Items mit Suffix (count, status, etc.) -type SuffixedItem interface { - Suffix() string -} +type RowRenderer func(item ScrollItem, width int, selected bool) string // ScrollListConfig für NewScrollList type ScrollListConfig struct { - Width int - Height int - WithInput bool - Placeholder string + Width int + Height int + RenderRow RowRenderer } // ScrollList ist eine einfache scrollbare Liste mit echtem Zeile-für-Zeile Scrolling @@ -41,26 +28,16 @@ type ScrollList struct { viewStart int // erstes sichtbares item width int height int // anzahl sichtbarer items - input *textinput.Model + renderRow RowRenderer } func NewScrollList(items []ScrollItem, cfg ScrollListConfig) ScrollList { l := ScrollList{ - allItems: items, - items: items, - width: cfg.Width, - height: cfg.Height, - } - - if cfg.WithInput { - ti := NewTextInput(20) - ti.Placeholder = cfg.Placeholder - if ti.Placeholder == "" { - ti.Placeholder = "Type to search..." - } - ti.Focus() - ti.CharLimit = 100 - l.input = &ti + allItems: items, + items: items, + width: cfg.Width, + height: cfg.Height, + renderRow: cfg.RenderRow, } return l @@ -104,6 +81,9 @@ func (m ScrollList) Items() []ScrollItem { return m.items } func (m *ScrollList) SetWidth(w int) { m.width = w } func (m *ScrollList) SetHeight(h int) { m.height = h } +func (m *ScrollList) SetRenderer(r RowRenderer) { + m.renderRow = r +} func (m *ScrollList) Select(index int) { if index >= 0 && index < len(m.items) { @@ -129,55 +109,19 @@ func (m *ScrollList) SetItems(items []ScrollItem) { } func (m ScrollList) Init() tea.Cmd { - if m.input != nil { - return textinput.Blink - } return nil } -func (m *ScrollList) filter() { - if m.input == nil { - return - } - - query := strings.ToLower(m.input.Value()) - if query == "" { - m.items = m.allItems - } else { - var filtered []ScrollItem - for _, item := range m.allItems { - if strings.Contains(strings.ToLower(item.FilterValue()), query) { - filtered = append(filtered, item) - } - } - m.items = filtered - } - - // Reset cursor wenn nötig - if m.cursor >= len(m.items) { - m.cursor = max(0, len(m.items)-1) - } - m.viewStart = 0 -} - func (m ScrollList) Update(msg tea.Msg) (ScrollList, tea.Cmd) { var cmd tea.Cmd - // Input updaten wenn vorhanden (für Blink und Typing) - if m.input != nil { - *m.input, cmd = m.input.Update(msg) - m.filter() - } - // Navigation (vim-style + mouse) switch msg := msg.(type) { case tea.KeyPressMsg: key := msg.String() - // Mit Input: Ctrl+P/N, Tab/Shift+Tab, Pfeiltasten - // Ohne Input: zusätzlich j/k - isUp := msg.Code == tea.KeyUp || key == "ctrl+p" || key == "shift+tab" || (m.input == nil && key == "k") - isDown := msg.Code == tea.KeyDown || key == "ctrl+n" || key == "tab" || (m.input == nil && key == "j") + isUp := msg.Code == tea.KeyUp || key == "ctrl+p" || key == "shift+tab" || key == "k" + isDown := msg.Code == tea.KeyDown || key == "ctrl+n" || key == "tab" || key == "j" switch { case isUp: @@ -197,22 +141,6 @@ func (m ScrollList) Update(msg tea.Msg) (ScrollList, tea.Cmd) { return m, cmd } -func (m ScrollList) HasInput() bool { - return m.input != nil -} - -func (m ScrollList) InputView() string { - if m.input == nil { - return "" - } - return lipgloss.NewStyle(). - Width(m.width). - Background(styles.ColorBackgroundPanel()). - PaddingLeft(1). - PaddingBottom(1). - Render(m.input.View()) -} - func (m ScrollList) View() string { var lines []string @@ -228,44 +156,15 @@ func (m ScrollList) View() string { for i := m.viewStart; i < end; i++ { item := m.items[i] selected := i == m.cursor - - // Get prefix if item implements PrefixedItem - prefix := "" - pi, ok := item.(PrefixedItem) - if ok { - prefix = pi.Prefix() + " " - } - - // Get suffix if item implements SuffixedItem - suffix := "" - si, ok := item.(SuffixedItem) - if ok { - suffix = si.Suffix() - } - - title := item.Title() - // Calculate space-between padding (1 leading + 1 trailing space) - usedWidth := 2 + len(prefix) + len(title) + len(suffix) - padding := max(1, m.width-usedWidth) - - content := " " + prefix + title + strings.Repeat(" ", padding) + suffix + " " - lineStyle := lipgloss.NewStyle().Width(m.width) - - var line string - if selected { - line = lineStyle. - Background(styles.ColorPrimary()). - Foreground(styles.ColorBackground()). - Bold(true). - Render(content) - } else { - line = lineStyle. + if m.renderRow == nil { + lines = append(lines, lipgloss.NewStyle(). + Width(m.width). Background(styles.ColorBackgroundPanel()). - Foreground(styles.ColorForeground()). - Render(content) + Foreground(styles.ColorError()). + Render(" missing row renderer")) + continue } - - lines = append(lines, line) + lines = append(lines, m.renderRow(item, m.width, selected)) } } diff --git a/internal/tui/ui/components/theme_switcher.go b/internal/tui/ui/components/theme_switcher.go index 8b3b47c..0086d61 100644 --- a/internal/tui/ui/components/theme_switcher.go +++ b/internal/tui/ui/components/theme_switcher.go @@ -14,13 +14,26 @@ type themeItem struct { isActive bool } -func (i themeItem) Title() string { return i.name } -func (i themeItem) FilterValue() string { return i.name } -func (i themeItem) Prefix() string { - if i.isActive { - return "●" +func renderThemeRow(item ScrollItem, width int, selected bool) string { + ti := item.(themeItem) + marker := " " + if ti.isActive { + marker = "●" } - return " " + content := " " + marker + " " + ti.name + + style := lipgloss.NewStyle().Width(width) + if selected { + return style. + Background(styles.ColorPrimary()). + Foreground(styles.ColorBackground()). + Bold(true). + Render(content) + } + return style. + Background(styles.ColorBackgroundPanel()). + Foreground(styles.ColorForeground()). + Render(content) } type ThemeSwitcher struct { @@ -39,8 +52,9 @@ func NewThemeSwitcher() ThemeSwitcher { card := styles.CardProps{Width: styles.CardWidthMD, Padding: []int{1, 1}} l := NewScrollList(items, ScrollListConfig{ - Width: card.InnerWidth(), - Height: 15, + Width: card.InnerWidth(), + Height: 15, + RenderRow: renderThemeRow, }) for i, t := range themes { diff --git a/internal/tui/ui/layout/layout.go b/internal/tui/ui/layout/layout.go index 9d17c59..7b6703a 100644 --- a/internal/tui/ui/layout/layout.go +++ b/internal/tui/ui/layout/layout.go @@ -428,7 +428,7 @@ func (l *Layout) renderContent() string { style := lipgloss.NewStyle(). Width(mainWidth). Height(mainHeight). - Border(lipgloss.RoundedBorder()). + Border(lipgloss.NormalBorder()). BorderForeground(borderColor) if l.content == nil { diff --git a/internal/tui/ui/layout/sidebar.go b/internal/tui/ui/layout/sidebar.go index 2b12ef1..698989d 100644 --- a/internal/tui/ui/layout/sidebar.go +++ b/internal/tui/ui/layout/sidebar.go @@ -339,7 +339,7 @@ func (s *Sidebar) renderPanel(title, content string, width, contentHeight int, f return lipgloss.NewStyle(). Width(width - BorderSize). Height(contentHeight). - Border(lipgloss.RoundedBorder()). + Border(lipgloss.NormalBorder()). BorderForeground(borderColor). Render(titleBar + "\n" + listStyle.Render(content)) } diff --git a/internal/tui/ui/page/profile_selector.go b/internal/tui/ui/page/profile_selector.go index 7c86f1e..a27db91 100644 --- a/internal/tui/ui/page/profile_selector.go +++ b/internal/tui/ui/page/profile_selector.go @@ -17,15 +17,30 @@ type profileItem struct { isActive bool } -func (i profileItem) Title() string { return i.profile.Name } -func (i profileItem) FilterValue() string { return i.profile.Name + " " + i.profile.Server } -func (i profileItem) Prefix() string { - if i.isActive { - return "●" +func renderProfileRow(item components.ScrollItem, width int, selected bool) string { + pi := item.(profileItem) + marker := " " + if pi.isActive { + marker = "●" } - return " " + + left := " " + marker + " " + pi.profile.Name + right := pi.profile.Server + " " + content := styles.SpaceBetween(width, left, right) + + style := lipgloss.NewStyle().Width(width) + if selected { + return style. + Background(styles.ColorPrimary()). + Foreground(styles.ColorBackground()). + Bold(true). + Render(content) + } + return style. + Background(styles.ColorBackgroundPanel()). + Foreground(styles.ColorForeground()). + Render(content) } -func (i profileItem) Suffix() string { return i.profile.Server } type profileSelector struct { width int @@ -44,12 +59,13 @@ type profileSelector struct { } func NewProfileSelector() profileSelector { - card := styles.CardProps{Width: styles.CardWidthLG, Padding: []int{1, 2}, Accent: true} + card := styles.CardProps{Width: styles.CardWidthLG, Padding: []int{1, 1}, Accent: true} p := profileSelector{ list: components.NewScrollList(nil, components.ScrollListConfig{ - Width: card.InnerWidth(), - Height: 10, + Width: card.InnerWidth(), + Height: 10, + RenderRow: renderProfileRow, }), keySelect: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), keyNew: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new")), @@ -186,16 +202,25 @@ func (p profileSelector) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { func (p profileSelector) View() tea.View { card := styles.CardProps{ Width: styles.CardWidthLG, - Padding: []int{1, 2}, + Padding: []int{1, 1}, Accent: true, } + innerWidth := card.InnerWidth() + line := func(s lipgloss.Style, text string) string { + return s. + Width(innerWidth). + Background(styles.ColorBackgroundPanel()). + Render(text) + } + blank := line(lipgloss.NewStyle(), "") + content := lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Bold(true).Render("Profiles"), - "", + line(lipgloss.NewStyle().Bold(true), "Profiles"), + blank, p.list.View(), - "", - styles.MutedStyle().Render("Select a VPS profile to continue."), + blank, + line(styles.MutedStyle(), "Select a VPS profile to continue."), ) if p.err != nil { diff --git a/internal/tui/ui/panel/pod.go b/internal/tui/ui/panel/pod.go index 4503125..70d32f5 100644 --- a/internal/tui/ui/panel/pod.go +++ b/internal/tui/ui/panel/pod.go @@ -11,6 +11,7 @@ import ( "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/deeploy-sh/deeploy/internal/shared/model" "github.com/deeploy-sh/deeploy/internal/tui/api" "github.com/deeploy-sh/deeploy/internal/tui/config" @@ -126,6 +127,9 @@ func NewPodPanel(store msg.Store, podID string) *PodPanel { logStatus: "loading", domainItems: store.PodDomains(podID), envVars: store.PodEnvVars(podID), + domains: components.NewScrollList(nil, components.ScrollListConfig{ + RenderRow: renderDomainRow, + }), } // Init domains list @@ -527,10 +531,23 @@ type domainItem struct { domain model.PodDomain } -func (d domainItem) Title() string { return d.domain.Domain } -func (d domainItem) FilterValue() string { return d.domain.Domain } -func (d domainItem) Suffix() string { - return fmt.Sprintf(":%d", d.domain.Port) +func renderDomainRow(item components.ScrollItem, width int, selected bool) string { + di := item.(domainItem) + + left := " " + di.domain.Domain + right := fmt.Sprintf(":%d", di.domain.Port) + content := styles.SpaceBetween(width, left, right) + + style := lipgloss.NewStyle().Width(width) + if selected { + return style. + Background(styles.ColorPrimary()). + Foreground(styles.ColorBackground()). + Bold(true). + Render(content) + } + return style. + Render(content) } // Vars helpers diff --git a/internal/tui/ui/panel/tokens.go b/internal/tui/ui/panel/tokens.go index fe92ca9..7ea0ad9 100644 --- a/internal/tui/ui/panel/tokens.go +++ b/internal/tui/ui/panel/tokens.go @@ -5,6 +5,7 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/deeploy-sh/deeploy/internal/shared/model" "github.com/deeploy-sh/deeploy/internal/tui/msg" "github.com/deeploy-sh/deeploy/internal/tui/ui/components" @@ -28,6 +29,27 @@ type TokensPanel struct { height int } +func renderTokenRow(item components.ScrollItem, width int, selected bool) string { + ti := item.(tokenItem) + content := " " + ti.token.Name + if ti.token.Provider != "" { + content += " (" + ti.token.Provider + ")" + } + + style := lipgloss.NewStyle().Width(width) + if selected { + return style. + Background(styles.ColorPrimary()). + Foreground(styles.ColorBackground()). + Bold(true). + Render(content) + } + return style. + Background(styles.ColorBackgroundPanel()). + Foreground(styles.ColorForeground()). + Render(content) +} + // NewTokensPanel creates a new tokens panel func NewTokensPanel(store msg.Store) *TokensPanel { tokens := store.GitTokens() @@ -58,8 +80,9 @@ func (p *TokensPanel) rebuildList() { items[i] = tokenItem{token: t} } p.list = components.NewScrollList(items, components.ScrollListConfig{ - Width: p.width - 4, - Height: p.height - 4, + Width: p.width - 4, + Height: p.height - 4, + RenderRow: renderTokenRow, }) } @@ -145,7 +168,3 @@ func (p *TokensPanel) HelpKeys() []key.Binding { type tokenItem struct { token model.GitToken } - -func (t tokenItem) Title() string { return t.token.Name } -func (t tokenItem) FilterValue() string { return t.token.Name } -func (t tokenItem) Suffix() string { return t.token.Provider } diff --git a/internal/tui/ui/styles/layout.go b/internal/tui/ui/styles/layout.go new file mode 100644 index 0000000..066de3e --- /dev/null +++ b/internal/tui/ui/styles/layout.go @@ -0,0 +1,17 @@ +package styles + +import lipgloss "charm.land/lipgloss/v2" + +// SpaceBetween lays out left and right text across a fixed width. +func SpaceBetween(width int, left, right string) string { + if width <= 0 { + return "" + } + + rest := width - lipgloss.Width(left) + if rest <= 0 { + return left + } + + return left + lipgloss.PlaceHorizontal(rest, lipgloss.Right, right) +}