From b72dc921a5b443ae259c88fca50e1e2fb805414d Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Thu, 8 Oct 2026 13:47:38 +0200 Subject: [PATCH 1/2] added scrollbars to error viewport #101 --- internal/ui/app.go | 36 ++++++++++----- internal/ui/helpers.go | 12 +++++ internal/ui/render.go | 100 ++++++++++++++++++++++++++++++++++++++++- internal/ui/styles.go | 10 ++++- 4 files changed, 145 insertions(+), 13 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index fd4a354..69b8e47 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -32,7 +32,8 @@ type Model struct { table table.Model tableXOffset int - err error + err error + errorViewport viewport.Model itemCount int status string @@ -106,8 +107,10 @@ func New(registry *resource.Registry, openstackContext *openstack.Context, plugi command.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEFA")) command.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - detail := viewport.New(1, 1) - detail.SetHorizontalStep(4) + detailViewport := viewport.New(1, 1) + detailViewport.SetHorizontalStep(4) + + errorViewport := viewport.New(1, 1) s := spinner.New() s.Spinner = spinner.Points @@ -123,7 +126,8 @@ func New(registry *resource.Registry, openstackContext *openstack.Context, plugi context: openstackContext, pluginHost: pluginHost, - detail: detail, + detail: detailViewport, + errorViewport: errorViewport, loading: true, showLoading: true, @@ -148,22 +152,34 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.err != nil { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { case "esc", "enter": - m.err = nil + m.setError(nil) if len(m.navigation) > 0 { return m, m.navigateBack() } return m, nil + case "ctrl+x": return m, tea.Quit } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.Resize() + m.resizeErrorViewport() + return m, nil } - return m, nil + var cmd tea.Cmd + m.errorViewport, cmd = m.errorViewport.Update(msg) + + return m, cmd } switch msg := msg.(type) { @@ -177,7 +193,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showLoading = false m.loadingLabel = "" - m.err = msg.err + m.setError(msg.err) slog.Error(msg.err.Error(), "cloud", m.context.Cloud) return m, nil @@ -188,7 +204,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showLoading = false m.loadingLabel = "" - m.err = msg.Err + m.setError(msg.Err) slog.Error(msg.Err.Error(), "cloud", m.context.Cloud) return m, nil diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index 9effbbe..e63d6b9 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -119,3 +119,15 @@ func (m *Model) suggestions() []string { return suggestions } + +func (m *Model) setError(err error) { + m.err = err + + if err == nil { + m.errorViewport.SetContent("") + m.errorViewport.GotoTop() + return + } + + m.resizeErrorViewport() +} diff --git a/internal/ui/render.go b/internal/ui/render.go index fdefb63..290687f 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -391,9 +391,105 @@ func (m Model) renderErrorModal() string { content := errorTitleStyle.Render("Error") + "\n\n" + - m.err.Error() + + m.errorViewport.View() + "\n\n" + hint - return errorModalStyle.Render(content) + modal := errorModalStyle.Render(content) + + scrollbar := m.renderErrorScrollbar() + if scrollbar == "" { + return modal + } + + lines := strings.Split(modal, "\n") + scrollbarLines := strings.Split(scrollbar, "\n") + + // One border row, one padding row, title row, + // one blank row, then the viewport. + const viewportStartRow = 4 + + for i, scrollbarLine := range scrollbarLines { + row := viewportStartRow + i + + if row >= len(lines)-1 { + break + } + + line := lines[row] + width := ansi.StringWidth(line) + + // Preserve everything except the final border cell. + lines[row] = ansi.Cut(line, 0, width-1) + scrollbarLine + } + + return strings.Join(lines, "\n") +} + +func (m Model) renderErrorScrollbar() string { + height := m.errorViewport.Height + + if height <= 0 || m.errorViewport.TotalLineCount() <= height { + return "" + } + + totalLines := m.errorViewport.TotalLineCount() + maxOffset := max(totalLines-height, 1) + + thumbHeight := max(height*height/totalLines, 1) + thumbHeight = min(thumbHeight, height) + + thumbTravel := height - thumbHeight + thumbStart := m.errorViewport.YOffset * thumbTravel / maxOffset + + lines := make([]string, height) + + for i := range lines { + if i >= thumbStart && i < thumbStart+thumbHeight { + lines[i] = errorScrollbarThumbStyle.Render("█") + } else { + lines[i] = errorScrollbarTrackStyle.Render("│") + } + } + + return strings.Join(lines, "\n") +} + +func (m *Model) resizeErrorViewport() { + const ( + modalWidth = 60 + horizontalTrim = 4 + scrollbarWidth = 2 + verticalTrim = 8 + maxVisibleLines = 12 + ) + + contentWidth := modalWidth - horizontalTrim - scrollbarWidth + m.errorViewport.Width = contentWidth + + if m.err == nil { + m.errorViewport.Height = 1 + return + } + + wrapped := lipgloss.NewStyle(). + Width(contentWidth). + Render(m.err.Error()) + + m.errorViewport.SetContent(wrapped) + + contentHeight := lipgloss.Height(wrapped) + availableHeight := max(m.height-verticalTrim, 1) + + m.errorViewport.Height = max( + min(contentHeight, availableHeight, maxVisibleLines), + 1, + ) + + m.errorViewport.SetYOffset( + min( + m.errorViewport.YOffset, + max(contentHeight-m.errorViewport.Height, 0), + ), + ) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b02e19f..8f00545 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -99,7 +99,7 @@ var ( Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#ED1944")). Foreground(lipgloss.Color("#FFFFFF")). - Padding(1, 2). + Padding(1, 1). Width(60) errorTitleStyle = lipgloss.NewStyle(). @@ -124,3 +124,11 @@ func tableStyles() table.Styles { return styles } + +var ( + errorScrollbarTrackStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ED1944")) + + errorScrollbarThumbStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ED1944")) +) From 875aae7bbc774bb13bad85102a4dc866907235ba Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Thu, 8 Oct 2026 13:57:14 +0200 Subject: [PATCH 2/2] added scrollbars to details viewport #101 --- internal/ui/app.go | 22 ++++++++++----------- internal/ui/render.go | 46 +++++++++++++++++++++++++++++++++++++++++-- internal/ui/styles.go | 7 +++++++ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 69b8e47..4ac596e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -59,10 +59,10 @@ type Model struct { filter *resourceFilter scope resourceScope - detailMode bool - detail viewport.Model - detailID string - detailContent string + detailMode bool + detailViewport viewport.Model + detailID string + detailContent string commandMode bool command textinput.Model @@ -126,8 +126,8 @@ func New(registry *resource.Registry, openstackContext *openstack.Context, plugi context: openstackContext, pluginHost: pluginHost, - detail: detailViewport, - errorViewport: errorViewport, + detailViewport: detailViewport, + errorViewport: errorViewport, loading: true, showLoading: true, @@ -262,7 +262,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd - m.detail, cmd = m.detail.Update(msg) + m.detailViewport, cmd = m.detailViewport.Update(msg) return m, cmd } @@ -568,8 +568,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailMode = true m.detailID = msg.ID m.detailContent = string(data) - m.detail.SetContent(colorizeJSON(data)) - m.detail.GotoTop() + m.detailViewport.SetContent(colorizeJSON(data)) + m.detailViewport.GotoTop() m.Resize() return m, nil @@ -740,8 +740,8 @@ func (m *Model) Resize() { m.table.SetHeight(contentHeight) - m.detail.Width = max(m.width-2, 1) - m.detail.Height = contentHeight + m.detailViewport.Width = max(m.width-2, 1) + m.detailViewport.Height = contentHeight if m.resource == nil { return diff --git a/internal/ui/render.go b/internal/ui/render.go index 290687f..b75f54b 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -319,9 +319,16 @@ func (m Model) renderDetails() string { "┘", ) - body := m.detail.View() + body := m.detailViewport.View() bodyLines := strings.Split(body, "\n") + scrollbar := m.renderDetailScrollbar() + var scrollbarLines []string + + if scrollbar != "" { + scrollbarLines = strings.Split(scrollbar, "\n") + } + for i, line := range bodyLines { lineWidth := lipgloss.Width(line) @@ -329,10 +336,16 @@ func (m Model) renderDetails() string { line += strings.Repeat(" ", innerWidth-lineWidth) } + rightBorder := borderStyle.Render("│") + + if i < len(scrollbarLines) { + rightBorder = scrollbarLines[i] + } + bodyLines[i] = borderStyle.Render("│") + line + - borderStyle.Render("│") + rightBorder } return top + @@ -342,6 +355,35 @@ func (m Model) renderDetails() string { bottom } +func (m Model) renderDetailScrollbar() string { + height := m.detailViewport.Height + + if height <= 0 || m.detailViewport.TotalLineCount() <= height { + return "" + } + + totalLines := m.detailViewport.TotalLineCount() + maxOffset := max(totalLines-height, 1) + + thumbHeight := max(height*height/totalLines, 1) + thumbHeight = min(thumbHeight, height) + + thumbTravel := height - thumbHeight + thumbStart := m.detailViewport.YOffset * thumbTravel / maxOffset + + lines := make([]string, height) + + for i := range lines { + if i >= thumbStart && i < thumbStart+thumbHeight { + lines[i] = detailScrollbarThumbStyle.Render("█") + } else { + lines[i] = detailScrollbarTrackStyle.Render("│") + } + } + + return strings.Join(lines, "\n") +} + func overlayCenter(background, foreground string) string { bg := strings.Split(background, "\n") fg := strings.Split(foreground, "\n") diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 8f00545..382b053 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -90,6 +90,13 @@ var ( jsonNullStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#7F848E")) + + detailScrollbarTrackStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00BFFF")) + + detailScrollbarThumbStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00BFFF")). + Bold(true) ) //ErrorView