Skip to content

Commit d89004b

Browse files
committed
Added JSON syntax highlighting for the job details
1 parent 07b4836 commit d89004b

12 files changed

Lines changed: 538 additions & 142 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
charm.land/bubbletea/v2 v2.0.0-rc.2
88
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
99
github.com/NimbleMarkets/ntcharts v0.3.1
10+
github.com/alecthomas/chroma/v2 v2.14.0
1011
github.com/charmbracelet/fang v0.4.4
1112
github.com/charmbracelet/lipgloss v1.1.0
1213
github.com/charmbracelet/x/ansi v0.11.3
@@ -31,6 +32,7 @@ require (
3132
github.com/clipperhouse/stringish v0.1.1 // indirect
3233
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
3334
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
35+
github.com/dlclark/regexp2 v1.11.0 // indirect
3436
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3537
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3638
github.com/lrstanley/bubblezone v1.0.0 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYp
66
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
77
github.com/NimbleMarkets/ntcharts v0.3.1 h1:EH4O80RMy5rqDmZM7aWjTbCSuRDDJ5fXOv/qAzdwOjk=
88
github.com/NimbleMarkets/ntcharts v0.3.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk=
9+
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
10+
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
11+
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
12+
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
13+
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
14+
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
915
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
1016
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
1117
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -55,8 +61,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
5561
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5662
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
5763
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
64+
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
65+
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
5866
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
5967
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
68+
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
69+
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
6070
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
6171
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
6272
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=

internal/ui/app.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,26 @@ func New(client *sidekiq.Client) App {
5757

5858
// Apply styles to views
5959
viewStyles := views.Styles{
60-
Text: styles.ViewText,
61-
Muted: styles.ViewMuted,
62-
Title: styles.ViewTitle,
63-
MetricLabel: styles.MetricLabel,
64-
MetricValue: styles.MetricValue,
65-
TableHeader: styles.TableHeader,
66-
TableSelected: styles.TableSelected,
67-
TableSeparator: styles.TableSeparator,
68-
BoxPadding: styles.BoxPadding,
69-
BorderStyle: styles.BorderStyle,
70-
FocusBorder: styles.FocusBorder,
71-
NavKey: styles.NavKey,
72-
ChartSuccess: styles.ChartSuccess,
73-
ChartFailure: styles.ChartFailure,
60+
Text: styles.ViewText,
61+
Muted: styles.ViewMuted,
62+
Title: styles.ViewTitle,
63+
MetricLabel: styles.MetricLabel,
64+
MetricValue: styles.MetricValue,
65+
TableHeader: styles.TableHeader,
66+
TableSelected: styles.TableSelected,
67+
TableSeparator: styles.TableSeparator,
68+
BoxPadding: styles.BoxPadding,
69+
BorderStyle: styles.BorderStyle,
70+
FocusBorder: styles.FocusBorder,
71+
NavKey: styles.NavKey,
72+
ChartSuccess: styles.ChartSuccess,
73+
ChartFailure: styles.ChartFailure,
74+
JSONKey: styles.JSONKey,
75+
JSONString: styles.JSONString,
76+
JSONNumber: styles.JSONNumber,
77+
JSONBool: styles.JSONBool,
78+
JSONNull: styles.JSONNull,
79+
JSONPunctuation: styles.JSONPunctuation,
7480
}
7581
for i := range viewList {
7682
viewList[i] = viewList[i].SetStyles(viewStyles)

internal/ui/components/jobdetail/jobdetail.go

Lines changed: 72 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
package jobdetail
33

44
import (
5-
"encoding/json"
65
"fmt"
76
"math"
87
"strings"
@@ -11,8 +10,10 @@ import (
1110
"charm.land/bubbles/v2/key"
1211
tea "charm.land/bubbletea/v2"
1312
"charm.land/lipgloss/v2"
13+
"github.com/charmbracelet/x/ansi"
1414
"github.com/kpumuk/lazykiq/internal/sidekiq"
1515
"github.com/kpumuk/lazykiq/internal/ui/components/frame"
16+
"github.com/kpumuk/lazykiq/internal/ui/components/jsonview"
1617
"github.com/kpumuk/lazykiq/internal/ui/format"
1718
)
1819

@@ -73,27 +74,39 @@ func DefaultKeyMap() KeyMap {
7374

7475
// Styles holds styles for the job detail component.
7576
type Styles struct {
76-
Title lipgloss.Style
77-
Label lipgloss.Style
78-
Value lipgloss.Style
79-
JSON lipgloss.Style
80-
Border lipgloss.Style
81-
PanelTitle lipgloss.Style
82-
FocusBorder lipgloss.Style
83-
Muted lipgloss.Style
77+
Title lipgloss.Style
78+
Label lipgloss.Style
79+
Value lipgloss.Style
80+
JSON lipgloss.Style
81+
JSONKey lipgloss.Style
82+
JSONString lipgloss.Style
83+
JSONNumber lipgloss.Style
84+
JSONBool lipgloss.Style
85+
JSONNull lipgloss.Style
86+
JSONPunctuation lipgloss.Style
87+
Border lipgloss.Style
88+
PanelTitle lipgloss.Style
89+
FocusBorder lipgloss.Style
90+
Muted lipgloss.Style
8491
}
8592

8693
// DefaultStyles returns default styles.
8794
func DefaultStyles() Styles {
8895
return Styles{
89-
Title: lipgloss.NewStyle().Bold(true),
90-
Label: lipgloss.NewStyle().Faint(true),
91-
Value: lipgloss.NewStyle(),
92-
JSON: lipgloss.NewStyle(),
93-
Border: lipgloss.NewStyle(),
94-
PanelTitle: lipgloss.NewStyle().Bold(true),
95-
FocusBorder: lipgloss.NewStyle(),
96-
Muted: lipgloss.NewStyle().Faint(true),
96+
Title: lipgloss.NewStyle().Bold(true),
97+
Label: lipgloss.NewStyle().Faint(true),
98+
Value: lipgloss.NewStyle(),
99+
JSON: lipgloss.NewStyle(),
100+
JSONKey: lipgloss.NewStyle(),
101+
JSONString: lipgloss.NewStyle(),
102+
JSONNumber: lipgloss.NewStyle(),
103+
JSONBool: lipgloss.NewStyle(),
104+
JSONNull: lipgloss.NewStyle(),
105+
JSONPunctuation: lipgloss.NewStyle(),
106+
Border: lipgloss.NewStyle(),
107+
PanelTitle: lipgloss.NewStyle().Bold(true),
108+
FocusBorder: lipgloss.NewStyle(),
109+
Muted: lipgloss.NewStyle().Faint(true),
97110
}
98111
}
99112

@@ -113,7 +126,7 @@ type Model struct {
113126
// Job data
114127
job *sidekiq.JobRecord
115128
properties []PropertyRow
116-
jsonLines []string
129+
jsonView jsonview.Model
117130

118131
// Scroll state
119132
leftYOffset int
@@ -124,10 +137,9 @@ type Model struct {
124137
focusRight bool
125138

126139
// Calculated dimensions
127-
leftWidth int
128-
rightWidth int
129-
panelHeight int
130-
maxJSONWidth int
140+
leftWidth int
141+
rightWidth int
142+
panelHeight int
131143
}
132144

133145
const (
@@ -141,8 +153,9 @@ type Option func(*Model)
141153
// New creates a new job detail model.
142154
func New(opts ...Option) Model {
143155
m := Model{
144-
KeyMap: DefaultKeyMap(),
145-
styles: DefaultStyles(),
156+
KeyMap: DefaultKeyMap(),
157+
styles: DefaultStyles(),
158+
jsonView: jsonview.New(),
146159
}
147160

148161
for _, opt := range opts {
@@ -156,6 +169,16 @@ func New(opts ...Option) Model {
156169
func WithStyles(s Styles) Option {
157170
return func(m *Model) {
158171
m.styles = s
172+
m.jsonView.SetStyles(jsonview.Styles{
173+
Text: s.JSON,
174+
Key: s.JSONKey,
175+
String: s.JSONString,
176+
Number: s.JSONNumber,
177+
Bool: s.JSONBool,
178+
Null: s.JSONNull,
179+
Punctuation: s.JSONPunctuation,
180+
Muted: s.Muted,
181+
})
159182
}
160183
}
161184

@@ -172,12 +195,23 @@ func WithSize(width, height int) Option {
172195
m.width = width
173196
m.height = height
174197
m.updateDimensions()
198+
m.jsonView.SetSize(width, height)
175199
}
176200
}
177201

178202
// SetStyles sets the styles.
179203
func (m *Model) SetStyles(s Styles) {
180204
m.styles = s
205+
m.jsonView.SetStyles(jsonview.Styles{
206+
Text: s.JSON,
207+
Key: s.JSONKey,
208+
String: s.JSONString,
209+
Number: s.JSONNumber,
210+
Bool: s.JSONBool,
211+
Null: s.JSONNull,
212+
Punctuation: s.JSONPunctuation,
213+
Muted: s.Muted,
214+
})
181215
}
182216

183217
// SetSize sets the dimensions.
@@ -186,6 +220,7 @@ func (m *Model) SetSize(width, height int) {
186220
m.height = height
187221
m.updateDimensions()
188222
m.clampScroll()
223+
m.jsonView.SetSize(width, height)
189224
}
190225

191226
// SetJob sets the job to display.
@@ -293,7 +328,7 @@ func (m Model) maxLeftYOffset() int {
293328
}
294329

295330
func (m Model) maxRightYOffset() int {
296-
maxY := len(m.jsonLines) - m.panelHeight
331+
maxY := m.jsonView.LineCount() - m.panelHeight
297332
if maxY < 0 {
298333
return 0
299334
}
@@ -302,7 +337,7 @@ func (m Model) maxRightYOffset() int {
302337

303338
func (m Model) maxRightXOffset() int {
304339
contentWidth := max(m.rightWidth-2-2*jobDetailPanelPadding, 0)
305-
maxX := m.maxJSONWidth - contentWidth
340+
maxX := m.jsonView.MaxWidth() - contentWidth
306341
if maxX < 0 {
307342
return 0
308343
}
@@ -446,27 +481,11 @@ func (m *Model) extractProperties() {
446481

447482
// formatJSON creates pretty-printed JSON lines.
448483
func (m *Model) formatJSON() {
449-
m.jsonLines = nil
450-
m.maxJSONWidth = 0
451-
452484
if m.job == nil {
485+
m.jsonView.SetValue(nil)
453486
return
454487
}
455-
456-
b, err := json.MarshalIndent(m.job.Item(), "", " ")
457-
if err != nil {
458-
m.jsonLines = []string{"{}", " Error formatting JSON"}
459-
return
460-
}
461-
462-
m.jsonLines = strings.Split(string(b), "\n")
463-
464-
// Calculate max width for horizontal scroll
465-
for _, line := range m.jsonLines {
466-
if len(line) > m.maxJSONWidth {
467-
m.maxJSONWidth = len(line)
468-
}
469-
}
488+
m.jsonView.SetValue(m.job.Item())
470489
}
471490

472491
// renderLeftPanel renders the properties panel.
@@ -531,19 +550,15 @@ func (m Model) renderRightPanel() string {
531550
contentWidth := max(innerWidth-2*jobDetailPanelPadding, 0)
532551

533552
// Content lines with horizontal scroll
534-
endY := min(m.rightYOffset+m.panelHeight, len(m.jsonLines))
553+
endY := min(m.rightYOffset+m.panelHeight, m.jsonView.LineCount())
535554
contentCap := 0
536555
if endY > m.rightYOffset {
537556
contentCap = endY - m.rightYOffset
538557
}
539558
contentLines := make([]string, 0, contentCap)
540559

541560
for i := m.rightYOffset; i < endY; i++ {
542-
line := m.jsonLines[i]
543-
// Apply horizontal scroll BEFORE styling
544-
line = applyHorizontalScroll(line, m.rightXOffset, contentWidth)
545-
line = m.styles.JSON.Render(line)
546-
contentLines = append(contentLines, line)
561+
contentLines = append(contentLines, m.jsonView.RenderLine(i, m.rightXOffset, contentWidth))
547562
}
548563

549564
// Pad to panel height
@@ -584,40 +599,23 @@ func formatTimestamp(ts float64) string {
584599
return t.Format("2006-01-02 15:04:05")
585600
}
586601

587-
// applyHorizontalScroll applies horizontal scroll offset.
588-
func applyHorizontalScroll(line string, offset, visibleWidth int) string {
589-
runes := []rune(line)
590-
591-
if offset >= len(runes) {
592-
return strings.Repeat(" ", visibleWidth)
593-
}
594-
runes = runes[offset:]
595-
596-
if len(runes) < visibleWidth {
597-
return string(runes) + strings.Repeat(" ", visibleWidth-len(runes))
598-
}
599-
return string(runes[:visibleWidth])
600-
}
601-
602602
// wrapText wraps text to fit within the specified width.
603603
func wrapText(s string, width int) []string {
604604
if width <= 0 {
605605
return []string{s}
606606
}
607607

608-
runes := []rune(s)
609-
if len(runes) <= width {
608+
if lipgloss.Width(s) <= width {
610609
return []string{s}
611610
}
612611

613612
var lines []string
614-
for len(runes) > 0 {
615-
if len(runes) <= width {
616-
lines = append(lines, string(runes))
617-
break
618-
}
619-
lines = append(lines, string(runes[:width]))
620-
runes = runes[width:]
613+
for lipgloss.Width(s) > width {
614+
lines = append(lines, ansi.Truncate(s, width, ""))
615+
s = ansi.Cut(s, width, lipgloss.Width(s))
616+
}
617+
if s != "" {
618+
lines = append(lines, s)
621619
}
622620
return lines
623621
}

0 commit comments

Comments
 (0)