From 79ae565702b4d0227bcb3f298f9160bbb0c42bfe Mon Sep 17 00:00:00 2001 From: engalar Date: Tue, 24 Mar 2026 19:58:57 +0800 Subject: [PATCH 01/10] feat(tui): add interactive DiffView component with sticky line numbers Add a reusable TUI diff viewer supporting: - Unified and side-by-side views (Tab to toggle) - Word-level inline diff highlighting (Lipgloss segments) - Sticky line numbers during horizontal scroll - Mouse wheel vertical and horizontal scrolling - Vim-style navigation (j/k, h/l, g/G, ]/[ hunk jump) - Search with live matching (/, n/N) - Chroma syntax highlighting for unchanged lines Uses go-difflib SequenceMatcher for high-quality line pairing and sergi/go-diff for word-level segments within paired lines. Integrated via CompareView 'D' key: pick two objects in compare mode, press D to diff their content with word-level precision. Closes engalar/mxcli#17 --- cmd/mxcli/tui/app.go | 32 +- cmd/mxcli/tui/compare.go | 19 + cmd/mxcli/tui/diffengine.go | 193 ++++++++ cmd/mxcli/tui/diffrender.go | 253 ++++++++++ cmd/mxcli/tui/diffview.go | 577 ++++++++++++++++++++++ cmd/mxcli/tui/hintbar.go | 7 + docs/plans/2026-03-24-diff-view-design.md | 234 +++++++++ go.mod | 1 + go.sum | 7 + 9 files changed, 1322 insertions(+), 1 deletion(-) create mode 100644 cmd/mxcli/tui/diffengine.go create mode 100644 cmd/mxcli/tui/diffrender.go create mode 100644 cmd/mxcli/tui/diffview.go create mode 100644 docs/plans/2026-03-24-diff-view-design.md diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 6563c77..53aeb93 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -28,6 +28,7 @@ type App struct { overlay Overlay compare CompareView + diffView *DiffView showHelp bool picker *PickerModel // non-nil when cross-project picker is open @@ -100,7 +101,9 @@ func (a *App) syncStatusBar() { } func (a *App) syncHintBar() { - if a.overlay.IsVisible() { + if a.diffView != nil { + a.hintBar.SetHints(DiffViewHints) + } else if a.overlay.IsVisible() { a.hintBar.SetHints(OverlayHints) } else if a.compare.IsVisible() { a.hintBar.SetHints(CompareHints) @@ -168,6 +171,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncHintBar() return a, nil + case DiffOpenMsg: + dv := NewDiffView(msg, a.width, a.height) + a.diffView = &dv + a.syncHintBar() + return a, nil + + case DiffCloseMsg: + a.diffView = nil + a.syncHintBar() + return a, nil + case OpenOverlayMsg: a.overlay.Show(msg.Title, msg.Content, a.width, a.height) a.syncHintBar() @@ -240,6 +254,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Fullscreen modes + if a.diffView != nil { + var cmd tea.Cmd + *a.diffView, cmd = a.diffView.Update(msg) + return a, cmd + } if a.compare.IsVisible() { var cmd tea.Cmd a.compare, cmd = a.compare.Update(msg) @@ -273,6 +292,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: Trace("app: mouse x=%d y=%d btn=%v action=%v", msg.X, msg.Y, msg.Button, msg.Action) + if a.diffView != nil { + var cmd tea.Cmd + *a.diffView, cmd = a.diffView.Update(msg) + return a, cmd + } if a.picker != nil || a.compare.IsVisible() || a.overlay.IsVisible() { return a, nil } @@ -302,6 +326,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Trace("app: resize %dx%d", msg.Width, msg.Height) a.width = msg.Width a.height = msg.Height + if a.diffView != nil { + a.diffView.SetSize(msg.Width, msg.Height) + } a.resizeAll() return a, nil @@ -528,6 +555,9 @@ func (a App) View() string { if a.picker != nil { return a.picker.View() } + if a.diffView != nil { + return a.diffView.View() + } if a.compare.IsVisible() { return a.compare.View() } diff --git a/cmd/mxcli/tui/compare.go b/cmd/mxcli/tui/compare.go index 47d37bb..cad204d 100644 --- a/cmd/mxcli/tui/compare.go +++ b/cmd/mxcli/tui/compare.go @@ -338,6 +338,24 @@ func (c CompareView) updateNormal(msg tea.KeyMsg) (CompareView, tea.Cmd) { c.kind = CompareMDL return c, c.emitReload() + // Diff view — open DiffView with left vs right content + case "D": + leftText := c.left.content.PlainText() + rightText := c.right.content.PlainText() + if leftText != "" && rightText != "" { + leftTitle := c.left.title + rightTitle := c.right.title + return c, func() tea.Msg { + return DiffOpenMsg{ + OldText: leftText, + NewText: rightText, + Language: "", + Title: fmt.Sprintf("Diff: %s vs %s", leftTitle, rightTitle), + } + } + } + return c, nil + // Refresh both panes case "r": return c, c.emitReload() @@ -499,6 +517,7 @@ func (c CompareView) renderStatusBar() string { if si := c.focusedPane().content.SearchInfo(); si != "" { parts = append(parts, key.Render("n/N")+" "+active.Render(si)) } + parts = append(parts, key.Render("D")+" "+dim.Render("diff")) parts = append(parts, key.Render("r")+" "+dim.Render("reload")) parts = append(parts, key.Render("j/k")+" "+dim.Render("scroll")) if c.copiedFlash { diff --git a/cmd/mxcli/tui/diffengine.go b/cmd/mxcli/tui/diffengine.go new file mode 100644 index 0000000..7ea9d22 --- /dev/null +++ b/cmd/mxcli/tui/diffengine.go @@ -0,0 +1,193 @@ +package tui + +import ( + "strings" + + "github.com/pmezard/go-difflib/difflib" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// DiffLineType represents the type of a diff line. +type DiffLineType int + +const ( + DiffEqual DiffLineType = iota + DiffInsert + DiffDelete +) + +// DiffSegment represents a word-level segment within a changed line. +type DiffSegment struct { + Text string + Changed bool +} + +// DiffLine represents a single line in the diff output. +type DiffLine struct { + Type DiffLineType + OldLineNo int // 0 for Insert lines + NewLineNo int // 0 for Delete lines + Content string + Segments []DiffSegment // word-level breakdown (Insert/Delete only) +} + +// DiffStats holds summary statistics for a diff. +type DiffStats struct { + Additions int + Deletions int + Equal int +} + +// DiffResult holds the complete diff output. +type DiffResult struct { + Lines []DiffLine + Stats DiffStats +} + +// ComputeDiff computes a line-level diff with word-level segments for changed lines. +// Uses go-difflib SequenceMatcher for high-quality line pairing (Python difflib algorithm), +// and sergi/go-diff for word-level segments within paired lines. +func ComputeDiff(oldText, newText string) *DiffResult { + oldLines := splitLines(oldText) + newLines := splitLines(newText) + + matcher := difflib.NewMatcherWithJunk(oldLines, newLines, false, nil) + opcodes := matcher.GetOpCodes() + + result := &DiffResult{} + oldLineNo := 0 + newLineNo := 0 + + for _, op := range opcodes { + switch op.Tag { + case 'e': // equal + for i := op.I1; i < op.I2; i++ { + oldLineNo++ + newLineNo++ + result.Stats.Equal++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffEqual, + OldLineNo: oldLineNo, + NewLineNo: newLineNo, + Content: oldLines[i], + }) + } + + case 'r': // replace — pair old[i1:i2] with new[j1:j2] + delCount := op.I2 - op.I1 + insCount := op.J2 - op.J1 + paired := min(delCount, insCount) + + // Paired lines get word-level segments + for k := range paired { + oldSegs, newSegs := computeWordSegments(oldLines[op.I1+k], newLines[op.J1+k]) + + oldLineNo++ + result.Stats.Deletions++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffDelete, + OldLineNo: oldLineNo, + Content: oldLines[op.I1+k], + Segments: oldSegs, + }) + + newLineNo++ + result.Stats.Additions++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffInsert, + NewLineNo: newLineNo, + Content: newLines[op.J1+k], + Segments: newSegs, + }) + } + + // Excess deletes (more old lines than new) + for k := paired; k < delCount; k++ { + oldLineNo++ + result.Stats.Deletions++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffDelete, + OldLineNo: oldLineNo, + Content: oldLines[op.I1+k], + Segments: []DiffSegment{{Text: oldLines[op.I1+k], Changed: true}}, + }) + } + + // Excess inserts (more new lines than old) + for k := paired; k < insCount; k++ { + newLineNo++ + result.Stats.Additions++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffInsert, + NewLineNo: newLineNo, + Content: newLines[op.J1+k], + Segments: []DiffSegment{{Text: newLines[op.J1+k], Changed: true}}, + }) + } + + case 'd': // delete + for i := op.I1; i < op.I2; i++ { + oldLineNo++ + result.Stats.Deletions++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffDelete, + OldLineNo: oldLineNo, + Content: oldLines[i], + Segments: []DiffSegment{{Text: oldLines[i], Changed: true}}, + }) + } + + case 'i': // insert + for j := op.J1; j < op.J2; j++ { + newLineNo++ + result.Stats.Additions++ + result.Lines = append(result.Lines, DiffLine{ + Type: DiffInsert, + NewLineNo: newLineNo, + Content: newLines[j], + Segments: []DiffSegment{{Text: newLines[j], Changed: true}}, + }) + } + } + } + + return result +} + +// splitLines splits text into lines, stripping trailing newline. +func splitLines(text string) []string { + if text == "" { + return nil + } + lines := strings.Split(text, "\n") + // Remove trailing empty element from final newline + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +// computeWordSegments runs character-level diff on two lines and maps +// the results to old/new DiffSegment slices with Changed flags. +func computeWordSegments(oldLine, newLine string) ([]DiffSegment, []DiffSegment) { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(oldLine, newLine, false) + diffs = dmp.DiffCleanupSemantic(diffs) + + var oldSegs, newSegs []DiffSegment + for _, d := range diffs { + if d.Text == "" { + continue + } + switch d.Type { + case diffmatchpatch.DiffEqual: + oldSegs = append(oldSegs, DiffSegment{Text: d.Text, Changed: false}) + newSegs = append(newSegs, DiffSegment{Text: d.Text, Changed: false}) + case diffmatchpatch.DiffDelete: + oldSegs = append(oldSegs, DiffSegment{Text: d.Text, Changed: true}) + case diffmatchpatch.DiffInsert: + newSegs = append(newSegs, DiffSegment{Text: d.Text, Changed: true}) + } + } + return oldSegs, newSegs +} diff --git a/cmd/mxcli/tui/diffrender.go b/cmd/mxcli/tui/diffrender.go new file mode 100644 index 0000000..645205f --- /dev/null +++ b/cmd/mxcli/tui/diffrender.go @@ -0,0 +1,253 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Diff color palette. +var ( + diffAddedFg = lipgloss.Color("#00D787") + diffAddedChangedFg = lipgloss.Color("#FFFFFF") + diffAddedChangedBg = lipgloss.Color("#005F00") + + diffRemovedFg = lipgloss.Color("#FF5F87") + diffRemovedChangedFg = lipgloss.Color("#FFFFFF") + diffRemovedChangedBg = lipgloss.Color("#5F0000") + + diffEqualGutter = lipgloss.Color("#626262") + diffGutterAddedFg = lipgloss.Color("#00D787") + diffGutterRemovedFg = lipgloss.Color("#FF5F87") +) + +// DiffRenderedLine holds the sticky prefix (gutter + line numbers) and scrollable content separately. +type DiffRenderedLine struct { + Prefix string // gutter char + line numbers (sticky, never scrolled) + Content string // actual code/text content (horizontally scrollable) +} + +// RenderUnifiedDiff renders a DiffResult as unified diff lines with prefix/content split. +func RenderUnifiedDiff(result *DiffResult, lang string) []DiffRenderedLine { + if result == nil || len(result.Lines) == 0 { + return nil + } + + gutterCharSt := lipgloss.NewStyle() + lineNoSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + maxLineNo := 0 + for _, dl := range result.Lines { + if dl.OldLineNo > maxLineNo { + maxLineNo = dl.OldLineNo + } + if dl.NewLineNo > maxLineNo { + maxLineNo = dl.NewLineNo + } + } + lineNoW := max(3, len(fmt.Sprintf("%d", maxLineNo))) + + rendered := make([]DiffRenderedLine, 0, len(result.Lines)) + for _, dl := range result.Lines { + var gutter, oldNo, newNo, content string + + switch dl.Type { + case DiffEqual: + gutter = gutterCharSt.Foreground(diffEqualGutter).Render("│") + oldNo = lineNoSt.Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + newNo = lineNoSt.Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + content = highlightLine(dl.Content, lang) + + case DiffInsert: + gutter = gutterCharSt.Foreground(diffGutterAddedFg).Render("+") + oldNo = lineNoSt.Render(strings.Repeat(" ", lineNoW)) + newNo = lipgloss.NewStyle().Foreground(diffGutterAddedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + content = renderSegments(dl.Segments, DiffInsert) + + case DiffDelete: + gutter = gutterCharSt.Foreground(diffGutterRemovedFg).Render("-") + oldNo = lipgloss.NewStyle().Foreground(diffGutterRemovedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + newNo = lineNoSt.Render(strings.Repeat(" ", lineNoW)) + content = renderSegments(dl.Segments, DiffDelete) + } + + prefix := gutter + " " + oldNo + " " + newNo + " " + rendered = append(rendered, DiffRenderedLine{Prefix: prefix, Content: content}) + } + return rendered +} + +// SideBySideRenderedLine holds prefix and content for one pane in side-by-side view. +type SideBySideRenderedLine struct { + Prefix string // line number (sticky) + Content string // code content (scrollable) + Blank bool // true if this is a blank filler line +} + +// RenderSideBySideDiff renders a DiffResult as two columns with prefix/content split. +func RenderSideBySideDiff(result *DiffResult, lang string) (left, right []SideBySideRenderedLine) { + if result == nil || len(result.Lines) == 0 { + return nil, nil + } + + lineNoSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + maxLineNo := 0 + for _, dl := range result.Lines { + if dl.OldLineNo > maxLineNo { + maxLineNo = dl.OldLineNo + } + if dl.NewLineNo > maxLineNo { + maxLineNo = dl.NewLineNo + } + } + lineNoW := max(3, len(fmt.Sprintf("%d", maxLineNo))) + blankPrefix := strings.Repeat(" ", lineNoW) + " " + + for _, dl := range result.Lines { + switch dl.Type { + case DiffEqual: + highlighted := highlightLine(dl.Content, lang) + oldNo := lineNoSt.Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + " " + newNo := lineNoSt.Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + " " + left = append(left, SideBySideRenderedLine{Prefix: oldNo, Content: highlighted}) + right = append(right, SideBySideRenderedLine{Prefix: newNo, Content: highlighted}) + + case DiffDelete: + content := renderSegments(dl.Segments, DiffDelete) + oldNo := lipgloss.NewStyle().Foreground(diffGutterRemovedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + " " + left = append(left, SideBySideRenderedLine{Prefix: oldNo, Content: content}) + right = append(right, SideBySideRenderedLine{Prefix: blankPrefix, Blank: true}) + + case DiffInsert: + content := renderSegments(dl.Segments, DiffInsert) + newNo := lipgloss.NewStyle().Foreground(diffGutterAddedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + " " + left = append(left, SideBySideRenderedLine{Prefix: blankPrefix, Blank: true}) + right = append(right, SideBySideRenderedLine{Prefix: newNo, Content: content}) + } + } + return left, right +} + +// renderSegments renders word-level diff segments with appropriate styling. +func renderSegments(segments []DiffSegment, lineType DiffLineType) string { + if len(segments) == 0 { + return "" + } + + var normalFg, changedFg, changedBg lipgloss.Color + switch lineType { + case DiffInsert: + normalFg = diffAddedFg + changedFg = diffAddedChangedFg + changedBg = diffAddedChangedBg + case DiffDelete: + normalFg = diffRemovedFg + changedFg = diffRemovedChangedFg + changedBg = diffRemovedChangedBg + default: + var sb strings.Builder + for _, seg := range segments { + sb.WriteString(seg.Text) + } + return sb.String() + } + + normalSt := lipgloss.NewStyle().Foreground(normalFg) + changedSt := lipgloss.NewStyle().Foreground(changedFg).Background(changedBg) + + var sb strings.Builder + for _, seg := range segments { + if seg.Changed { + sb.WriteString(changedSt.Render(seg.Text)) + } else { + sb.WriteString(normalSt.Render(seg.Text)) + } + } + return sb.String() +} + +// highlightLine applies syntax highlighting based on language. +func highlightLine(content, lang string) string { + switch strings.ToLower(lang) { + case "sql", "mdl": + return HighlightMDL(content) + case "ndsl": + return HighlightNDSL(content) + case "": + return DetectAndHighlight(content) + default: + return content + } +} + +// hslice returns a horizontal slice of an ANSI-colored string, +// skipping the first `skip` visual columns and returning up to `take` visual columns. +func hslice(s string, skip, take int) string { + if skip == 0 { + return truncateToWidth(s, take) + } + + var result strings.Builder + visW := 0 + inEsc := false + for _, r := range s { + if r == '\x1b' { + inEsc = true + if visW >= skip { + result.WriteRune(r) + } + continue + } + if inEsc { + if visW >= skip { + result.WriteRune(r) + } + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inEsc = false + } + continue + } + visW++ + if visW <= skip { + continue + } + if visW-skip > take { + break + } + result.WriteRune(r) + } + return result.String() +} + +// truncateToWidth truncates a (possibly ANSI-colored) string to fit maxW visual columns. +func truncateToWidth(s string, maxW int) string { + if lipgloss.Width(s) <= maxW { + return s + } + + var result strings.Builder + visW := 0 + inEsc := false + for _, r := range s { + if r == '\x1b' { + inEsc = true + result.WriteRune(r) + continue + } + if inEsc { + result.WriteRune(r) + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inEsc = false + } + continue + } + visW++ + if visW > maxW { + break + } + result.WriteRune(r) + } + return result.String() +} diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go new file mode 100644 index 0000000..eaed701 --- /dev/null +++ b/cmd/mxcli/tui/diffview.go @@ -0,0 +1,577 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// DiffViewMode determines the diff display layout. +type DiffViewMode int + +const ( + DiffViewUnified DiffViewMode = iota + DiffViewSideBySide +) + +// DiffOpenMsg requests opening a diff view. +type DiffOpenMsg struct { + OldText string + NewText string + Language string // "sql", "go", "ndsl", "" (auto-detect) + Title string +} + +// DiffCloseMsg signals the diff view should close. +type DiffCloseMsg struct{} + +// DiffView is a Bubble Tea component for interactive diff viewing. +type DiffView struct { + // Input + oldText string + newText string + language string + title string + + // Computed + result *DiffResult + unified []DiffRenderedLine // pre-rendered unified lines + sideLeft []SideBySideRenderedLine // pre-rendered side-by-side left + sideRight []SideBySideRenderedLine // pre-rendered side-by-side right + hunkStarts []int // line indices where hunks begin + + // View state + viewMode DiffViewMode + yOffset int + xOffset int // horizontal scroll offset (content only, line numbers stay fixed) + width int + height int + + // Side-by-side state + syncScroll bool + focus int // 0=left, 1=right + leftOffset int + rightOffset int + + // Search + searching bool + searchInput textinput.Model + searchQuery string + matchLines []int + matchIdx int +} + +// NewDiffView creates a DiffView from a DiffOpenMsg. +func NewDiffView(msg DiffOpenMsg, width, height int) DiffView { + ti := textinput.New() + ti.Prompt = "/" + ti.CharLimit = 200 + + dv := DiffView{ + oldText: msg.OldText, + newText: msg.NewText, + language: msg.Language, + title: msg.Title, + width: width, + height: height, + syncScroll: true, + searchInput: ti, + } + + dv.result = ComputeDiff(msg.OldText, msg.NewText) + dv.renderAll() + dv.computeHunkStarts() + + return dv +} + +func (dv *DiffView) renderAll() { + dv.unified = RenderUnifiedDiff(dv.result, dv.language) + dv.sideLeft, dv.sideRight = RenderSideBySideDiff(dv.result, dv.language) +} + +func (dv *DiffView) computeHunkStarts() { + dv.hunkStarts = nil + if dv.result == nil { + return + } + for i, dl := range dv.result.Lines { + if dl.Type == DiffEqual { + continue + } + if i == 0 || dv.result.Lines[i-1].Type == DiffEqual { + dv.hunkStarts = append(dv.hunkStarts, i) + } + } +} + +// SetSize updates dimensions. +func (dv *DiffView) SetSize(w, h int) { + dv.width = w + dv.height = h +} + +// IsVisible returns true (always visible when DiffView exists). +func (dv DiffView) IsVisible() bool { return true } + +func (dv DiffView) totalLines() int { + if dv.viewMode == DiffViewSideBySide { + return len(dv.sideLeft) + } + return len(dv.unified) +} + +func (dv DiffView) contentHeight() int { + h := dv.height - 2 // title bar + hint bar + if dv.searching { + h-- + } + return max(5, h) +} + +func (dv DiffView) maxOffset() int { + return max(0, dv.totalLines()-dv.contentHeight()) +} + +// --- Update --- + +func (dv DiffView) Update(msg tea.Msg) (DiffView, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if dv.searching { + return dv.updateSearch(msg) + } + return dv.updateNormal(msg) + + case tea.MouseMsg: + if msg.Action == tea.MouseActionPress { + switch msg.Button { + case tea.MouseButtonWheelUp: + dv.scroll(-3) + case tea.MouseButtonWheelDown: + dv.scroll(3) + case tea.MouseButtonWheelLeft: + dv.xOffset = max(0, dv.xOffset-8) + case tea.MouseButtonWheelRight: + dv.xOffset += 8 + } + } + + case tea.WindowSizeMsg: + dv.SetSize(msg.Width, msg.Height) + } + return dv, nil +} + +func (dv DiffView) updateSearch(msg tea.KeyMsg) (DiffView, tea.Cmd) { + switch msg.String() { + case "esc": + dv.searching = false + dv.searchInput.Blur() + return dv, nil + case "enter": + dv.commitSearch() + return dv, nil + default: + var cmd tea.Cmd + dv.searchInput, cmd = dv.searchInput.Update(msg) + dv.searchQuery = strings.TrimSpace(dv.searchInput.Value()) + dv.buildMatchLines() + if len(dv.matchLines) > 0 { + dv.matchIdx = 0 + dv.scrollToMatch() + } + return dv, cmd + } +} + +func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { + switch msg.String() { + case "q", "esc": + return dv, func() tea.Msg { return DiffCloseMsg{} } + + // Vertical scroll + case "j", "down": + dv.scroll(1) + case "k", "up": + dv.scroll(-1) + case "d", "ctrl+d": + dv.scroll(dv.contentHeight() / 2) + case "u", "ctrl+u": + dv.scroll(-dv.contentHeight() / 2) + case "f", "pgdown": + dv.scroll(dv.contentHeight()) + case "b", "pgup": + dv.scroll(-dv.contentHeight()) + case "g", "home": + dv.yOffset = 0 + dv.leftOffset = 0 + dv.rightOffset = 0 + case "G", "end": + dv.yOffset = dv.maxOffset() + dv.leftOffset = dv.maxOffset() + dv.rightOffset = dv.maxOffset() + + // Horizontal scroll + case "h", "left": + dv.xOffset = max(0, dv.xOffset-8) + case "l", "right": + dv.xOffset += 8 + + // View mode toggle + case "tab": + if dv.viewMode == DiffViewUnified { + dv.viewMode = DiffViewSideBySide + } else { + dv.viewMode = DiffViewUnified + } + dv.yOffset = 0 + dv.xOffset = 0 + dv.leftOffset = 0 + dv.rightOffset = 0 + + // Search + case "/": + dv.searching = true + dv.searchInput.SetValue(dv.searchQuery) + dv.searchInput.Focus() + case "n": + dv.nextMatch() + case "N": + dv.prevMatch() + + // Hunk navigation + case "]": + dv.nextHunk() + case "[": + dv.prevHunk() + } + + return dv, nil +} + +func (dv *DiffView) scroll(delta int) { + if dv.viewMode == DiffViewSideBySide && !dv.syncScroll { + if dv.focus == 0 { + dv.leftOffset = clamp(dv.leftOffset+delta, 0, dv.maxOffset()) + } else { + dv.rightOffset = clamp(dv.rightOffset+delta, 0, dv.maxOffset()) + } + } else { + dv.yOffset = clamp(dv.yOffset+delta, 0, dv.maxOffset()) + if dv.syncScroll { + dv.leftOffset = dv.yOffset + dv.rightOffset = dv.yOffset + } + } +} + +// --- Hunk navigation --- + +func (dv *DiffView) nextHunk() { + if len(dv.hunkStarts) == 0 { + return + } + for _, hs := range dv.hunkStarts { + if hs > dv.yOffset { + dv.yOffset = clamp(hs, 0, dv.maxOffset()) + return + } + } + dv.yOffset = clamp(dv.hunkStarts[0], 0, dv.maxOffset()) +} + +func (dv *DiffView) prevHunk() { + if len(dv.hunkStarts) == 0 { + return + } + for i := len(dv.hunkStarts) - 1; i >= 0; i-- { + if dv.hunkStarts[i] < dv.yOffset { + dv.yOffset = clamp(dv.hunkStarts[i], 0, dv.maxOffset()) + return + } + } + dv.yOffset = clamp(dv.hunkStarts[len(dv.hunkStarts)-1], 0, dv.maxOffset()) +} + +// --- Search --- + +func (dv *DiffView) commitSearch() { + dv.searching = false + dv.searchInput.Blur() + dv.searchQuery = strings.TrimSpace(dv.searchInput.Value()) + dv.buildMatchLines() + if len(dv.matchLines) > 0 { + dv.matchIdx = 0 + dv.scrollToMatch() + } +} + +func (dv *DiffView) buildMatchLines() { + dv.matchLines = nil + if dv.searchQuery == "" { + return + } + q := strings.ToLower(dv.searchQuery) + // Search in the raw content of DiffResult lines (not rendered) + if dv.result != nil { + for i, dl := range dv.result.Lines { + if strings.Contains(strings.ToLower(dl.Content), q) { + dv.matchLines = append(dv.matchLines, i) + } + } + } +} + +func (dv *DiffView) nextMatch() { + if len(dv.matchLines) == 0 { + return + } + dv.matchIdx = (dv.matchIdx + 1) % len(dv.matchLines) + dv.scrollToMatch() +} + +func (dv *DiffView) prevMatch() { + if len(dv.matchLines) == 0 { + return + } + dv.matchIdx-- + if dv.matchIdx < 0 { + dv.matchIdx = len(dv.matchLines) - 1 + } + dv.scrollToMatch() +} + +func (dv *DiffView) scrollToMatch() { + if dv.matchIdx >= len(dv.matchLines) { + return + } + target := dv.matchLines[dv.matchIdx] + dv.yOffset = clamp(target-dv.contentHeight()/2, 0, dv.maxOffset()) +} + +func (dv DiffView) searchInfo() string { + if dv.searchQuery == "" || len(dv.matchLines) == 0 { + return "" + } + return fmt.Sprintf("%d/%d", dv.matchIdx+1, len(dv.matchLines)) +} + +// --- View --- + +func (dv DiffView) View() string { + titleSt := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + dimSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + keySt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true) + activeSt := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + addSt := lipgloss.NewStyle().Foreground(diffAddedFg).Bold(true) + delSt := lipgloss.NewStyle().Foreground(diffRemovedFg).Bold(true) + + // Title bar + modeLabel := "Unified" + if dv.viewMode == DiffViewSideBySide { + modeLabel = "Side-by-Side" + } + stats := "" + if dv.result != nil { + stats = addSt.Render(fmt.Sprintf("+%d", dv.result.Stats.Additions)) + " " + + delSt.Render(fmt.Sprintf("-%d", dv.result.Stats.Deletions)) + } + pct := fmt.Sprintf("%d%%", dv.scrollPercent()) + xInfo := "" + if dv.xOffset > 0 { + xInfo = dimSt.Render(fmt.Sprintf(" col:%d", dv.xOffset)) + } + titleBar := titleSt.Render(dv.title) + " " + stats + " " + + dimSt.Render("["+modeLabel+"]") + " " + dimSt.Render(pct) + xInfo + + // Content + viewH := dv.contentHeight() + var content string + if dv.viewMode == DiffViewSideBySide { + content = dv.renderSideBySide(viewH) + } else { + content = dv.renderUnified(viewH) + } + + // Hint bar + var hints []string + hints = append(hints, keySt.Render("j/k")+" "+dimSt.Render("vert")) + hints = append(hints, keySt.Render("h/l")+" "+dimSt.Render("horiz")) + hints = append(hints, keySt.Render("Tab")+" "+dimSt.Render("mode")) + hints = append(hints, keySt.Render("]/[")+" "+dimSt.Render("hunk")) + hints = append(hints, keySt.Render("/")+" "+dimSt.Render("search")) + if si := dv.searchInfo(); si != "" { + hints = append(hints, keySt.Render("n/N")+" "+activeSt.Render(si)) + } + hints = append(hints, keySt.Render("q")+" "+dimSt.Render("close")) + hintLine := " " + strings.Join(hints, " ") + + var sb strings.Builder + sb.WriteString(titleBar) + sb.WriteString("\n") + sb.WriteString(content) + sb.WriteString("\n") + + if dv.searching { + matchInfo := "" + if q := strings.TrimSpace(dv.searchInput.Value()); q != "" { + matchInfo = fmt.Sprintf(" (%d matches)", len(dv.matchLines)) + } + sb.WriteString(dv.searchInput.View() + dimSt.Render(matchInfo)) + } else { + sb.WriteString(hintLine) + } + + return sb.String() +} + +func (dv DiffView) renderUnified(viewH int) string { + lines := dv.unified + total := len(lines) + showScrollbar := total > viewH + + trackSt := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + thumbSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + + var thumbStart, thumbEnd int + if showScrollbar { + thumbSize := max(1, viewH*viewH/total) + if m := dv.maxOffset(); m > 0 { + thumbStart = dv.yOffset * (viewH - thumbSize) / m + } + thumbEnd = thumbStart + thumbSize + } + + scrollW := 0 + if showScrollbar { + scrollW = 1 + } + + // Calculate content width after prefix + // Prefix is fixed, content gets the remaining width + prefixW := 0 + if len(lines) > 0 { + prefixW = lipgloss.Width(lines[0].Prefix) + } + contentW := max(10, dv.width-prefixW-scrollW) + + var sb strings.Builder + for vi := range viewH { + lineIdx := dv.yOffset + vi + var line string + if lineIdx < total { + rl := lines[lineIdx] + // Prefix is sticky (always visible) + content := hslice(rl.Content, dv.xOffset, contentW) + // Pad content to fill width + if pad := contentW - lipgloss.Width(content); pad > 0 { + content += strings.Repeat(" ", pad) + } + line = rl.Prefix + content + } else { + line = strings.Repeat(" ", dv.width-scrollW) + } + + if showScrollbar { + if vi >= thumbStart && vi < thumbEnd { + line += thumbSt.Render("█") + } else { + line += trackSt.Render("│") + } + } + + sb.WriteString(line) + if vi < viewH-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func (dv DiffView) renderSideBySide(viewH int) string { + dividerSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + total := len(dv.sideLeft) + showScrollbar := total > viewH + + trackSt := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + thumbSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + + var thumbStart, thumbEnd int + if showScrollbar { + thumbSize := max(1, viewH*viewH/total) + if m := dv.maxOffset(); m > 0 { + thumbStart = dv.yOffset * (viewH - thumbSize) / m + } + thumbEnd = thumbStart + thumbSize + } + + scrollW := 0 + if showScrollbar { + scrollW = 1 + } + dividerW := 3 // " │ " + paneTotal := (dv.width - dividerW - scrollW) / 2 + + // Calculate prefix width from rendered data + prefixW := 0 + if len(dv.sideLeft) > 0 { + prefixW = lipgloss.Width(dv.sideLeft[0].Prefix) + } + contentW := max(5, paneTotal-prefixW) + + var sb strings.Builder + for vi := range viewH { + lineIdx := dv.yOffset + vi + + var leftStr, rightStr string + if lineIdx < total { + ll := dv.sideLeft[lineIdx] + leftContent := hslice(ll.Content, dv.xOffset, contentW) + if pad := contentW - lipgloss.Width(leftContent); pad > 0 { + leftContent += strings.Repeat(" ", pad) + } + leftStr = ll.Prefix + leftContent + } else { + leftStr = strings.Repeat(" ", paneTotal) + } + + if lineIdx < len(dv.sideRight) { + rl := dv.sideRight[lineIdx] + rightContent := hslice(rl.Content, dv.xOffset, contentW) + if pad := contentW - lipgloss.Width(rightContent); pad > 0 { + rightContent += strings.Repeat(" ", pad) + } + rightStr = rl.Prefix + rightContent + } else { + rightStr = strings.Repeat(" ", paneTotal) + } + + line := leftStr + dividerSt.Render(" │ ") + rightStr + + if showScrollbar { + if vi >= thumbStart && vi < thumbEnd { + line += thumbSt.Render("█") + } else { + line += trackSt.Render("│") + } + } + + sb.WriteString(line) + if vi < viewH-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func (dv DiffView) scrollPercent() int { + m := dv.maxOffset() + if m <= 0 { + return 100 + } + return int(float64(dv.yOffset) / float64(m) * 100) +} diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index 4c9dccc..4e98a44 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -62,6 +62,13 @@ var ( {Key: "d", Label: "diff"}, {Key: "q", Label: "close"}, } + DiffViewHints = []Hint{ + {Key: "j/k", Label: "scroll"}, + {Key: "Tab", Label: "mode"}, + {Key: "]c/[c", Label: "hunk"}, + {Key: "/", Label: "search"}, + {Key: "q", Label: "close"}, + } ) // View renders the hint bar to fit within the given width. diff --git a/docs/plans/2026-03-24-diff-view-design.md b/docs/plans/2026-03-24-diff-view-design.md new file mode 100644 index 0000000..e3c33c3 --- /dev/null +++ b/docs/plans/2026-03-24-diff-view-design.md @@ -0,0 +1,234 @@ +# Diff View TUI Component Design + +**Issue**: [engalar/mxcli#17](https://github.com/engalar/mxcli/issues/17) +**Date**: 2026-03-24 + +## Overview + +A reusable, generic TUI diff component for mxcli's Bubble Tea interface. Accepts any two text inputs, computes line-level and word-level diffs, and renders them with syntax highlighting and interactive navigation. Supports both Unified and Side-by-Side view modes with keyboard toggle. + +## Requirements + +- **Generic**: Decoupled from specific data sources (MDL, git, BSON) — accepts `(oldText, newText, language)`. +- **Two view modes**: Unified (single column, +/- markers) and Side-by-Side (left/right panes), switchable via `Tab`. +- **Word-level inline diff**: Within changed lines, highlight the specific words/characters that changed (deep background color), not just the entire line. +- **Syntax highlighting**: Equal lines use Chroma; changed lines use Lipgloss segment rendering (avoids ANSI Reset conflict). +- **Interactive**: Vim-style scrolling, search, hunk navigation (`]c`/`[c`). +- **Integration**: Opens as an overlay in `app.go` via `DiffOpenMsg`, closes with `q`/`Esc`. + +## Architecture + +### Data Flow + +``` +(oldText, newText, lang) → diffengine.ComputeDiff() → []DiffLine → diffrender.Render*() → string +``` + +### Data Model (`diffengine.go`) + +```go +type DiffLineType int + +const ( + DiffEqual DiffLineType = iota + DiffInsert + DiffDelete +) + +type DiffSegment struct { + Text string + Changed bool // true = this specific segment was modified +} + +type DiffLine struct { + Type DiffLineType + OldLineNo int // 0 for Insert lines + NewLineNo int // 0 for Delete lines + Content string // raw text + Segments []DiffSegment // word-level breakdown (Insert/Delete only) +} + +type DiffResult struct { + Lines []DiffLine + Stats DiffStats // additions, deletions, changes counts +} +``` + +### Diff Engine (`diffengine.go`, ~150 lines) + +Uses `github.com/sergi/go-diff/diffmatchpatch`. + +**Two-pass strategy:** + +1. **Line-level diff**: Use `DiffLinesToChars()` + `DiffMain()` + `DiffCharsToLines()` to get line-level differences. This avoids character-level fragmentation that breaks TUI layout. + +2. **Word-level diff** (second pass): For adjacent Delete/Insert line pairs, run `DiffMain()` on the raw text to identify which words/characters changed. Map results to `[]DiffSegment` with `Changed` flags. + +```go +func ComputeDiff(oldText, newText string) *DiffResult +func computeWordSegments(oldLine, newLine string) (oldSegs, newSegs []DiffSegment) +``` + +### Rendering (`diffrender.go`, ~250 lines) + +#### Chroma ANSI Reset Conflict Resolution + +**Problem**: Chroma inserts `\x1b[0m` after each token, which erases any outer background color set by Lipgloss. + +**Solution**: Don't use Chroma on changed lines. + +| Line Type | Rendering Strategy | +|-----------|--------------------| +| Equal | Chroma syntax highlighting (via existing `DetectAndHighlight`) | +| Insert | Lipgloss per-segment: unchanged segments = light green foreground, changed segments = white on dark green background | +| Delete | Lipgloss per-segment: unchanged segments = light red foreground, changed segments = white on dark red background | + +#### Color Palette + +``` +// Insert (added) +AddedFg = "#00D787" // light green — unchanged segments +AddedChangedFg = "#FFFFFF" // white — changed word text +AddedChangedBg = "#005F00" // dark green — changed word background +AddedGutter = "#00D787" // gutter "+" + +// Delete (removed) +RemovedFg = "#FF5F87" // light red — unchanged segments +RemovedChangedFg = "#FFFFFF" // white — changed word text +RemovedChangedBg = "#5F0000" // dark red — changed word background +RemovedGutter = "#FF5F87" // gutter "-" + +// Equal +EqualGutter = "#626262" // dim gray — gutter "│" +``` + +#### Unified View Renderer + +``` +func RenderUnified(result *DiffResult, width int, lang string) []string +``` + +Output format per line: +``` +[gutter] [old_lineno] [new_lineno] [content] +``` + +- Gutter: `+` (green), `-` (red), `│` (gray) +- Line numbers: dual column (old/new), dim when not applicable + +#### Side-by-Side Renderer + +``` +func RenderSideBySide(result *DiffResult, width int, lang string) (left, right []string) +``` + +- Each pane = `(width - 3) / 2` characters (3 = divider + padding) +- Delete lines on left, Insert lines on right, Equal on both +- Blank lines pad the shorter side to maintain alignment + +### DiffView Component (`diffview.go`, ~400 lines) + +```go +type DiffViewMode int +const ( + DiffViewUnified DiffViewMode = iota + DiffViewSideBySide +) + +type DiffView struct { + // Input + oldText, newText string + language string + title string + + // Computed + result *DiffResult + rendered []string // unified: rendered lines; side-by-side: not used + leftLines []string // side-by-side only + rightLines []string // side-by-side only + hunkStarts []int // line indices where hunks begin (for ]c/[c) + + // View state + viewMode DiffViewMode + yOffset int + width int + height int + + // Side-by-side state + syncScroll bool // default true + focus int // 0=left, 1=right + leftOffset int + rightOffset int + + // Search + searching bool + searchInput textinput.Model + searchQuery string + matchLines []int + matchIdx int +} +``` + +#### Key Bindings + +| Key | Action | +|-----|--------| +| `j` / `↓` | Scroll down | +| `k` / `↑` | Scroll up | +| `d` / `PgDn` | Page down | +| `u` / `PgUp` | Page up | +| `g` | Jump to top | +| `G` | Jump to bottom | +| `Tab` | Toggle Unified ↔ Side-by-Side | +| `h` / `l` | Switch focus left/right (side-by-side) | +| `/` | Start search | +| `n` / `N` | Next / previous search match | +| `]c` | Jump to next hunk | +| `[c` | Jump to previous hunk | +| `q` / `Esc` | Close diff view | +| Mouse wheel | Scroll | + +#### Messages + +```go +type DiffOpenMsg struct { + OldText string + NewText string + Language string // "sql", "go", "ndsl", "" (auto-detect) + Title string +} + +type DiffCloseMsg struct{} +``` + +### Integration (`app.go` changes) + +Add `diffView *DiffView` field to the root `App` model. + +When `diffView != nil`: +- `Update()` delegates all input to `diffView.Update()` +- `View()` renders `diffView.View()` as full-screen overlay +- On `DiffCloseMsg`, set `diffView = nil` + +## New Dependencies + +``` +github.com/sergi/go-diff v1.3.0 +``` + +## File Plan + +| File | Type | Lines | Purpose | +|------|------|-------|---------| +| `cmd/mxcli/tui/diffengine.go` | New | ~150 | Diff computation (line + word level) | +| `cmd/mxcli/tui/diffrender.go` | New | ~250 | Unified + Side-by-Side rendering | +| `cmd/mxcli/tui/diffview.go` | New | ~400 | Bubble Tea component | +| `cmd/mxcli/tui/app.go` | Modify | +30 | DiffView overlay integration | +| `go.mod` / `go.sum` | Modify | +2 | Add sergi/go-diff dependency | + +## Not in MVP + +- Integration with `mxcli diff` / `mxcli diff-local` CLI commands (separate issue) +- Context folding (collapsing unchanged regions) +- Hunk accept/reject (edit mode) +- Side-by-side synchronized line alignment for multi-line insertions diff --git a/go.mod b/go.mod index 1b810b5..136a549 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.3.4 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index 57f2be8..c5fb96a 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+Pauc= github.com/segmentio/encoding v0.3.4/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg= @@ -140,6 +142,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -218,11 +221,15 @@ google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGm google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 2e8232a865de007592fd4530d7ab720d28a76315 Mon Sep 17 00:00:00 2001 From: engalar Date: Tue, 24 Mar 2026 20:24:01 +0800 Subject: [PATCH 02/10] feat(tui): add Plain Diff mode and yank for LLM-friendly output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add third DiffView mode (Tab cycles: Unified → Side-by-Side → Plain Diff): - Plain Diff renders standard unified diff format (--- a/, +++ b/, @@ @@) - No ANSI colors — tmux capture-pane produces LLM-readable output - y key copies unified diff to clipboard for pasting to LLM LLM agents can see the "Tab mode" hint, press Tab to switch to Plain Diff, then tmux capture the standard diff format. --- cmd/mxcli/tui/diffrender.go | 104 +++++++++++++++++++++++++++++++++++ cmd/mxcli/tui/diffview.go | 107 +++++++++++++++++++++++++++++++++--- 2 files changed, 202 insertions(+), 9 deletions(-) diff --git a/cmd/mxcli/tui/diffrender.go b/cmd/mxcli/tui/diffrender.go index 645205f..e6c157e 100644 --- a/cmd/mxcli/tui/diffrender.go +++ b/cmd/mxcli/tui/diffrender.go @@ -22,6 +22,110 @@ var ( diffGutterRemovedFg = lipgloss.Color("#FF5F87") ) +// RenderPlainUnifiedDiff generates a standard unified diff string (no ANSI colors). +// This format is directly understood by LLMs and tools like patch/git. +func RenderPlainUnifiedDiff(result *DiffResult, oldTitle, newTitle string) string { + if result == nil || len(result.Lines) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- a/%s\n", oldTitle)) + sb.WriteString(fmt.Sprintf("+++ b/%s\n", newTitle)) + + // Generate hunks with context + lines := result.Lines + total := len(lines) + const contextLines = 3 + + // Find hunk boundaries: groups of changes with context + type hunkRange struct{ start, end int } + var hunks []hunkRange + + i := 0 + for i < total { + // Skip equal lines until we find a change + if lines[i].Type == DiffEqual { + i++ + continue + } + // Found a change — expand to include context + start := max(0, i-contextLines) + // Find end of this change group (including bridged gaps) + for i < total { + if lines[i].Type != DiffEqual { + i++ + continue + } + // Count consecutive equal lines + eqStart := i + for i < total && lines[i].Type == DiffEqual { + i++ + } + eqCount := i - eqStart + if i >= total || eqCount > contextLines*2 { + // Gap too large or end of file — close hunk + end := min(total, eqStart+contextLines) + hunks = append(hunks, hunkRange{start, end}) + break + } + // Small gap — bridge and continue + } + if len(hunks) == 0 || hunks[len(hunks)-1].end < i { + end := min(total, i+contextLines) + hunks = append(hunks, hunkRange{start, end}) + } + } + + // If no hunks (all equal), nothing to output + if len(hunks) == 0 { + return sb.String() + "@@ no differences @@\n" + } + + for _, h := range hunks { + // Count old/new lines in this hunk + oldStart, newStart := 0, 0 + oldCount, newCount := 0, 0 + for j := h.start; j < h.end; j++ { + dl := lines[j] + if j == h.start { + oldStart = max(1, dl.OldLineNo) + newStart = max(1, dl.NewLineNo) + if dl.Type == DiffInsert { + oldStart = max(1, dl.NewLineNo) // approximate + } + if dl.Type == DiffDelete { + newStart = max(1, dl.OldLineNo) + } + } + switch dl.Type { + case DiffEqual: + oldCount++ + newCount++ + case DiffDelete: + oldCount++ + case DiffInsert: + newCount++ + } + } + + sb.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)) + for j := h.start; j < h.end; j++ { + dl := lines[j] + switch dl.Type { + case DiffEqual: + sb.WriteString(" " + dl.Content + "\n") + case DiffDelete: + sb.WriteString("-" + dl.Content + "\n") + case DiffInsert: + sb.WriteString("+" + dl.Content + "\n") + } + } + } + + return sb.String() +} + // DiffRenderedLine holds the sticky prefix (gutter + line numbers) and scrollable content separately. type DiffRenderedLine struct { Prefix string // gutter char + line numbers (sticky, never scrolled) diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go index eaed701..d398690 100644 --- a/cmd/mxcli/tui/diffview.go +++ b/cmd/mxcli/tui/diffview.go @@ -15,6 +15,7 @@ type DiffViewMode int const ( DiffViewUnified DiffViewMode = iota DiffViewSideBySide + DiffViewPlainDiff // standard unified diff text (LLM-friendly) ) // DiffOpenMsg requests opening a diff view. @@ -41,6 +42,7 @@ type DiffView struct { unified []DiffRenderedLine // pre-rendered unified lines sideLeft []SideBySideRenderedLine // pre-rendered side-by-side left sideRight []SideBySideRenderedLine // pre-rendered side-by-side right + plainLines []string // standard unified diff text lines (LLM-friendly) hunkStarts []int // line indices where hunks begin // View state @@ -91,6 +93,11 @@ func NewDiffView(msg DiffOpenMsg, width, height int) DiffView { func (dv *DiffView) renderAll() { dv.unified = RenderUnifiedDiff(dv.result, dv.language) dv.sideLeft, dv.sideRight = RenderSideBySideDiff(dv.result, dv.language) + plain := RenderPlainUnifiedDiff(dv.result, "old", "new") + dv.plainLines = strings.Split(plain, "\n") + if len(dv.plainLines) > 0 && dv.plainLines[len(dv.plainLines)-1] == "" { + dv.plainLines = dv.plainLines[:len(dv.plainLines)-1] + } } func (dv *DiffView) computeHunkStarts() { @@ -118,10 +125,14 @@ func (dv *DiffView) SetSize(w, h int) { func (dv DiffView) IsVisible() bool { return true } func (dv DiffView) totalLines() int { - if dv.viewMode == DiffViewSideBySide { + switch dv.viewMode { + case DiffViewSideBySide: return len(dv.sideLeft) + case DiffViewPlainDiff: + return len(dv.plainLines) + default: + return len(dv.unified) } - return len(dv.unified) } func (dv DiffView) contentHeight() int { @@ -221,11 +232,14 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { case "l", "right": dv.xOffset += 8 - // View mode toggle + // View mode toggle: Unified → Side-by-Side → Plain Diff → Unified case "tab": - if dv.viewMode == DiffViewUnified { + switch dv.viewMode { + case DiffViewUnified: dv.viewMode = DiffViewSideBySide - } else { + case DiffViewSideBySide: + dv.viewMode = DiffViewPlainDiff + case DiffViewPlainDiff: dv.viewMode = DiffViewUnified } dv.yOffset = 0 @@ -233,6 +247,11 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { dv.leftOffset = 0 dv.rightOffset = 0 + // Yank unified diff to clipboard + case "y": + plain := RenderPlainUnifiedDiff(dv.result, "old", "new") + _ = writeClipboard(plain) + // Search case "/": dv.searching = true @@ -371,9 +390,14 @@ func (dv DiffView) View() string { delSt := lipgloss.NewStyle().Foreground(diffRemovedFg).Bold(true) // Title bar - modeLabel := "Unified" - if dv.viewMode == DiffViewSideBySide { + var modeLabel string + switch dv.viewMode { + case DiffViewUnified: + modeLabel = "Unified" + case DiffViewSideBySide: modeLabel = "Side-by-Side" + case DiffViewPlainDiff: + modeLabel = "Plain Diff (LLM)" } stats := "" if dv.result != nil { @@ -391,9 +415,12 @@ func (dv DiffView) View() string { // Content viewH := dv.contentHeight() var content string - if dv.viewMode == DiffViewSideBySide { + switch dv.viewMode { + case DiffViewSideBySide: content = dv.renderSideBySide(viewH) - } else { + case DiffViewPlainDiff: + content = dv.renderPlainDiff(viewH) + default: content = dv.renderUnified(viewH) } @@ -568,6 +595,68 @@ func (dv DiffView) renderSideBySide(viewH int) string { return sb.String() } +func (dv DiffView) renderPlainDiff(viewH int) string { + lines := dv.plainLines + total := len(lines) + showScrollbar := total > viewH + + trackSt := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + thumbSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + + var thumbStart, thumbEnd int + if showScrollbar { + thumbSize := max(1, viewH*viewH/total) + if m := dv.maxOffset(); m > 0 { + thumbStart = dv.yOffset * (viewH - thumbSize) / m + } + thumbEnd = thumbStart + thumbSize + } + + scrollW := 0 + if showScrollbar { + scrollW = 1 + } + contentW := dv.width - scrollW + + var sb strings.Builder + for vi := range viewH { + lineIdx := dv.yOffset + vi + var line string + if lineIdx < total { + line = lines[lineIdx] + // Apply horizontal scroll + if dv.xOffset > 0 && len(line) > dv.xOffset { + line = line[dv.xOffset:] + } else if dv.xOffset > 0 { + line = "" + } + // Truncate to width + if len(line) > contentW { + line = line[:contentW] + } + } + + // Pad to fill width + if pad := contentW - len(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + + if showScrollbar { + if vi >= thumbStart && vi < thumbEnd { + line += thumbSt.Render("█") + } else { + line += trackSt.Render("│") + } + } + + sb.WriteString(line) + if vi < viewH-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + func (dv DiffView) scrollPercent() int { m := dv.maxOffset() if m <= 0 { From 374399f8cef6fd1e7dc26d57fd5c7d940433a5c5 Mon Sep 17 00:00:00 2001 From: engalar Date: Tue, 24 Mar 2026 22:11:29 +0800 Subject: [PATCH 03/10] refactor(tui): introduce View interface, ViewStack, and UX improvements Replace ad-hoc view routing (bool/pointer priority chain) with a unified View interface and ViewStack pattern. All views (Browser, Overlay, Compare, Diff, Jumper) implement the same interface, enabling declarative chrome rendering and eliminating 30+ manual sync calls. Key changes: - View interface + ViewStack for composable view management - BrowserView wraps MillerView, absorbs action keys from app.go - OverlayView handles NDSL/MDL tab switching self-contained - Semantic color system (AdaptiveColor) replacing monochrome styles - Focus indicators: blue title + edge bar on focused column, faint unfocused - Global fuzzy jump (Space key) to navigate to any project node - Preview debounce (150ms) to prevent subprocess flooding during fast scroll - LLM anchor lines ([mxcli:mode]) for tmux capture-pane parsing - Clickable breadcrumb in status bar for mouse navigation - Enhanced chrome: mode badge, context summary, compact hint bar Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mxcli/tui/app.go | 752 ++++++++---------- cmd/mxcli/tui/browserview.go | 329 ++++++++ cmd/mxcli/tui/column.go | 34 +- cmd/mxcli/tui/compare.go | 164 +++- cmd/mxcli/tui/diffview.go | 70 +- cmd/mxcli/tui/fuzzy.go | 51 ++ cmd/mxcli/tui/help.go | 57 +- cmd/mxcli/tui/hintbar.go | 3 +- cmd/mxcli/tui/jumper.go | 212 +++++ cmd/mxcli/tui/miller.go | 61 +- cmd/mxcli/tui/overlay.go | 3 - cmd/mxcli/tui/overlayview.go | 182 +++++ cmd/mxcli/tui/statusbar.go | 75 +- cmd/mxcli/tui/styles.go | 45 -- cmd/mxcli/tui/theme.go | 57 ++ cmd/mxcli/tui/view.go | 58 ++ cmd/mxcli/tui/viewstack.go | 51 ++ cmd/mxcli/tui/viewstack_test.go | 119 +++ .../2026-03-24-tui-ux-refactor-design.md | 283 +++++++ ...-24-tui-ux-refactor-implementation-plan.md | 748 +++++++++++++++++ 20 files changed, 2807 insertions(+), 547 deletions(-) create mode 100644 cmd/mxcli/tui/browserview.go create mode 100644 cmd/mxcli/tui/fuzzy.go create mode 100644 cmd/mxcli/tui/jumper.go create mode 100644 cmd/mxcli/tui/overlayview.go delete mode 100644 cmd/mxcli/tui/styles.go create mode 100644 cmd/mxcli/tui/theme.go create mode 100644 cmd/mxcli/tui/view.go create mode 100644 cmd/mxcli/tui/viewstack.go create mode 100644 cmd/mxcli/tui/viewstack_test.go create mode 100644 docs/plans/2026-03-24-tui-ux-refactor-design.md create mode 100644 docs/plans/2026-03-24-tui-ux-refactor-implementation-plan.md diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 53aeb93..b6503fb 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -2,9 +2,7 @@ package tui import ( "fmt" - "os" "strings" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -26,20 +24,13 @@ type App struct { height int mxcliPath string - overlay Overlay - compare CompareView - diffView *DiffView + views ViewStack showHelp bool picker *PickerModel // non-nil when cross-project picker is open - // Overlay switch state - overlayQName string - overlayNodeType string - overlayIsNDSL bool - tabBar TabBar - statusBar StatusBar hintBar HintBar + statusBar StatusBar previewEngine *PreviewEngine } @@ -51,11 +42,12 @@ func NewApp(mxcliPath, projectPath string) App { engine := NewPreviewEngine(mxcliPath, projectPath) tab := NewTab(1, projectPath, engine, nil) + browserView := NewBrowserView(&tab, mxcliPath, engine) + app := App{ mxcliPath: mxcliPath, nextTabID: 2, - overlay: NewOverlay(), - compare: NewCompareView(), + views: NewViewStack(browserView), tabBar: NewTabBar(nil), statusBar: NewStatusBar(), hintBar: NewHintBar(ListBrowsingHints), @@ -73,6 +65,14 @@ func (a *App) activeTabPtr() *Tab { return nil } +func (a *App) activeTabProjectPath() string { + tab := a.activeTabPtr() + if tab != nil { + return tab.ProjectPath + } + return "" +} + func (a *App) syncTabBar() { infos := make([]TabInfo, len(a.tabs)) for i, t := range a.tabs { @@ -81,40 +81,15 @@ func (a *App) syncTabBar() { a.tabBar.SetTabs(infos) } -func (a *App) syncStatusBar() { +func (a *App) syncBrowserView() { tab := a.activeTabPtr() if tab == nil { return } - crumbs := tab.Miller.Breadcrumb() - a.statusBar.SetBreadcrumb(crumbs) - - mode := "MDL" - if tab.Miller.preview.mode == PreviewNDSL { - mode = "NDSL" - } - a.statusBar.SetMode(mode) - - col := tab.Miller.current - pos := fmt.Sprintf("%d/%d", col.cursor+1, col.ItemCount()) - a.statusBar.SetPosition(pos) -} - -func (a *App) syncHintBar() { - if a.diffView != nil { - a.hintBar.SetHints(DiffViewHints) - } else if a.overlay.IsVisible() { - a.hintBar.SetHints(OverlayHints) - } else if a.compare.IsVisible() { - a.hintBar.SetHints(CompareHints) - } else { - tab := a.activeTabPtr() - if tab != nil && tab.Miller.focusedColumn().IsFilterActive() { - a.hintBar.SetHints(FilterActiveHints) - } else { - a.hintBar.SetHints(ListBrowsingHints) - } - } + bv := NewBrowserView(tab, a.mxcliPath, a.previewEngine) + bv.allNodes = tab.AllNodes + bv.compareItems = flattenQualifiedNames(tab.AllNodes) + a.views.base = bv } // --- Init --- @@ -141,50 +116,18 @@ func (a App) Init() tea.Cmd { func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case PickerDoneMsg: - Trace("app: PickerDoneMsg path=%q", msg.Path) - a.picker = nil - if msg.Path != "" { - SaveHistory(msg.Path) - engine := NewPreviewEngine(a.mxcliPath, msg.Path) - newTab := NewTab(a.nextTabID, msg.Path, engine, nil) - a.nextTabID++ - a.tabs = append(a.tabs, newTab) - a.activeTab = len(a.tabs) - 1 - a.resizeAll() - a.syncTabBar() - a.syncStatusBar() - a.syncHintBar() - // Load project tree for new tab - tabID := newTab.ID - mxcliPath := a.mxcliPath - projectPath := msg.Path - return a, func() tea.Msg { - out, err := runMxcli(mxcliPath, "project-tree", "-p", projectPath) - if err != nil { - return LoadTreeMsg{TabID: tabID, Err: err} - } - nodes, parseErr := ParseTree(out) - return LoadTreeMsg{TabID: tabID, Nodes: nodes, Err: parseErr} - } - } - a.syncHintBar() + // --- ViewStack navigation --- + case PushViewMsg: + a.views.Push(msg.View) return a, nil - - case DiffOpenMsg: - dv := NewDiffView(msg, a.width, a.height) - a.diffView = &dv - a.syncHintBar() - return a, nil - - case DiffCloseMsg: - a.diffView = nil - a.syncHintBar() + case PopViewMsg: + a.views.Pop() return a, nil + // --- View creation messages --- case OpenOverlayMsg: - a.overlay.Show(msg.Title, msg.Content, a.width, a.height) - a.syncHintBar() + ov := NewOverlayView(msg.Title, msg.Content, a.width, a.height, OverlayViewOpts{}) + a.views.Push(ov) return a, nil case OpenImageOverlayMsg: @@ -211,41 +154,118 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return OpenOverlayMsg{Title: title, Content: content} } + case JumpToNodeMsg: + // Pop the jumper view first + a.views.Pop() + // Navigate browser to the target node + if bv, ok := a.views.base.(BrowserView); ok { + cmd := bv.navigateToNode(msg.QName) + a.views.base = bv + if tab := a.activeTabPtr(); tab != nil { + tab.Miller = bv.miller + tab.UpdateLabel() + a.syncTabBar() + } + return a, cmd + } + return a, nil + + case DiffOpenMsg: + dv := NewDiffView(msg, a.width, a.height) + a.views.Push(dv) + return a, nil + + case PickerDoneMsg: + Trace("app: PickerDoneMsg path=%q", msg.Path) + a.picker = nil + if msg.Path != "" { + SaveHistory(msg.Path) + engine := NewPreviewEngine(a.mxcliPath, msg.Path) + newTab := NewTab(a.nextTabID, msg.Path, engine, nil) + a.nextTabID++ + a.tabs = append(a.tabs, newTab) + a.activeTab = len(a.tabs) - 1 + a.syncBrowserView() + a.syncTabBar() + tabID := newTab.ID + mxcliPath := a.mxcliPath + projectPath := msg.Path + return a, func() tea.Msg { + out, err := runMxcli(mxcliPath, "project-tree", "-p", projectPath) + if err != nil { + return LoadTreeMsg{TabID: tabID, Err: err} + } + nodes, parseErr := ParseTree(out) + return LoadTreeMsg{TabID: tabID, Nodes: nodes, Err: parseErr} + } + } + return a, nil + case CompareLoadMsg: - a.compare.SetContent(msg.Side, msg.Title, msg.NodeType, msg.Content) + if cv, ok := a.views.Active().(CompareView); ok { + cv.SetContent(msg.Side, msg.Title, msg.NodeType, msg.Content) + a.views.SetActive(cv) + } return a, nil case ComparePickMsg: - a.compare.SetLoading(msg.Side) - return a, a.loadForCompare(msg.QName, msg.NodeType, msg.Side, msg.Kind) + if cv, ok := a.views.Active().(CompareView); ok { + cv.SetLoading(msg.Side) + a.views.SetActive(cv) + return a, cv.loadForCompare(msg.QName, msg.NodeType, msg.Side, msg.Kind) + } + return a, nil case CompareReloadMsg: - var cmds []tea.Cmd - if a.compare.left.qname != "" { - a.compare.SetLoading(CompareFocusLeft) - cmds = append(cmds, a.loadForCompare(a.compare.left.qname, a.compare.left.nodeType, CompareFocusLeft, msg.Kind)) - } - if a.compare.right.qname != "" { - a.compare.SetLoading(CompareFocusRight) - cmds = append(cmds, a.loadForCompare(a.compare.right.qname, a.compare.right.nodeType, CompareFocusRight, msg.Kind)) + if cv, ok := a.views.Active().(CompareView); ok { + var cmds []tea.Cmd + if cv.left.qname != "" { + cv.SetLoading(CompareFocusLeft) + cmds = append(cmds, cv.loadForCompare(cv.left.qname, cv.left.nodeType, CompareFocusLeft, msg.Kind)) + } + if cv.right.qname != "" { + cv.SetLoading(CompareFocusRight) + cmds = append(cmds, cv.loadForCompare(cv.right.qname, cv.right.nodeType, CompareFocusRight, msg.Kind)) + } + a.views.SetActive(cv) + return a, tea.Batch(cmds...) } - return a, tea.Batch(cmds...) + return a, nil case overlayFlashClearMsg: - a.overlay.copiedFlash = false - return a, nil + // Forward to active view (Overlay handles this internally) + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + return a, cmd case compareFlashClearMsg: - a.compare.copiedFlash = false + if cv, ok := a.views.Active().(CompareView); ok { + cv.copiedFlash = false + a.views.SetActive(cv) + } + return a, nil + + case overlayContentMsg: + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + return a, cmd + + case CmdResultMsg: + content := msg.Output + if msg.Err != nil { + content = "-- Error:\n" + msg.Output + } + ov := NewOverlayView("Result", DetectAndHighlight(content), a.width, a.height, OverlayViewOpts{}) + a.views.Push(ov) return a, nil case tea.KeyMsg: - Trace("app: key=%q picker=%v overlay=%v compare=%v help=%v", msg.String(), a.picker != nil, a.overlay.IsVisible(), a.compare.IsVisible(), a.showHelp) + Trace("app: key=%q picker=%v mode=%v help=%v", msg.String(), a.picker != nil, a.views.Active().Mode(), a.showHelp) if msg.String() == "ctrl+c" { return a, tea.Quit } - // Picker modal + // Picker modal (not a View — special case) if a.picker != nil { result, cmd := a.picker.Update(msg) p := result.(PickerModel) @@ -253,55 +273,49 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } - // Fullscreen modes - if a.diffView != nil { - var cmd tea.Cmd - *a.diffView, cmd = a.diffView.Update(msg) - return a, cmd + // Help toggle (global, only in Browser mode) + if a.showHelp { + a.showHelp = false + return a, nil } - if a.compare.IsVisible() { - var cmd tea.Cmd - a.compare, cmd = a.compare.Update(msg) - if !a.compare.IsVisible() { - a.syncHintBar() + if msg.String() == "?" && a.views.Active().Mode() == ModeBrowser { + a.showHelp = !a.showHelp + return a, nil + } + + // Tab management and app-level keys (only in Browser mode) + if a.views.Active().Mode() == ModeBrowser { + if cmd := a.handleBrowserAppKeys(msg); cmd != nil { + return a, cmd } - return a, cmd } - if a.overlay.IsVisible() { - if msg.String() == "tab" && a.overlayQName != "" && !a.overlay.content.IsSearching() { - a.overlayIsNDSL = !a.overlayIsNDSL - if a.overlayIsNDSL { - bsonType := inferBsonType(a.overlayNodeType) - return a, a.runBsonOverlay(bsonType, a.overlayQName) + + // Delegate to active view + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + + // Sync tab label if browser view + if a.views.Active().Mode() == ModeBrowser { + tab := a.activeTabPtr() + if tab != nil { + if bv, ok := a.views.Active().(BrowserView); ok { + tab.Miller = bv.miller + tab.UpdateLabel() + a.syncTabBar() } - return a, a.runMDLOverlay(a.overlayNodeType, a.overlayQName) } - var cmd tea.Cmd - a.overlay, cmd = a.overlay.Update(msg) - if !a.overlay.IsVisible() { - a.syncHintBar() - } - return a, cmd } - if a.showHelp { - a.showHelp = false - return a, nil - } - - return a.updateNormalMode(msg) + return a, cmd case tea.MouseMsg: Trace("app: mouse x=%d y=%d btn=%v action=%v", msg.X, msg.Y, msg.Button, msg.Action) - if a.diffView != nil { - var cmd tea.Cmd - *a.diffView, cmd = a.diffView.Update(msg) - return a, cmd - } - if a.picker != nil || a.compare.IsVisible() || a.overlay.IsVisible() { + if a.picker != nil { return a, nil } - // Tab bar clicks (row 0) - if msg.Y == 0 && msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + + // Tab bar clicks (row 0) — only when in browser mode + if msg.Y == 0 && a.views.Active().Mode() == ModeBrowser && + msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { if clickMsg := a.tabBar.HandleClick(msg.X); clickMsg != nil { if tc, ok := clickMsg.(TabClickMsg); ok { a.switchToTabByID(tc.ID) @@ -309,27 +323,45 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - // Forward to Miller (offset Y by -1 for tab bar) - tab := a.activeTabPtr() - if tab != nil { - millerMsg := tea.MouseMsg{ + + // Status bar clicks (last line) — breadcrumb navigation + if msg.Y == a.height-1 && a.views.Active().Mode() == ModeBrowser && + msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + if depth, ok := a.statusBar.HitTest(msg.X); ok { + if bv, ok := a.views.Active().(BrowserView); ok { + var cmd tea.Cmd + bv.miller, cmd = bv.miller.goBackToDepth(depth) + a.views.SetActive(bv) + if tab := a.activeTabPtr(); tab != nil { + tab.Miller = bv.miller + tab.UpdateLabel() + a.syncTabBar() + } + return a, cmd + } + } + } + + // Offset Y by -1 for tab bar when in browser mode + if a.views.Active().Mode() == ModeBrowser { + offsetMsg := tea.MouseMsg{ X: msg.X, Y: msg.Y - 1, Button: msg.Button, Action: msg.Action, } - var cmd tea.Cmd - tab.Miller, cmd = tab.Miller.Update(millerMsg) - a.syncStatusBar() + updated, cmd := a.views.Active().Update(offsetMsg) + a.views.SetActive(updated) return a, cmd } + // Forward to active view + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + return a, cmd + case tea.WindowSizeMsg: Trace("app: resize %dx%d", msg.Width, msg.Height) a.width = msg.Width a.height = msg.Height - if a.diffView != nil { - a.diffView.SetSize(msg.Width, msg.Height) - } - a.resizeAll() return a, nil case LoadTreeMsg: @@ -339,74 +371,73 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if tab != nil { tab.AllNodes = msg.Nodes tab.Miller.SetRootNodes(msg.Nodes) - a.compare.SetItems(flattenQualifiedNames(msg.Nodes)) - a.syncStatusBar() a.syncTabBar() + // Update browser view if it's the base + if bv, ok := a.views.base.(BrowserView); ok { + bv.allNodes = msg.Nodes + bv.compareItems = flattenQualifiedNames(msg.Nodes) + bv.miller = tab.Miller + a.views.base = bv + } } } + return a, nil case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg: - tab := a.activeTabPtr() - if tab != nil { - var cmd tea.Cmd - tab.Miller, cmd = tab.Miller.Update(msg) - a.syncStatusBar() + if a.views.Active().Mode() == ModeBrowser { + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + // Sync miller back to tab + if bv, ok := updated.(BrowserView); ok { + tab := a.activeTabPtr() + if tab != nil { + tab.Miller = bv.miller + } + } return a, cmd } + return a, nil - case CmdResultMsg: - content := msg.Output - if msg.Err != nil { - content = "-- Error:\n" + msg.Output - } - a.overlayQName = "" - a.overlay.switchable = false - a.overlay.Show("Result", DetectAndHighlight(content), a.width, a.height) - a.syncHintBar() + default: + // Forward everything else to active view + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + return a, cmd } - return a, nil } -func (a App) updateNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +// handleBrowserAppKeys handles keys that App intercepts when in Browser mode. +// Returns a non-nil tea.Cmd if the key was handled, nil if the key should +// be forwarded to the active view. +func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { tab := a.activeTabPtr() - // If filter is active, forward to miller - if tab != nil && tab.Miller.focusedColumn().IsFilterActive() { - var cmd tea.Cmd - tab.Miller, cmd = tab.Miller.Update(msg) - a.syncHintBar() - a.syncStatusBar() - return a, cmd - } - switch msg.String() { case "q": for i := range a.tabs { a.tabs[i].Miller.previewEngine.Cancel() } CloseTrace() - return a, tea.Quit - case "?": - a.showHelp = !a.showHelp - return a, nil + return tea.Quit - // Tab management case "t": if tab != nil { newTab := tab.CloneTab(a.nextTabID, a.previewEngine) a.nextTabID++ a.tabs = append(a.tabs, newTab) a.activeTab = len(a.tabs) - 1 + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return func() tea.Msg { return nil } + case "T": p := NewEmbeddedPicker() p.width = a.width p.height = a.height a.picker = &p - return a, nil + return func() tea.Msg { return nil } + case "W": if len(a.tabs) > 1 { a.tabs[a.activeTab].Miller.previewEngine.Cancel() @@ -414,100 +445,65 @@ func (a App) updateNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if a.activeTab >= len(a.tabs) { a.activeTab = len(a.tabs) - 1 } - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return func() tea.Msg { return nil } + case "1", "2", "3", "4", "5", "6", "7", "8", "9": idx := int(msg.String()[0]-'0') - 1 if idx >= 0 && idx < len(a.tabs) { a.activeTab = idx - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return func() tea.Msg { return nil } + case "[": if a.activeTab > 0 { a.activeTab-- - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return func() tea.Msg { return nil } + case "]": if a.activeTab < len(a.tabs)-1 { a.activeTab++ - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return func() tea.Msg { return nil } - // Actions on selected node - case "b": - if tab != nil { - if node := tab.Miller.SelectedNode(); node != nil && node.QualifiedName != "" { - if bsonType := inferBsonType(node.Type); bsonType != "" { - a.overlayQName = node.QualifiedName - a.overlayNodeType = node.Type - a.overlayIsNDSL = true - a.overlay.switchable = true - return a, a.runBsonOverlay(bsonType, node.QualifiedName) - } - } - } - case "m": + case "r": + return a.Init() + + case " ": if tab != nil { - if node := tab.Miller.SelectedNode(); node != nil && node.QualifiedName != "" { - a.overlayQName = node.QualifiedName - a.overlayNodeType = node.Type - a.overlayIsNDSL = false - a.overlay.switchable = true - return a, a.runMDLOverlay(node.Type, node.QualifiedName) - } + items := flattenQualifiedNames(tab.AllNodes) + jumper := NewJumperView(items, a.width, a.height) + a.views.Push(jumper) } + return func() tea.Msg { return nil } + case "c": - a.compare.Show(CompareNDSL, a.width, a.height) - if tab != nil { - a.compare.SetItems(flattenQualifiedNames(tab.AllNodes)) - if node := tab.Miller.SelectedNode(); node != nil && node.QualifiedName != "" { - a.compare.SetLoading(CompareFocusLeft) - a.syncHintBar() - return a, a.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft) - } - } - a.syncHintBar() - return a, nil - case "d": + cv := NewCompareView() + cv.mxcliPath = a.mxcliPath + cv.projectPath = a.activeTabProjectPath() + cv.Show(CompareNDSL, a.width, a.height) if tab != nil { + cv.SetItems(flattenQualifiedNames(tab.AllNodes)) if node := tab.Miller.SelectedNode(); node != nil && node.QualifiedName != "" { - return a, a.openDiagram(node.Type, node.QualifiedName) + cv.SetLoading(CompareFocusLeft) + a.views.Push(cv) + return cv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft) } } - case "y": - // Copy preview content to clipboard - if tab != nil && tab.Miller.preview.content != "" { - raw := stripAnsi(tab.Miller.preview.content) - _ = writeClipboard(raw) - } - return a, nil - case "r": - return a, a.Init() + a.views.Push(cv) + return func() tea.Msg { return nil } } - // Forward to Miller - if tab != nil { - var cmd tea.Cmd - tab.Miller, cmd = tab.Miller.Update(msg) - tab.UpdateLabel() - a.syncTabBar() - a.syncStatusBar() - a.syncHintBar() - return a, cmd - } - return a, nil + return nil } func (a *App) findTabByID(id int) *Tab { @@ -523,28 +519,13 @@ func (a *App) switchToTabByID(id int) { for i, t := range a.tabs { if t.ID == id { a.activeTab = i - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() return } } } -func (a *App) resizeAll() { - if a.width == 0 || a.height == 0 { - return - } - millerH := a.height - chromeHeight - if millerH < 5 { - millerH = 5 - } - tab := a.activeTabPtr() - if tab != nil { - tab.Miller.SetSize(a.width, millerH) - } -} - // --- View --- func (a App) View() string { @@ -555,40 +536,70 @@ func (a App) View() string { if a.picker != nil { return a.picker.View() } - if a.diffView != nil { - return a.diffView.View() - } - if a.compare.IsVisible() { - return a.compare.View() - } - if a.overlay.IsVisible() { - return a.overlay.View() - } - a.syncStatusBar() + active := a.views.Active() - // Tab bar (line 1) - tabLine := a.tabBar.View(a.width) + // For non-browser views, delegate rendering entirely + if active.Mode() != ModeBrowser { + contentH := a.height + content := active.Render(a.width, contentH) + + if a.showHelp { + helpView := renderHelp(a.width, a.height) + content = lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, helpView, + lipgloss.WithWhitespaceBackground(lipgloss.Color("0"))) + } - // Miller columns (main area) - millerH := a.height - chromeHeight - if millerH < 5 { - millerH = 5 + return content } + + // Browser mode: App renders chrome (tab bar, hint bar, status bar) + + // Tab bar (line 1) with mode badge + context summary on the right + tabLine := a.tabBar.View(a.width) tab := a.activeTabPtr() - var millerView string if tab != nil { - tab.Miller.SetSize(a.width, millerH) - millerView = tab.Miller.View() - } else { - millerView = strings.Repeat("\n", millerH-1) + modeBadge := AccentStyle.Render(active.Mode().String()) + summary := renderContextSummary(tab.AllNodes) + rightSide := modeBadge + if summary != "" { + rightSide += BreadcrumbDimStyle.Render(" │ ") + BreadcrumbDimStyle.Render(summary) + } + rightWidth := lipgloss.Width(rightSide) + 1 // 1 char right padding + tabWidth := lipgloss.Width(tabLine) + if tabWidth+rightWidth <= a.width { + // Replace trailing spaces with gap + right side + trimmed := strings.TrimRight(tabLine, " ") + trimmedWidth := lipgloss.Width(trimmed) + gap := a.width - trimmedWidth - rightWidth + if gap < 2 { + gap = 2 + } + tabLine = trimmed + strings.Repeat(" ", gap) + rightSide + " " + } + } + + // Content area + contentH := a.height - chromeHeight + if contentH < 5 { + contentH = 5 } + content := active.Render(a.width, contentH) - // Hint bar + Status bar (bottom 2 lines) + // Hint bar — declarative from active view + a.hintBar.SetHints(active.Hints()) hintLine := a.hintBar.View(a.width) + + // Status bar — declarative from active view + info := active.StatusInfo() + a.statusBar.SetBreadcrumb(info.Breadcrumb) + a.statusBar.SetPosition(info.Position) + a.statusBar.SetMode(info.Mode) + viewModeNames := a.collectViewModeNames() + a.statusBar.SetViewDepth(a.views.Depth(), viewModeNames) statusLine := StatusBarStyle.Width(a.width).Render(a.statusBar.View(a.width)) - rendered := tabLine + "\n" + millerView + "\n" + hintLine + "\n" + statusLine + rendered := tabLine + "\n" + content + "\n" + hintLine + "\n" + statusLine if a.showHelp { helpView := renderHelp(a.width, a.height) @@ -599,37 +610,7 @@ func (a App) View() string { return rendered } -// --- Load helpers (ported from old model.go) --- - -func (a App) openDiagram(nodeType, qualifiedName string) tea.Cmd { - tab := a.activeTabPtr() - if tab == nil { - return nil - } - mxcliPath := a.mxcliPath - projectPath := tab.ProjectPath - return func() tea.Msg { - out, err := runMxcli(mxcliPath, "describe", "-p", projectPath, - "--format", "elk", nodeType, qualifiedName) - if err != nil { - return CmdResultMsg{Output: out, Err: err} - } - htmlContent := buildDiagramHTML(out, nodeType, qualifiedName) - tmpFile, err := os.CreateTemp("", "mxcli-diagram-*.html") - if err != nil { - return CmdResultMsg{Err: err} - } - if _, err := tmpFile.WriteString(htmlContent); err != nil { - tmpFile.Close() - return CmdResultMsg{Err: fmt.Errorf("writing diagram HTML: %w", err)} - } - tmpFile.Close() - tmpPath := tmpFile.Name() - openBrowser(tmpPath) - time.AfterFunc(30*time.Second, func() { os.Remove(tmpPath) }) - return CmdResultMsg{Output: fmt.Sprintf("Opened diagram: %s", tmpPath)} - } -} +// --- Load helpers --- func buildDiagramHTML(elkJSON, nodeType, qualifiedName string) string { return fmt.Sprintf(` @@ -646,105 +627,60 @@ ELK.layout(elkData).then(graph=>{ `, nodeType, qualifiedName, elkJSON) } -func (a App) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd { - tab := a.activeTabPtr() - if tab == nil { - return nil - } - mxcliPath := a.mxcliPath - projectPath := tab.ProjectPath - return func() tea.Msg { - bsonType := inferBsonType(nodeType) - if bsonType == "" { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, - Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType), - Err: fmt.Errorf("unsupported type")} - } - args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", - "--type", bsonType, "--object", qname} - out, err := runMxcli(mxcliPath, args...) - out = StripBanner(out) - if err != nil { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} - } - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)} - } +// CmdResultMsg carries output from any mxcli command. +type CmdResultMsg struct { + Output string + Err error } -func (a App) loadMDL(qname, nodeType string, side CompareFocus) tea.Cmd { - tab := a.activeTabPtr() - if tab == nil { - return nil - } - mxcliPath := a.mxcliPath - projectPath := tab.ProjectPath - return func() tea.Msg { - out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname)) - out = StripBanner(out) - if err != nil { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} - } - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: DetectAndHighlight(out)} +// renderContextSummary counts top-level node types and returns a compact summary. +func renderContextSummary(nodes []*TreeNode) string { + if len(nodes) == 0 { + return "" } -} - -func (a App) loadForCompare(qname, nodeType string, side CompareFocus, kind CompareKind) tea.Cmd { - switch kind { - case CompareNDSL: - return a.loadBsonNDSL(qname, nodeType, side) - case CompareNDSLMDL: - if side == CompareFocusLeft { - return a.loadBsonNDSL(qname, nodeType, side) - } - return a.loadMDL(qname, nodeType, side) - case CompareMDL: - return a.loadMDL(qname, nodeType, side) + counts := map[string]int{} + for _, n := range nodes { + counts[n.Type]++ } - return nil -} - -func (a App) runBsonOverlay(bsonType, qname string) tea.Cmd { - tab := a.activeTabPtr() - if tab == nil { - return nil + // Display in a predictable order + order := []struct { + key string + plural string + }{ + {"Module", "modules"}, + {"Entity", "entities"}, + {"Microflow", "microflows"}, + {"Page", "pages"}, + {"Nanoflow", "nanoflows"}, + {"Enumeration", "enumerations"}, } - mxcliPath := a.mxcliPath - projectPath := tab.ProjectPath - return func() tea.Msg { - args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", - "--type", bsonType, "--object", qname} - out, err := runMxcli(mxcliPath, args...) - out = StripBanner(out) - title := fmt.Sprintf("BSON: %s", qname) - if err != nil { - return OpenOverlayMsg{Title: title, Content: "Error: " + out} + var parts []string + used := map[string]bool{} + for _, o := range order { + if c, ok := counts[o.key]; ok { + parts = append(parts, fmt.Sprintf("%d %s", c, o.plural)) + used[o.key] = true } - return OpenOverlayMsg{Title: title, Content: HighlightNDSL(out)} } -} - -func (a App) runMDLOverlay(nodeType, qname string) tea.Cmd { - tab := a.activeTabPtr() - if tab == nil { - return nil - } - mxcliPath := a.mxcliPath - projectPath := tab.ProjectPath - return func() tea.Msg { - out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname)) - out = StripBanner(out) - title := fmt.Sprintf("MDL: %s", qname) - if err != nil { - return OpenOverlayMsg{Title: title, Content: "Error: " + out} + // Add remaining types not in the predefined order + for k, c := range counts { + if !used[k] { + parts = append(parts, fmt.Sprintf("%d %s", c, strings.ToLower(k)+"s")) } - return OpenOverlayMsg{Title: title, Content: DetectAndHighlight(out)} } + if len(parts) > 3 { + parts = parts[:3] + } + return strings.Join(parts, ", ") } -// CmdResultMsg carries output from any mxcli command. -type CmdResultMsg struct { - Output string - Err error +// collectViewModeNames returns the mode names for all views in the stack. +func (a App) collectViewModeNames() []string { + names := []string{a.views.base.Mode().String()} + for _, v := range a.views.stack { + names = append(names, v.Mode().String()) + } + return names } // inferBsonType maps tree node types to valid bson object types. diff --git a/cmd/mxcli/tui/browserview.go b/cmd/mxcli/tui/browserview.go new file mode 100644 index 0000000..51dbe90 --- /dev/null +++ b/cmd/mxcli/tui/browserview.go @@ -0,0 +1,329 @@ +package tui + +import ( + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// BrowserView wraps MillerView and absorbs action keys from the normal browsing mode. +// It implements the View interface. +type BrowserView struct { + miller MillerView + tab *Tab + allNodes []*TreeNode + compareItems []PickerItem + mxcliPath string + projectPath string + previewEngine *PreviewEngine + + // Overlay state for NDSL/MDL tab switching context + overlayQName string + overlayNodeType string + overlayIsNDSL bool +} + +// NewBrowserView creates a BrowserView wrapping the Miller view from the given tab. +func NewBrowserView(tab *Tab, mxcliPath string, engine *PreviewEngine) BrowserView { + return BrowserView{ + miller: tab.Miller, + tab: tab, + allNodes: tab.AllNodes, + mxcliPath: mxcliPath, + projectPath: tab.ProjectPath, + previewEngine: engine, + } +} + +// Mode returns ModeBrowser. +func (bv BrowserView) Mode() ViewMode { + return ModeBrowser +} + +// Hints returns context-sensitive hints for the browser view. +func (bv BrowserView) Hints() []Hint { + if bv.miller.focusedColumn().IsFilterActive() { + return FilterActiveHints + } + return ListBrowsingHints +} + +// StatusInfo builds status bar data from the Miller view state. +func (bv BrowserView) StatusInfo() StatusInfo { + crumbs := bv.miller.Breadcrumb() + + mode := "MDL" + if bv.miller.preview.mode == PreviewNDSL { + mode = "NDSL" + } + + col := bv.miller.current + position := fmt.Sprintf("%d/%d", col.cursor+1, col.ItemCount()) + + return StatusInfo{ + Breadcrumb: crumbs, + Position: position, + Mode: mode, + } +} + +// Render sets the miller size and returns its rendered output with an LLM anchor prefix. +func (bv BrowserView) Render(width, height int) string { + bv.miller.SetSize(width, height) + rendered := bv.miller.View() + + // Embed LLM anchor as muted prefix on the first line + info := bv.StatusInfo() + anchor := fmt.Sprintf("[mxcli:browse] %s %s %s", + strings.Join(info.Breadcrumb, " > "), info.Position, info.Mode) + anchorStr := lipgloss.NewStyle().Foreground(MutedColor).Faint(true).Render(anchor) + + if idx := strings.IndexByte(rendered, '\n'); idx >= 0 { + rendered = anchorStr + rendered[idx:] + } else { + rendered = anchorStr + } + return rendered +} + +// Update handles messages for the browser view. +// Keys not handled here (q, ?, t, T, W, 1-9, [, ], ctrl+c) return (bv, nil) +// so App can handle them. +func (bv BrowserView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg: + var cmd tea.Cmd + bv.miller, cmd = bv.miller.Update(msg) + return bv, cmd + + case tea.MouseMsg: + var cmd tea.Cmd + bv.miller, cmd = bv.miller.Update(msg) + return bv, cmd + + case tea.KeyMsg: + return bv.handleKey(msg) + } + + return bv, nil +} + +func (bv BrowserView) handleKey(msg tea.KeyMsg) (View, tea.Cmd) { + // If filter is active, forward all keys to miller + if bv.miller.focusedColumn().IsFilterActive() { + var cmd tea.Cmd + bv.miller, cmd = bv.miller.Update(msg) + return bv, cmd + } + + switch msg.String() { + case "b": + node := bv.miller.SelectedNode() + if node != nil && node.QualifiedName != "" { + if bsonType := inferBsonType(node.Type); bsonType != "" { + bv.overlayQName = node.QualifiedName + bv.overlayNodeType = node.Type + bv.overlayIsNDSL = true + return bv, bv.runBsonOverlay(bsonType, node.QualifiedName) + } + } + return bv, nil + + case "m": + node := bv.miller.SelectedNode() + if node != nil && node.QualifiedName != "" { + bv.overlayQName = node.QualifiedName + bv.overlayNodeType = node.Type + bv.overlayIsNDSL = false + return bv, bv.runMDLOverlay(node.Type, node.QualifiedName) + } + return bv, nil + + case "c": + node := bv.miller.SelectedNode() + var loadCmd tea.Cmd + if node != nil && node.QualifiedName != "" { + loadCmd = bv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft) + } + return bv, loadCmd + + case "d": + node := bv.miller.SelectedNode() + if node != nil && node.QualifiedName != "" { + return bv, bv.openDiagram(node.Type, node.QualifiedName) + } + return bv, nil + + case "y": + if bv.miller.preview.content != "" { + raw := stripAnsi(bv.miller.preview.content) + _ = writeClipboard(raw) + } + return bv, nil + + case "r": + // Return nil — App handles refresh via Init() + return bv, nil + + case "z": + bv.miller.zenMode = !bv.miller.zenMode + bv.miller.relayout() + return bv, nil + } + + // Navigation keys: forward to miller + switch msg.String() { + case "j", "k", "g", "G", "h", "l", "left", "right", "up", "down", + "enter", "tab", "/", "n", "N": + var cmd tea.Cmd + bv.miller, cmd = bv.miller.Update(msg) + return bv, cmd + } + + // Keys not handled: q, ?, t, T, W, 1-9, [, ], ctrl+c — let App handle + return bv, nil +} + +// --- Load helpers (moved from app.go) --- + +func (bv BrowserView) runBsonOverlay(bsonType, qname string) tea.Cmd { + mxcliPath := bv.mxcliPath + projectPath := bv.projectPath + return func() tea.Msg { + args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", + "--type", bsonType, "--object", qname} + out, err := runMxcli(mxcliPath, args...) + out = StripBanner(out) + title := fmt.Sprintf("BSON: %s", qname) + if err != nil { + return OpenOverlayMsg{Title: title, Content: "Error: " + out} + } + return OpenOverlayMsg{Title: title, Content: HighlightNDSL(out)} + } +} + +func (bv BrowserView) runMDLOverlay(nodeType, qname string) tea.Cmd { + mxcliPath := bv.mxcliPath + projectPath := bv.projectPath + return func() tea.Msg { + out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname)) + out = StripBanner(out) + title := fmt.Sprintf("MDL: %s", qname) + if err != nil { + return OpenOverlayMsg{Title: title, Content: "Error: " + out} + } + return OpenOverlayMsg{Title: title, Content: DetectAndHighlight(out)} + } +} + +func (bv BrowserView) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd { + mxcliPath := bv.mxcliPath + projectPath := bv.projectPath + return func() tea.Msg { + bsonType := inferBsonType(nodeType) + if bsonType == "" { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, + Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType), + Err: fmt.Errorf("unsupported type")} + } + args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", + "--type", bsonType, "--object", qname} + out, err := runMxcli(mxcliPath, args...) + out = StripBanner(out) + if err != nil { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} + } + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)} + } +} + +// navigateToNode resets the miller view to root and drills down to the node +// matching the given qualified name. Returns a preview request command. +func (bv *BrowserView) navigateToNode(qname string) tea.Cmd { + path := findNodePath(bv.allNodes, qname) + if len(path) == 0 { + return nil + } + + // Reset to root + bv.miller.SetRootNodes(bv.allNodes) + + // Drill in for each intermediate node in the path (all except the last) + for i := 0; i < len(path)-1; i++ { + node := path[i] + // Find this node's index in the current column + idx := -1 + for j, item := range bv.miller.current.items { + if item.Node == node { + idx = j + break + } + } + if idx < 0 { + return nil + } + bv.miller.current.SetCursor(idx) + bv.miller, _ = bv.miller.drillIn() + } + + // Select the final node + target := path[len(path)-1] + for j, item := range bv.miller.current.items { + if item.Node == target { + bv.miller.current.SetCursor(j) + break + } + } + + // Request preview for the selected node + if target.QualifiedName != "" && target.Type != "" && len(target.Children) == 0 { + return bv.miller.previewEngine.RequestPreview(target.Type, target.QualifiedName, bv.miller.preview.mode) + } + return nil +} + +// findNodePath walks the tree to find the chain of nodes from root to the node +// with the matching qualified name. Returns nil if not found. +func findNodePath(nodes []*TreeNode, qname string) []*TreeNode { + for _, n := range nodes { + if n.QualifiedName == qname { + return []*TreeNode{n} + } + if len(n.Children) > 0 { + if sub := findNodePath(n.Children, qname); sub != nil { + return append([]*TreeNode{n}, sub...) + } + } + } + return nil +} + +func (bv BrowserView) openDiagram(nodeType, qualifiedName string) tea.Cmd { + mxcliPath := bv.mxcliPath + projectPath := bv.projectPath + return func() tea.Msg { + out, err := runMxcli(mxcliPath, "describe", "-p", projectPath, + "--format", "elk", nodeType, qualifiedName) + if err != nil { + return CmdResultMsg{Output: out, Err: err} + } + htmlContent := buildDiagramHTML(out, nodeType, qualifiedName) + tmpFile, err := os.CreateTemp("", "mxcli-diagram-*.html") + if err != nil { + return CmdResultMsg{Err: err} + } + if _, err := tmpFile.WriteString(htmlContent); err != nil { + tmpFile.Close() + return CmdResultMsg{Err: fmt.Errorf("writing diagram HTML: %w", err)} + } + tmpFile.Close() + tmpPath := tmpFile.Name() + openBrowser(tmpPath) + time.AfterFunc(30*time.Second, func() { os.Remove(tmpPath) }) + return CmdResultMsg{Output: fmt.Sprintf("Opened diagram: %s", tmpPath)} + } +} diff --git a/cmd/mxcli/tui/column.go b/cmd/mxcli/tui/column.go index 51886e6..ed9040b 100644 --- a/cmd/mxcli/tui/column.go +++ b/cmd/mxcli/tui/column.go @@ -220,8 +220,12 @@ func (c *Column) handleMouse(msg tea.MouseMsg) { func (c Column) View() string { var sb strings.Builder - // Title - sb.WriteString(ColumnTitleStyle.Render(c.title)) + // Title — use accent style when focused + titleStyle := ColumnTitleStyle + if c.focused { + titleStyle = FocusedTitleStyle + } + sb.WriteString(titleStyle.Render(c.title)) sb.WriteString("\n") // Filter bar @@ -243,6 +247,10 @@ func (c Column) View() string { if showScrollbar { contentWidth-- } + // Reserve 1 column for focus edge indicator + if c.focused { + contentWidth-- + } if contentWidth < 10 { contentWidth = 10 } @@ -264,6 +272,7 @@ func (c Column) View() string { scrollThumbStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) scrollTrackStyle := lipgloss.NewStyle().Faint(true) + edgeChar := FocusedEdgeStyle.Render(FocusedEdgeChar) for vi := range maxVis { idx := c.scrollOffset + vi @@ -297,10 +306,19 @@ func (c Column) View() string { } } - // Pad to contentWidth + // Focus edge indicator + if c.focused { + line = edgeChar + line + } + + // Pad to contentWidth (plus edge char width) + targetWidth := contentWidth + if c.focused { + targetWidth++ // account for the 1-char edge prefix + } lineWidth := lipgloss.Width(line) - if lineWidth < contentWidth { - line += strings.Repeat(" ", contentWidth-lineWidth) + if lineWidth < targetWidth { + line += strings.Repeat(" ", targetWidth-lineWidth) } // Scrollbar @@ -318,7 +336,11 @@ func (c Column) View() string { } } - return sb.String() + result := sb.String() + if !c.focused { + result = lipgloss.NewStyle().Faint(true).Render(result) + } + return result } // --- Helpers --- diff --git a/cmd/mxcli/tui/compare.go b/cmd/mxcli/tui/compare.go index cad204d..4f76e93 100644 --- a/cmd/mxcli/tui/compare.go +++ b/cmd/mxcli/tui/compare.go @@ -91,15 +91,14 @@ type CompareView struct { pickerOffset int pickerSide CompareFocus + // Self-contained operation (for View interface) + mxcliPath string + projectPath string + width int height int } -type pickerMatch struct { - item PickerItem - score int -} - const pickerMaxShow = 12 func NewCompareView() CompareView { @@ -135,8 +134,6 @@ func (c CompareView) paneDimensions() (int, int) { return pw, ph } -func (c *CompareView) Hide() { c.visible = false; c.picker = false } -func (c CompareView) IsVisible() bool { return c.visible } func (c *CompareView) SetItems(items []PickerItem) { c.pickerItems = items } func (c *CompareView) SetContent(side CompareFocus, title, nodeType, content string) { @@ -244,7 +241,7 @@ func (c CompareView) pickerSelected() PickerItem { // --- Update --- -func (c CompareView) Update(msg tea.Msg) (CompareView, tea.Cmd) { +func (c CompareView) updateInternal(msg tea.Msg) (CompareView, tea.Cmd) { if !c.visible { return c, nil } @@ -311,7 +308,7 @@ func (c CompareView) updateNormal(msg tea.KeyMsg) (CompareView, tea.Cmd) { switch msg.String() { case "esc", "q": c.visible = false - return c, nil + return c, func() tea.Msg { return PopViewMsg{} } // Focus switching — lazygit style: Tab only case "tab": @@ -589,46 +586,127 @@ func (c CompareView) renderPicker() string { Render(sb.String()) } -// --- Utilities --- +// --- View interface --- + +// Update satisfies the View interface. +func (c CompareView) Update(msg tea.Msg) (View, tea.Cmd) { + updated, cmd := c.updateInternal(msg) + return updated, cmd +} -// fuzzyScore checks if query chars appear in order within target (fzf-style). -func fuzzyScore(target, query string) (bool, int) { - tLower := strings.ToLower(target) - qLower := strings.ToLower(query) - if len(qLower) == 0 { - return true, 0 +// Render satisfies the View interface, with an LLM anchor prefix. +func (c CompareView) Render(width, height int) string { + c.width = width + c.height = height + pw, ph := c.paneDimensions() + c.left.content.SetSize(pw, ph) + c.right.content.SetSize(pw, ph) + rendered := c.View() + + // Embed LLM anchor as muted prefix on the first line + info := c.StatusInfo() + leftTitle := c.left.title + if leftTitle == "" { + leftTitle = "—" + } + rightTitle := c.right.title + if rightTitle == "" { + rightTitle = "—" + } + anchor := fmt.Sprintf("[mxcli:compare] Left: %s Right: %s %s", leftTitle, rightTitle, info.Mode) + anchorSt := lipgloss.NewStyle().Foreground(MutedColor).Faint(true) + anchorStr := anchorSt.Render(anchor) + + if idx := strings.IndexByte(rendered, '\n'); idx >= 0 { + rendered = anchorStr + rendered[idx:] + } else { + rendered = anchorStr } - if len(qLower) > len(tLower) { - return false, 0 + return rendered +} + +// Hints satisfies the View interface. +func (c CompareView) Hints() []Hint { + return CompareHints +} + +// StatusInfo satisfies the View interface. +func (c CompareView) StatusInfo() StatusInfo { + kindNames := []string{"NDSL|NDSL", "NDSL|MDL", "MDL|MDL"} + modeLabel := "Compare" + if int(c.kind) < len(kindNames) { + modeLabel = kindNames[c.kind] + } + leftTitle := c.left.title + if leftTitle == "" { + leftTitle = "—" + } + rightTitle := c.right.title + if rightTitle == "" { + rightTitle = "—" } - score := 0 - qi := 0 - prevMatched := false - for ti := range len(tLower) { - if qi >= len(qLower) { - break + return StatusInfo{ + Breadcrumb: []string{leftTitle, rightTitle}, + Position: fmt.Sprintf("%d%%", c.focusedPane().scrollPercent()), + Mode: modeLabel, + } +} + +// Mode satisfies the View interface. +func (c CompareView) Mode() ViewMode { + return ModeCompare +} + +// loadBsonNDSL loads BSON NDSL content for a compare pane. +func (c CompareView) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd { + mxcliPath := c.mxcliPath + projectPath := c.projectPath + return func() tea.Msg { + bsonType := inferBsonType(nodeType) + if bsonType == "" { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, + Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType), + Err: fmt.Errorf("unsupported type")} } - if tLower[ti] == qLower[qi] { - qi++ - if ti == 0 { - score += 7 - } else if target[ti-1] == '.' || target[ti-1] == '_' { - score += 5 - } else if target[ti] >= 'A' && target[ti] <= 'Z' { - score += 5 - } - if prevMatched { - score += 3 - } - prevMatched = true - } else { - prevMatched = false + args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", + "--type", bsonType, "--object", qname} + out, err := runMxcli(mxcliPath, args...) + out = StripBanner(out) + if err != nil { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} + } + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)} + } +} + +// loadMDL loads MDL content for a compare pane. +func (c CompareView) loadMDL(qname, nodeType string, side CompareFocus) tea.Cmd { + mxcliPath := c.mxcliPath + projectPath := c.projectPath + return func() tea.Msg { + out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname)) + out = StripBanner(out) + if err != nil { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} } + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: DetectAndHighlight(out)} } - if qi < len(qLower) { - return false, 0 +} + +// loadForCompare dispatches to the appropriate loader based on compare kind. +func (c CompareView) loadForCompare(qname, nodeType string, side CompareFocus, kind CompareKind) tea.Cmd { + switch kind { + case CompareNDSL: + return c.loadBsonNDSL(qname, nodeType, side) + case CompareNDSLMDL: + if side == CompareFocusLeft { + return c.loadBsonNDSL(qname, nodeType, side) + } + return c.loadMDL(qname, nodeType, side) + case CompareMDL: + return c.loadMDL(qname, nodeType, side) } - score += max(0, 50-len(tLower)) - return true, score + return nil } + diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go index d398690..b01feca 100644 --- a/cmd/mxcli/tui/diffview.go +++ b/cmd/mxcli/tui/diffview.go @@ -26,9 +26,6 @@ type DiffOpenMsg struct { Title string } -// DiffCloseMsg signals the diff view should close. -type DiffCloseMsg struct{} - // DiffView is a Bubble Tea component for interactive diff viewing. type DiffView struct { // Input @@ -121,9 +118,6 @@ func (dv *DiffView) SetSize(w, h int) { dv.height = h } -// IsVisible returns true (always visible when DiffView exists). -func (dv DiffView) IsVisible() bool { return true } - func (dv DiffView) totalLines() int { switch dv.viewMode { case DiffViewSideBySide: @@ -149,7 +143,7 @@ func (dv DiffView) maxOffset() int { // --- Update --- -func (dv DiffView) Update(msg tea.Msg) (DiffView, tea.Cmd) { +func (dv DiffView) updateInternal(msg tea.Msg) (DiffView, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if dv.searching { @@ -202,7 +196,7 @@ func (dv DiffView) updateSearch(msg tea.KeyMsg) (DiffView, tea.Cmd) { func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { switch msg.String() { case "q", "esc": - return dv, func() tea.Msg { return DiffCloseMsg{} } + return dv, func() tea.Msg { return PopViewMsg{} } // Vertical scroll case "j", "down": @@ -664,3 +658,63 @@ func (dv DiffView) scrollPercent() int { } return int(float64(dv.yOffset) / float64(m) * 100) } + +// --- View interface --- + +// Update satisfies the View interface. +func (dv DiffView) Update(msg tea.Msg) (View, tea.Cmd) { + updated, cmd := dv.updateInternal(msg) + return updated, cmd +} + +// Render satisfies the View interface, with an LLM anchor prefix. +func (dv DiffView) Render(width, height int) string { + dv.width = width + dv.height = height + rendered := dv.View() + + // Embed LLM anchor as muted prefix on the first line + info := dv.StatusInfo() + anchor := fmt.Sprintf("[mxcli:diff] %s %s", info.Mode, info.Extra) + anchorStr := lipgloss.NewStyle().Foreground(MutedColor).Faint(true).Render(anchor) + + if idx := strings.IndexByte(rendered, '\n'); idx >= 0 { + rendered = anchorStr + rendered[idx:] + } else { + rendered = anchorStr + } + return rendered +} + +// Hints satisfies the View interface. +func (dv DiffView) Hints() []Hint { + return DiffViewHints +} + +// StatusInfo satisfies the View interface. +func (dv DiffView) StatusInfo() StatusInfo { + var modeLabel string + switch dv.viewMode { + case DiffViewUnified: + modeLabel = "Unified" + case DiffViewSideBySide: + modeLabel = "Side-by-Side" + case DiffViewPlainDiff: + modeLabel = "Plain Diff" + } + extra := "" + if dv.result != nil { + extra = fmt.Sprintf("+%d -%d", dv.result.Stats.Additions, dv.result.Stats.Deletions) + } + return StatusInfo{ + Breadcrumb: []string{dv.title}, + Position: fmt.Sprintf("%d%%", dv.scrollPercent()), + Mode: modeLabel, + Extra: extra, + } +} + +// Mode satisfies the View interface. +func (dv DiffView) Mode() ViewMode { + return ModeDiff +} diff --git a/cmd/mxcli/tui/fuzzy.go b/cmd/mxcli/tui/fuzzy.go new file mode 100644 index 0000000..67ca889 --- /dev/null +++ b/cmd/mxcli/tui/fuzzy.go @@ -0,0 +1,51 @@ +package tui + +import "strings" + +// pickerMatch pairs a PickerItem with its fuzzy match score. +type pickerMatch struct { + item PickerItem + score int +} + +// fuzzyScore checks if query chars appear in order within target (fzf-style). +// Returns (matched, score). Higher score = better match. +func fuzzyScore(target, query string) (bool, int) { + tLower := strings.ToLower(target) + qLower := strings.ToLower(query) + if len(qLower) == 0 { + return true, 0 + } + if len(qLower) > len(tLower) { + return false, 0 + } + score := 0 + qi := 0 + prevMatched := false + for ti := range len(tLower) { + if qi >= len(qLower) { + break + } + if tLower[ti] == qLower[qi] { + qi++ + if ti == 0 { + score += 7 + } else if target[ti-1] == '.' || target[ti-1] == '_' { + score += 5 + } else if target[ti] >= 'A' && target[ti] <= 'Z' { + score += 5 + } + if prevMatched { + score += 3 + } + prevMatched = true + } else { + prevMatched = false + } + } + if qi < len(qLower) { + return false, 0 + } + score += max(0, 50-len(tLower)) + return true, score +} diff --git a/cmd/mxcli/tui/help.go b/cmd/mxcli/tui/help.go index c91d72a..32eec0e 100644 --- a/cmd/mxcli/tui/help.go +++ b/cmd/mxcli/tui/help.go @@ -6,30 +6,47 @@ const helpText = ` mxcli tui — Keyboard Reference NAVIGATION - j / ↓ move down / scroll - k / ↑ move up / scroll + j / ↓ move down / scroll + k / ↑ move up / scroll l / → / Enter drill in / expand - h / ← go back - Tab cycle panel focus - / filter in list - Esc back / close + h / ← go back + Space fuzzy jump to object + / filter in list + Esc back / close ACTIONS - b BSON dump (overlay) - c compare view (side-by-side) - d diagram in browser - r refresh project tree - z zen mode (zoom panel) - Enter full detail (in preview) - - OVERLAY / COMPARE VIEW - y copy content to clipboard - Tab switch left/right pane (compare) - / fuzzy pick object (compare) - 1/2/3 NDSL|NDSL / NDSL|MDL / MDL|MDL - s toggle sync scroll + b BSON dump (overlay) + c compare view (side-by-side) + d diagram in browser + y copy to clipboard + r refresh project tree + z zen mode (zoom panel) + Tab switch MDL / NDSL preview + t new tab (same project) + T new tab (pick project) + 1-9 switch tab + + OVERLAY j/k scroll content - Esc close + / search in content + y copy to clipboard + Tab switch MDL / NDSL + q close + + COMPARE VIEW + h/l navigate panes + / search in content + s toggle sync scroll + 1/2/3 NDSL|NDSL / NDSL|MDL / MDL|MDL + d open diff view + q close + + DIFF VIEW + j/k scroll + Tab cycle mode (unified/side-by-side/plain) + ]c/[c next/prev hunk + / search + q close OTHER ? show/hide this help diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index 4e98a44..a03f5d1 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -32,6 +32,7 @@ var ( ListBrowsingHints = []Hint{ {Key: "h", Label: "back"}, {Key: "l", Label: "open"}, + {Key: "Space", Label: "jump"}, {Key: "/", Label: "filter"}, {Key: "Tab", Label: "mdl/ndsl"}, {Key: "y", Label: "copy"}, @@ -88,7 +89,7 @@ func (h *HintBar) View(width int) string { } items := make([]rendered, len(h.hints)) for i, hint := range h.hints { - text := HintKeyStyle.Render(hint.Key) + ":" + HintLabelStyle.Render(hint.Label) + text := HintKeyStyle.Render(hint.Key) + " " + HintLabelStyle.Render(hint.Label) items[i] = rendered{text: text, width: lipgloss.Width(text)} } diff --git a/cmd/mxcli/tui/jumper.go b/cmd/mxcli/tui/jumper.go new file mode 100644 index 0000000..5541fb0 --- /dev/null +++ b/cmd/mxcli/tui/jumper.go @@ -0,0 +1,212 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const jumperMaxShow = 12 + +// JumpToNodeMsg is emitted when the user selects a node from the jumper. +type JumpToNodeMsg struct { + QName string + NodeType string +} + +// JumperView is a fuzzy-search modal for jumping to any node in the project. +type JumperView struct { + input textinput.Model + items []PickerItem + matches []pickerMatch + cursor int + offset int + width int + height int +} + +// NewJumperView creates a JumperView populated with the given items. +func NewJumperView(items []PickerItem, width, height int) JumperView { + ti := textinput.New() + ti.Prompt = "❯ " + ti.Placeholder = "jump to..." + ti.CharLimit = 200 + ti.Focus() + + jv := JumperView{ + input: ti, + items: items, + width: width, + height: height, + } + jv.filterItems() + return jv +} + +func (jv *JumperView) filterItems() { + query := strings.TrimSpace(jv.input.Value()) + jv.matches = nil + for _, it := range jv.items { + if query == "" { + jv.matches = append(jv.matches, pickerMatch{item: it}) + continue + } + if ok, sc := fuzzyScore(it.QName, query); ok { + jv.matches = append(jv.matches, pickerMatch{item: it, score: sc}) + } + } + // Sort by score descending (insertion sort, small n) + for i := 1; i < len(jv.matches); i++ { + for j := i; j > 0 && jv.matches[j].score > jv.matches[j-1].score; j-- { + jv.matches[j], jv.matches[j-1] = jv.matches[j-1], jv.matches[j] + } + } + if jv.cursor >= len(jv.matches) { + jv.cursor = max(0, len(jv.matches)-1) + } + jv.offset = 0 +} + +func (jv *JumperView) moveDown() { + if len(jv.matches) == 0 { + return + } + jv.cursor++ + if jv.cursor >= len(jv.matches) { + jv.cursor = 0 + jv.offset = 0 + } else if jv.cursor >= jv.offset+jumperMaxShow { + jv.offset = jv.cursor - jumperMaxShow + 1 + } +} + +func (jv *JumperView) moveUp() { + if len(jv.matches) == 0 { + return + } + jv.cursor-- + if jv.cursor < 0 { + jv.cursor = len(jv.matches) - 1 + jv.offset = max(0, jv.cursor-jumperMaxShow+1) + } else if jv.cursor < jv.offset { + jv.offset = jv.cursor + } +} + +func (jv JumperView) selected() PickerItem { + if len(jv.matches) == 0 || jv.cursor >= len(jv.matches) { + return PickerItem{} + } + return jv.matches[jv.cursor].item +} + +// --- View interface --- + +// Update handles key messages for the jumper modal. +func (jv JumperView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return jv, func() tea.Msg { return PopViewMsg{} } + case "enter": + sel := jv.selected() + if sel.QName != "" { + qname := sel.QName + nodeType := sel.NodeType + return jv, func() tea.Msg { return JumpToNodeMsg{QName: qname, NodeType: nodeType} } + } + return jv, func() tea.Msg { return PopViewMsg{} } + case "up", "ctrl+p": + jv.moveUp() + case "down", "ctrl+n": + jv.moveDown() + default: + var cmd tea.Cmd + jv.input, cmd = jv.input.Update(msg) + jv.filterItems() + return jv, cmd + } + + case tea.WindowSizeMsg: + jv.width = msg.Width + jv.height = msg.Height + } + return jv, nil +} + +// Render draws the jumper as a centered modal box with an LLM anchor prefix. +func (jv JumperView) Render(width, height int) string { + selSt := SelectedItemStyle + normSt := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + dimSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + typeSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + + boxWidth := max(30, min(60, width-10)) + + // LLM anchor embedded at the top of the box content + query := strings.TrimSpace(jv.input.Value()) + anchor := fmt.Sprintf("[mxcli:jump] > %s %d matches", query, len(jv.matches)) + anchorStr := lipgloss.NewStyle().Foreground(MutedColor).Faint(true).Render(anchor) + + var sb strings.Builder + sb.WriteString(anchorStr + "\n") + sb.WriteString(jv.input.View() + "\n\n") + + end := min(jv.offset+jumperMaxShow, len(jv.matches)) + if jv.offset > 0 { + sb.WriteString(dimSt.Render(" ↑ more") + "\n") + } + for i := jv.offset; i < end; i++ { + it := jv.matches[i].item + icon := IconFor(it.NodeType) + label := icon + " " + it.QName + typeLabel := it.NodeType + if i == jv.cursor { + sb.WriteString(selSt.Render(" "+label) + " " + typeSt.Render(typeLabel) + "\n") + } else { + sb.WriteString(normSt.Render(" "+label) + " " + dimSt.Render(typeLabel) + "\n") + } + } + if end < len(jv.matches) { + sb.WriteString(dimSt.Render(" ↓ more") + "\n") + } + sb.WriteString("\n" + dimSt.Render(fmt.Sprintf(" %d/%d", len(jv.matches), len(jv.items)))) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(1, 2). + Width(boxWidth). + Render(sb.String()) + + return lipgloss.Place(width, height, + lipgloss.Center, lipgloss.Center, + box, + lipgloss.WithWhitespaceBackground(lipgloss.Color("0"))) +} + +// Hints returns jumper-specific key hints. +func (jv JumperView) Hints() []Hint { + return []Hint{ + {Key: "↑/↓", Label: "navigate"}, + {Key: "Enter", Label: "jump"}, + {Key: "Esc", Label: "cancel"}, + } +} + +// StatusInfo returns match count information. +func (jv JumperView) StatusInfo() StatusInfo { + return StatusInfo{ + Mode: "Jump", + Position: fmt.Sprintf("%d/%d", len(jv.matches), len(jv.items)), + } +} + +// Mode returns ModeJumper. +func (jv JumperView) Mode() ViewMode { + return ModeJumper +} diff --git a/cmd/mxcli/tui/miller.go b/cmd/mxcli/tui/miller.go index 4a492f0..e9b2e8a 100644 --- a/cmd/mxcli/tui/miller.go +++ b/cmd/mxcli/tui/miller.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -51,6 +52,12 @@ type navEntry struct { currentCursor int } +// previewDebounceMsg fires after the debounce delay for leaf-node previews. +type previewDebounceMsg struct { + node *TreeNode + counter int +} + // animTickMsg is kept for backward compatibility (forwarded in app.go). type animTickMsg struct{} @@ -67,9 +74,10 @@ type MillerView struct { focus MillerFocus navStack []navEntry - width int - height int - zenMode bool + width int + height int + zenMode bool + debounceCounter int } // NewMillerView creates a MillerView wired to the given preview engine. @@ -136,6 +144,18 @@ func (m MillerView) Update(msg tea.Msg) (MillerView, tea.Cmd) { m.preview.loading = true return m, nil + case previewDebounceMsg: + // Ignore if superseded by a newer cursor move + if msg.counter != m.debounceCounter { + return m, nil + } + node := msg.node + if node != nil && node.QualifiedName != "" && node.Type != "" { + cmd := m.previewEngine.RequestPreview(node.Type, node.QualifiedName, m.preview.mode) + return m, cmd + } + return m, nil + case animTickMsg: return m, nil // no-op, animation removed @@ -188,7 +208,7 @@ func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.C return m, nil } - // If node has children, show them in the preview as a child column + // Nodes with children: show child column immediately (no subprocess, no debounce) if len(node.Children) > 0 { col := NewColumn(node.Label) col.SetItems(treeNodesToItems(node.Children)) @@ -202,13 +222,18 @@ func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.C return m, nil } - // Leaf node: request preview content + // Leaf node: debounce preview request to avoid flooding subprocesses m.preview.childColumn = nil m.preview.scrollOffset = 0 + m.debounceCounter++ + counter := m.debounceCounter + if node.QualifiedName != "" && node.Type != "" { - cmd := m.previewEngine.RequestPreview(node.Type, node.QualifiedName, m.preview.mode) - return m, cmd + return m, tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { + return previewDebounceMsg{node: node, counter: counter} + }) } + m.preview.content = "" m.preview.imagePaths = nil m.preview.contentLines = nil @@ -319,6 +344,25 @@ func (m MillerView) goBack() (MillerView, tea.Cmd) { return m, nil } +// goBackToDepth navigates back until the navStack has targetDepth entries. +func (m MillerView) goBackToDepth(targetDepth int) (MillerView, tea.Cmd) { + for len(m.navStack) > targetDepth { + m, _ = m.goBack() + } + // Trigger preview for current selection + if node := m.current.SelectedNode(); node != nil { + if len(node.Children) > 0 { + col := NewColumn(node.Label) + col.SetItems(treeNodesToItems(node.Children)) + m.preview.childColumn = &col + m.relayout() + } else if node.QualifiedName != "" && node.Type != "" { + return m, m.previewEngine.RequestPreview(node.Type, node.QualifiedName, m.preview.mode) + } + } + return m, nil +} + func (m MillerView) togglePreviewMode() (MillerView, tea.Cmd) { if m.preview.mode == PreviewMDL { m.preview.mode = PreviewNDSL @@ -394,6 +438,7 @@ func (m MillerView) renderPreview(previewWidth int) string { } if m.preview.childColumn != nil { + m.preview.childColumn.SetFocused(false) m.preview.childColumn.SetSize(previewWidth, m.height) return m.preview.childColumn.View() } @@ -463,7 +508,7 @@ func (m MillerView) renderPreview(previewWidth int) string { // Build output var out strings.Builder - out.WriteString(PreviewModeStyle.Render(modeLabel)) + out.WriteString(AccentStyle.Render(modeLabel)) for _, vl := range visible { out.WriteByte('\n') if vl.lineNo > 0 { diff --git a/cmd/mxcli/tui/overlay.go b/cmd/mxcli/tui/overlay.go index e4a2401..d236a45 100644 --- a/cmd/mxcli/tui/overlay.go +++ b/cmd/mxcli/tui/overlay.go @@ -47,9 +47,6 @@ func (o *Overlay) Show(title, content string, w, h int) { o.content.SetContent(content) } -func (o *Overlay) Hide() { o.visible = false } -func (o Overlay) IsVisible() bool { return o.visible } - func (o Overlay) Update(msg tea.Msg) (Overlay, tea.Cmd) { if !o.visible { return o, nil diff --git a/cmd/mxcli/tui/overlayview.go b/cmd/mxcli/tui/overlayview.go new file mode 100644 index 0000000..5628aca --- /dev/null +++ b/cmd/mxcli/tui/overlayview.go @@ -0,0 +1,182 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// overlayContentMsg carries reloaded content for an OverlayView after Tab switch. +type overlayContentMsg struct { + Title string + Content string +} + +// OverlayViewOpts holds optional configuration for an OverlayView. +type OverlayViewOpts struct { + QName string + NodeType string + IsNDSL bool + Switchable bool + MxcliPath string + ProjectPath string +} + +// OverlayView wraps an Overlay to satisfy the View interface, +// adding BSON/MDL switching and self-contained content reload. +type OverlayView struct { + overlay Overlay + qname string + nodeType string + isNDSL bool + switchable bool + mxcliPath string + projectPath string +} + +// NewOverlayView creates an OverlayView with the given title, content, dimensions, and options. +func NewOverlayView(title, content string, width, height int, opts OverlayViewOpts) OverlayView { + ov := OverlayView{ + qname: opts.QName, + nodeType: opts.NodeType, + isNDSL: opts.IsNDSL, + switchable: opts.Switchable, + mxcliPath: opts.MxcliPath, + projectPath: opts.ProjectPath, + } + ov.overlay = NewOverlay() + ov.overlay.switchable = opts.Switchable + ov.overlay.Show(title, content, width, height) + return ov +} + +// Update handles input and internal messages. +func (ov OverlayView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case overlayContentMsg: + ov.overlay.Show(msg.Title, msg.Content, ov.overlay.width, ov.overlay.height) + return ov, nil + + case tea.KeyMsg: + if ov.overlay.content.IsSearching() { + var cmd tea.Cmd + ov.overlay, cmd = ov.overlay.Update(msg) + return ov, cmd + } + switch msg.String() { + case "esc", "q": + return ov, func() tea.Msg { return PopViewMsg{} } + case "tab": + if ov.switchable && ov.qname != "" { + ov.isNDSL = !ov.isNDSL + return ov, ov.reloadContent() + } + } + } + + var cmd tea.Cmd + ov.overlay, cmd = ov.overlay.Update(msg) + return ov, cmd +} + +// Render returns the overlay's rendered string at the given dimensions with an LLM anchor prefix. +func (ov OverlayView) Render(width, height int) string { + if ov.overlay.width != width || ov.overlay.height != height { + ov.overlay.width = width + ov.overlay.height = height + } + rendered := ov.overlay.View() + + // Embed LLM anchor as muted prefix on the first line + info := ov.StatusInfo() + anchor := fmt.Sprintf("[mxcli:overlay] %s %s", ov.overlay.title, info.Mode) + anchorStr := lipgloss.NewStyle().Foreground(MutedColor).Faint(true).Render(anchor) + + if idx := strings.IndexByte(rendered, '\n'); idx >= 0 { + rendered = anchorStr + rendered[idx:] + } else { + rendered = anchorStr + } + return rendered +} + +// Hints returns context-sensitive key hints for this overlay. +func (ov OverlayView) Hints() []Hint { + hints := []Hint{ + {Key: "j/k", Label: "scroll"}, + {Key: "/", Label: "search"}, + {Key: "y", Label: "copy"}, + } + if ov.switchable { + hints = append(hints, Hint{Key: "Tab", Label: "mdl/ndsl"}) + } + hints = append(hints, Hint{Key: "q", Label: "close"}) + return hints +} + +// StatusInfo returns display data for the status bar. +func (ov OverlayView) StatusInfo() StatusInfo { + modeLabel := "MDL" + if ov.isNDSL { + modeLabel = "NDSL" + } + position := fmt.Sprintf("L%d/%d", ov.overlay.content.YOffset()+1, ov.overlay.content.TotalLines()) + return StatusInfo{ + Breadcrumb: []string{ov.overlay.title}, + Position: position, + Mode: modeLabel, + } +} + +// Mode returns ModeOverlay. +func (ov OverlayView) Mode() ViewMode { + return ModeOverlay +} + +// reloadContent returns a tea.Cmd that fetches new content based on isNDSL state. +func (ov OverlayView) reloadContent() tea.Cmd { + if ov.isNDSL { + return ov.runBsonReload() + } + return ov.runMDLReload() +} + +func (ov OverlayView) runBsonReload() tea.Cmd { + bsonType := inferBsonType(ov.nodeType) + if bsonType == "" { + return nil + } + mxcliPath := ov.mxcliPath + projectPath := ov.projectPath + qname := ov.qname + return func() tea.Msg { + args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", + "--type", bsonType, "--object", qname} + out, err := runMxcli(mxcliPath, args...) + out = StripBanner(out) + title := fmt.Sprintf("BSON: %s", qname) + if err != nil { + return overlayContentMsg{Title: title, Content: "Error: " + out} + } + return overlayContentMsg{Title: title, Content: HighlightNDSL(out)} + } +} + +func (ov OverlayView) runMDLReload() tea.Cmd { + mxcliPath := ov.mxcliPath + projectPath := ov.projectPath + qname := ov.qname + nodeType := ov.nodeType + return func() tea.Msg { + out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname)) + out = StripBanner(out) + title := fmt.Sprintf("MDL: %s", qname) + if err != nil { + return overlayContentMsg{Title: title, Content: "Error: " + out} + } + return overlayContentMsg{Title: title, Content: DetectAndHighlight(out)} + } +} + diff --git a/cmd/mxcli/tui/statusbar.go b/cmd/mxcli/tui/statusbar.go index 2a3c595..7881618 100644 --- a/cmd/mxcli/tui/statusbar.go +++ b/cmd/mxcli/tui/statusbar.go @@ -6,11 +6,21 @@ import ( "github.com/charmbracelet/lipgloss" ) +// breadcrumbZone tracks the X range for a clickable breadcrumb segment. +type breadcrumbZone struct { + startX int + endX int + depth int // navigation depth this segment corresponds to +} + // StatusBar renders a bottom status line with breadcrumb and position info. type StatusBar struct { breadcrumb []string position string // e.g. "3/4" mode string // e.g. "MDL" or "NDSL" + viewDepth int + viewModes []string + zones []breadcrumbZone // clickable breadcrumb zones } // NewStatusBar creates a status bar. @@ -33,16 +43,60 @@ func (s *StatusBar) SetMode(mode string) { s.mode = mode } -// View renders the status bar to fit the given width. +// SetViewDepth sets the view stack depth and mode names for breadcrumb display. +func (s *StatusBar) SetViewDepth(depth int, modes []string) { + s.viewDepth = depth + s.viewModes = modes +} + +// View renders the status bar to fit the given width, tracking breadcrumb click zones. func (s *StatusBar) View(width int) string { + s.zones = nil + // Build breadcrumb: all segments dim except last one normal. + // Track X positions for click zones. + sep := BreadcrumbDimStyle.Render(" › ") + sepWidth := lipgloss.Width(sep) + + xPos := 1 // leading space + var crumbParts []string - sep := BreadcrumbDimStyle.Render(" > ") + + // Prepend view depth breadcrumb if deeper than 1 + if s.viewDepth > 1 && len(s.viewModes) > 0 { + var modeParts []string + for _, m := range s.viewModes { + modeParts = append(modeParts, m) + } + depthLabel := strings.Join(modeParts, " › ") + rendered := BreadcrumbDimStyle.Render("[" + depthLabel + "]") + crumbParts = append(crumbParts, rendered) + xPos += lipgloss.Width(rendered) + if len(s.breadcrumb) > 0 { + xPos += sepWidth + } + } + for i, seg := range s.breadcrumb { + var rendered string if i == len(s.breadcrumb)-1 { - crumbParts = append(crumbParts, BreadcrumbCurrentStyle.Render(seg)) + rendered = BreadcrumbCurrentStyle.Render(seg) } else { - crumbParts = append(crumbParts, BreadcrumbDimStyle.Render(seg)) + rendered = BreadcrumbDimStyle.Render(seg) + } + + segWidth := lipgloss.Width(rendered) + // Record clickable zone: depth = i (how many levels deep from root) + s.zones = append(s.zones, breadcrumbZone{ + startX: xPos, + endX: xPos + segWidth, + depth: i, + }) + + crumbParts = append(crumbParts, rendered) + xPos += segWidth + if i < len(s.breadcrumb)-1 { + xPos += sepWidth } } left := " " + strings.Join(crumbParts, sep) @@ -53,7 +107,7 @@ func (s *StatusBar) View(width int) string { rightParts = append(rightParts, PositionStyle.Render(s.position)) } if s.mode != "" { - rightParts = append(rightParts, PreviewModeStyle.Render(s.mode)) + rightParts = append(rightParts, BreadcrumbDimStyle.Render("⎸")+PreviewModeStyle.Render(s.mode)) } right := strings.Join(rightParts, " ") + " " @@ -64,3 +118,14 @@ func (s *StatusBar) View(width int) string { return left + strings.Repeat(" ", gap) + right } + +// HitTest checks if x falls within a clickable breadcrumb zone. +// Returns the navigation depth and true if a zone was hit. +func (s *StatusBar) HitTest(x int) (int, bool) { + for _, z := range s.zones { + if x >= z.startX && x < z.endX { + return z.depth, true + } + } + return 0, false +} diff --git a/cmd/mxcli/tui/styles.go b/cmd/mxcli/tui/styles.go deleted file mode 100644 index 3050195..0000000 --- a/cmd/mxcli/tui/styles.go +++ /dev/null @@ -1,45 +0,0 @@ -package tui - -import "github.com/charmbracelet/lipgloss" - -// Yazi-style borderless, terminal-adaptive styles. -// No hardcoded colors — relies on Bold, Dim, Reverse, Italic, Underline. - -var ( - // Column separator: dim vertical bar between panels. - SeparatorChar = "│" - SeparatorStyle = lipgloss.NewStyle().Faint(true) - - // Tabs - ActiveTabStyle = lipgloss.NewStyle().Bold(true).Underline(true) - InactiveTabStyle = lipgloss.NewStyle().Faint(true) - - // Column title (e.g. "Entities", "Attributes") - ColumnTitleStyle = lipgloss.NewStyle().Bold(true) - - // List items - SelectedItemStyle = lipgloss.NewStyle().Reverse(true) - DirectoryStyle = lipgloss.NewStyle().Bold(true) - LeafStyle = lipgloss.NewStyle() - - // Breadcrumb - BreadcrumbDimStyle = lipgloss.NewStyle().Faint(true) - BreadcrumbCurrentStyle = lipgloss.NewStyle() - - // Loading / status - LoadingStyle = lipgloss.NewStyle().Italic(true).Faint(true) - PositionStyle = lipgloss.NewStyle().Faint(true) - - // Preview mode label (MDL / NDSL toggle) - PreviewModeStyle = lipgloss.NewStyle().Bold(true) - - // Hint bar: key name bold, description dim - HintKeyStyle = lipgloss.NewStyle().Bold(true) - HintLabelStyle = lipgloss.NewStyle().Faint(true) - - // Status bar (bottom line) - StatusBarStyle = lipgloss.NewStyle().Faint(true) - - // Command bar - CmdBarStyle = lipgloss.NewStyle().Bold(true) -) diff --git a/cmd/mxcli/tui/theme.go b/cmd/mxcli/tui/theme.go new file mode 100644 index 0000000..931f560 --- /dev/null +++ b/cmd/mxcli/tui/theme.go @@ -0,0 +1,57 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +// Semantic color tokens — AdaptiveColor picks Light/Dark based on terminal background. +var ( + FocusColor = lipgloss.AdaptiveColor{Light: "62", Dark: "63"} + AccentColor = lipgloss.AdaptiveColor{Light: "214", Dark: "214"} + MutedColor = lipgloss.AdaptiveColor{Light: "245", Dark: "243"} + AddedColor = lipgloss.AdaptiveColor{Light: "28", Dark: "114"} + RemovedColor = lipgloss.AdaptiveColor{Light: "124", Dark: "210"} +) + +var ( + // Column separator: dim vertical bar between panels. + SeparatorChar = "│" + SeparatorStyle = lipgloss.NewStyle().Foreground(MutedColor) + + // Tabs + ActiveTabStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(FocusColor) + InactiveTabStyle = lipgloss.NewStyle().Foreground(MutedColor) + + // Column title (e.g. "Entities", "Attributes") + ColumnTitleStyle = lipgloss.NewStyle().Bold(true) + + // List items + SelectedItemStyle = lipgloss.NewStyle().Reverse(true) + DirectoryStyle = lipgloss.NewStyle().Bold(true) + LeafStyle = lipgloss.NewStyle() + + // Breadcrumb + BreadcrumbDimStyle = lipgloss.NewStyle().Foreground(MutedColor) + BreadcrumbCurrentStyle = lipgloss.NewStyle() + + // Loading / status + LoadingStyle = lipgloss.NewStyle().Italic(true).Foreground(MutedColor) + PositionStyle = lipgloss.NewStyle().Foreground(MutedColor) + + // Preview mode label (MDL / NDSL toggle) + PreviewModeStyle = lipgloss.NewStyle().Bold(true) + + // Hint bar: key name bold, description dim + HintKeyStyle = lipgloss.NewStyle().Bold(true) + HintLabelStyle = lipgloss.NewStyle().Foreground(MutedColor) + + // Status bar (bottom line) + StatusBarStyle = lipgloss.NewStyle().Foreground(MutedColor) + + // Command bar + CmdBarStyle = lipgloss.NewStyle().Bold(true) + + // Focus indicator styles (Phase 2) + FocusedTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(FocusColor) + FocusedEdgeChar = "▎" + FocusedEdgeStyle = lipgloss.NewStyle().Foreground(FocusColor) + AccentStyle = lipgloss.NewStyle().Foreground(AccentColor) +) diff --git a/cmd/mxcli/tui/view.go b/cmd/mxcli/tui/view.go new file mode 100644 index 0000000..be57f00 --- /dev/null +++ b/cmd/mxcli/tui/view.go @@ -0,0 +1,58 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +// ViewMode identifies the type of view currently active. +type ViewMode int + +const ( + ModeBrowser ViewMode = iota + ModeOverlay + ModeCompare + ModeDiff + ModePicker + ModeJumper +) + +// String returns a human-readable label for the view mode. +func (m ViewMode) String() string { + switch m { + case ModeBrowser: + return "Browse" + case ModeOverlay: + return "Overlay" + case ModeCompare: + return "Compare" + case ModeDiff: + return "Diff" + case ModePicker: + return "Picker" + case ModeJumper: + return "Jump" + default: + return "Unknown" + } +} + +// StatusInfo carries display data for the status bar. +type StatusInfo struct { + Breadcrumb []string + Position string + Mode string + Extra string +} + +// View is the interface that all TUI views must implement. +type View interface { + Update(tea.Msg) (View, tea.Cmd) + Render(width, height int) string + Hints() []Hint + StatusInfo() StatusInfo + Mode() ViewMode +} + +// PushViewMsg requests that App push a new view onto the ViewStack. +type PushViewMsg struct{ View View } + +// PopViewMsg requests that App pop the current view from the ViewStack. +type PopViewMsg struct{} diff --git a/cmd/mxcli/tui/viewstack.go b/cmd/mxcli/tui/viewstack.go new file mode 100644 index 0000000..1dae8f8 --- /dev/null +++ b/cmd/mxcli/tui/viewstack.go @@ -0,0 +1,51 @@ +package tui + +// ViewStack manages a stack of Views on top of a base view. +// The base view is always present; pushed views overlay it. +type ViewStack struct { + base View + stack []View +} + +// NewViewStack creates a ViewStack with the given base view. +func NewViewStack(base View) ViewStack { + return ViewStack{base: base} +} + +// Active returns the top of the stack, or the base view if the stack is empty. +func (vs *ViewStack) Active() View { + if len(vs.stack) > 0 { + return vs.stack[len(vs.stack)-1] + } + return vs.base +} + +// Push adds a view to the top of the stack. +func (vs *ViewStack) Push(v View) { + vs.stack = append(vs.stack, v) +} + +// Pop removes and returns the top view from the stack. +// Returns false if the stack is empty (base view is never popped). +func (vs *ViewStack) Pop() (View, bool) { + if len(vs.stack) == 0 { + return nil, false + } + top := vs.stack[len(vs.stack)-1] + vs.stack = vs.stack[:len(vs.stack)-1] + return top, true +} + +// Depth returns the total number of views (base + stacked). +func (vs *ViewStack) Depth() int { + return len(vs.stack) + 1 +} + +// SetActive replaces the top of the stack, or the base view if the stack is empty. +func (vs *ViewStack) SetActive(v View) { + if len(vs.stack) > 0 { + vs.stack[len(vs.stack)-1] = v + } else { + vs.base = v + } +} diff --git a/cmd/mxcli/tui/viewstack_test.go b/cmd/mxcli/tui/viewstack_test.go new file mode 100644 index 0000000..db23909 --- /dev/null +++ b/cmd/mxcli/tui/viewstack_test.go @@ -0,0 +1,119 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +type mockView struct { + mode ViewMode +} + +func (m mockView) Update(tea.Msg) (View, tea.Cmd) { return m, nil } +func (m mockView) Render(w, h int) string { return "" } +func (m mockView) Hints() []Hint { return nil } +func (m mockView) StatusInfo() StatusInfo { return StatusInfo{} } +func (m mockView) Mode() ViewMode { return m.mode } + +func TestNewViewStack_ActiveReturnsBase(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + + if got := vs.Active(); got.Mode() != ModeBrowser { + t.Errorf("Active() mode = %v, want %v", got.Mode(), ModeBrowser) + } +} + +func TestPush_ActiveReturnsTop(t *testing.T) { + base := mockView{mode: ModeBrowser} + overlay := mockView{mode: ModeOverlay} + vs := NewViewStack(base) + + vs.Push(overlay) + + if got := vs.Active(); got.Mode() != ModeOverlay { + t.Errorf("Active() mode = %v, want %v", got.Mode(), ModeOverlay) + } +} + +func TestPop_RemovesTop(t *testing.T) { + base := mockView{mode: ModeBrowser} + overlay := mockView{mode: ModeOverlay} + vs := NewViewStack(base) + vs.Push(overlay) + + popped, ok := vs.Pop() + if !ok { + t.Fatal("Pop() returned false, want true") + } + if popped.Mode() != ModeOverlay { + t.Errorf("popped mode = %v, want %v", popped.Mode(), ModeOverlay) + } + if got := vs.Active(); got.Mode() != ModeBrowser { + t.Errorf("Active() after Pop = %v, want %v", got.Mode(), ModeBrowser) + } +} + +func TestPop_EmptyStack_ReturnsFalse(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + + _, ok := vs.Pop() + if ok { + t.Error("Pop() on empty stack returned true, want false") + } +} + +func TestDepth(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + + if got := vs.Depth(); got != 1 { + t.Errorf("Depth() = %d, want 1", got) + } + + vs.Push(mockView{mode: ModeOverlay}) + if got := vs.Depth(); got != 2 { + t.Errorf("Depth() = %d, want 2", got) + } + + vs.Push(mockView{mode: ModeDiff}) + if got := vs.Depth(); got != 3 { + t.Errorf("Depth() = %d, want 3", got) + } + + vs.Pop() + if got := vs.Depth(); got != 2 { + t.Errorf("Depth() after Pop = %d, want 2", got) + } +} + +func TestSetActive_ReplacesTop(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + vs.Push(mockView{mode: ModeOverlay}) + + vs.SetActive(mockView{mode: ModeDiff}) + + if got := vs.Active(); got.Mode() != ModeDiff { + t.Errorf("Active() = %v, want %v", got.Mode(), ModeDiff) + } + if got := vs.Depth(); got != 2 { + t.Errorf("Depth() = %d, want 2 (SetActive should not change depth)", got) + } +} + +func TestSetActive_EmptyStack_ReplacesBase(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + + vs.SetActive(mockView{mode: ModeCompare}) + + if got := vs.Active(); got.Mode() != ModeCompare { + t.Errorf("Active() = %v, want %v", got.Mode(), ModeCompare) + } + if got := vs.Depth(); got != 1 { + t.Errorf("Depth() = %d, want 1", got) + } +} diff --git a/docs/plans/2026-03-24-tui-ux-refactor-design.md b/docs/plans/2026-03-24-tui-ux-refactor-design.md new file mode 100644 index 0000000..ac7b75d --- /dev/null +++ b/docs/plans/2026-03-24-tui-ux-refactor-design.md @@ -0,0 +1,283 @@ +# TUI 2.0 UX Refactor Design + +**Date**: 2026-03-24 +**Status**: Approved +**Branch**: feat/tui-ux-refactor (from feat/tui-diff-view) + +## Problem Statement + +The current TUI has grown organically with multiple view modes (Miller browser, overlay, compare, diff) stacked on top of each other through implicit boolean/pointer checks. This causes: + +1. **Mode confusion** — Users can't tell which view layer they're in; each mode has different keybindings with no visual indicator of the current context +2. **Visual flatness** — No color differentiation between focused/unfocused columns; all areas look the same due to monochrome Bold/Faint/Reverse styling +3. **Navigation inefficiency** — No global jump-to-node; preview debounce missing; `Tab` key overloaded across contexts +4. **God Object** — `app.go` is 760 lines managing all view routing, state sync scattered across 30+ call sites + +### Dual Audience + +Both humans and LLMs read the TUI output (via tmux capture-pane). The redesign must serve both: +- Humans: visual hierarchy, color, focus indicators +- LLMs: structured text anchors, plain-text yank, parseable mode indicators + +## Architecture + +### View System + +``` +┌─────────────────────────────────────────────────┐ +│ Chrome (Header) │ ← Fixed +│ TabBar | ViewMode Badge | Context Summary │ +├─────────────────────────────────────────────────┤ +│ │ +│ Active View │ ← Replaceable +│ (BrowserView / CompareView / DiffView / etc.) │ +│ │ +├─────────────────────────────────────────────────┤ +│ Chrome (Footer) │ ← Fixed +│ HintBar (context-sensitive from active view) │ +│ StatusBar (breadcrumb + position + mode) │ +└─────────────────────────────────────────────────┘ +``` + +#### Unified View Interface + +```go +// View is the interface all TUI views must implement. +type View interface { + Update(tea.Msg) (View, tea.Cmd) + Render(width, height int) string + Hints() []Hint + StatusInfo() StatusInfo + Mode() ViewMode +} + +type ViewMode int +const ( + ModeBrowser ViewMode = iota + ModeOverlay + ModeCompare + ModeDiff + ModePicker +) + +type StatusInfo struct { + Breadcrumb []string + Position string // e.g. "3/47" + Mode string // e.g. "MDL", "NDSL" + Extra string // view-specific info +} +``` + +#### ViewStack + +Replaces the current `a.diffView != nil` / `a.overlay.IsVisible()` / `a.compare.IsVisible()` priority chain: + +```go +type ViewStack struct { + base View // always BrowserView + stack []View // overlay/compare/diff pushed on top +} + +func (vs *ViewStack) Active() View +func (vs *ViewStack) Push(v View) +func (vs *ViewStack) Pop() View +func (vs *ViewStack) Depth() int +``` + +#### Simplified App + +`app.go` reduces to ~200 lines: + +```go +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // 1. Handle global keys (ctrl+c, tab switching, Space for jump) + // 2. Delegate to active view + active := a.views.Active() + updated, cmd := active.Update(msg) + a.views.SetActive(updated) + return a, cmd +} + +func (a App) View() string { + active := a.views.Active() + header := a.renderHeader(active) + content := active.Render(a.width, a.contentHeight()) + footer := a.renderFooter(active) + return header + "\n" + content + "\n" + footer +} +``` + +Chrome (header/footer) is rendered by App using data from `active.Hints()` and `active.StatusInfo()` — **declarative, no manual sync calls**. + +## Visual Design + +### Color System (Terminal-Adaptive) + +```go +// theme.go — semantic color tokens using AdaptiveColor +var ( + FocusColor = lipgloss.AdaptiveColor{Light: "62", Dark: "63"} // blue-purple + AccentColor = lipgloss.AdaptiveColor{Light: "214", Dark: "214"} // orange + MutedColor = lipgloss.AdaptiveColor{Light: "245", Dark: "243"} // gray + AddedColor = lipgloss.AdaptiveColor{Light: "28", Dark: "114"} // green + RemovedColor = lipgloss.AdaptiveColor{Light: "124", Dark: "210"} // red +) +``` + +### Focus Indicators + +- **Focused column title**: FocusColor foreground + Bold +- **Focused column left edge**: FocusColor `▎` vertical bar (1 char) +- **Unfocused columns**: entire content rendered with Faint +- **Preview title**: shows MDL/NDSL mode in AccentColor + +### Header Enhancement + +``` +Before: 1:App.mpr 2:Other.mpr +After: ❶ App.mpr ❷ Other.mpr Browse │ 3 modules, 47 entities +``` + +- Left: tabs with FocusColor underline on active tab +- Center: ViewMode badge (Browse / Compare / Diff / Overlay) +- Right: context summary (node counts from project tree) + +### Footer Enhancement + +``` +Before: h:back l:open /:filter Tab:mdl/ndsl ... + MyModule > Entities > Customer 3/47 MDL + +After: h back l open / filter ⇥ mdl/ndsl c compare ? help + MyModule › Entities › Customer 3/47 ⎸ MDL +``` + +- HintBar: remove `:` separator, use space instead (more compact) +- StatusBar: use `›` for breadcrumb, `⎸` for mode separator +- When ViewStack depth > 1, show depth: `[Browse > Compare > Diff]` + +## Navigation & Interaction + +### Global Fuzzy Jump + +Press `Space` or `Ctrl+P` → opens a fuzzy finder overlay: + +``` +┌──────────────────────────────────────┐ +│ > cust_ │ +│ 🏢 MyModule.Customer Entity │ +│ 📄 MyModule.Customer_Overview Page │ +│ ⚡ MyModule.ACT_Customer_Create MF │ +└──────────────────────────────────────┘ +``` + +- Reuses existing `flattenQualifiedNames()` + fuzzy match logic +- On selection: navigates Miller columns to the node (expands path) +- LLM-friendly: results are plain text, capturable via tmux + +### Preview Debounce + +Add 150ms debounce to cursor change → preview request: + +```go +func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.Cmd) { + m.pendingPreviewNode = msg.Node + return m, tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { + return previewDebounceMsg{node: msg.Node} + }) +} +``` + +Prevents flooding mxcli subprocesses during fast j/k scrolling. + +### Keybinding Redesign + +| Key | Global | Browser | Overlay | Compare | Diff | +|-----|--------|---------|---------|---------|------| +| `q` / `Esc` | Pop ViewStack (quit if empty) | — | — | — | — | +| `Space` | Global fuzzy jump | — | — | — | — | +| `Tab` | — | MDL/NDSL toggle | MDL/NDSL toggle | — | Diff mode cycle | +| `1-9` | Tab switch (Browser) | — | — | Compare mode | — | +| `y` | Copy content | Copy preview | Copy content | Copy diff | Copy diff | + +Key changes: +- **`q`/`Esc` unified** as "exit current layer" (ViewStack.Pop) +- **`Space` for global jump** (currently unused) +- **`1-9` context-sensitive**: tab switch in Browser, compare mode in Compare + +### Clickable Breadcrumb + +Each segment in the status bar breadcrumb registers as a mouse zone. Clicking a segment calls `goBack()` to that navigation level. + +## LLM Friendliness + +### Structured Anchors + +Each view's first rendered line includes a machine-parseable prefix: + +``` +[mxcli:browse] MyModule > Entities > Customer 3/47 MDL +[mxcli:compare] Left: Entity.Customer Right: Entity.Order NDSL|NDSL +[mxcli:diff] unified +12 -8 3 hunks +``` + +LLMs can `grep '[mxcli:'` to identify current state. + +### Plain Text Yank + +Existing `y` → clipboard mechanism preserved. All views implement `PlainText() string` for ANSI-stripped output. + +### Future: Machine Mode + +Environment variable `MXCLI_TUI_MACHINE=1` strips all ANSI codes. Stretch goal, not in initial scope. + +## Implementation Phases + +### Phase 1: View System Foundation + +**Files**: New `view.go`, `viewstack.go`; rewrite `app.go` + +1. Define `View` interface, `ViewMode` enum, `StatusInfo` struct +2. Implement `ViewStack` with Push/Pop/Active +3. Wrap existing `MillerView` as `BrowserView` implementing `View` +4. Wrap existing `Overlay` as `OverlayView` implementing `View` +5. Wrap existing `CompareView` implementing `View` +6. Wrap existing `DiffView` implementing `View` +7. Rewrite `app.go` to use ViewStack — eliminate all `syncXxx()` calls +8. Verify all existing functionality works unchanged + +### Phase 2: Visual System + +**Files**: Replace `styles.go` → `theme.go`; modify `column.go`, `statusbar.go`, `hintbar.go`, `tabbar.go` + +1. Create `theme.go` with AdaptiveColor tokens +2. Add focus color to column titles and left edge indicator +3. Add Faint to unfocused columns +4. Enhance header: ViewMode badge + context summary +5. Enhance footer: compact hint format, breadcrumb with `›` +6. Add ViewStack depth indicator when depth > 1 + +### Phase 3: Navigation + +**Files**: New `jumper.go`; modify `miller.go`, `preview.go` + +1. Implement global fuzzy jump (reuse picker logic) +2. Add preview debounce (150ms) +3. Unify `q`/`Esc` as ViewStack.Pop +4. Add `Space` keybinding for global jump +5. Make `1-9` context-sensitive + +### Phase 4: Polish + +**Files**: Modify `statusbar.go`, `app.go` + +1. Add LLM anchor lines to each view's render +2. Implement clickable breadcrumb (mouse zones) +3. Code cleanup: remove dead code, update comments +4. Update help text for new keybindings + +## Testing Strategy + +- Each phase: `make test` + manual testing with `mxcli tui -p app.mpr` +- Phase 1 is the riskiest (refactoring core routing) — test all view transitions +- Existing TUI tests (if any) must pass at each phase boundary diff --git a/docs/plans/2026-03-24-tui-ux-refactor-implementation-plan.md b/docs/plans/2026-03-24-tui-ux-refactor-implementation-plan.md new file mode 100644 index 0000000..1a42200 --- /dev/null +++ b/docs/plans/2026-03-24-tui-ux-refactor-implementation-plan.md @@ -0,0 +1,748 @@ +# TUI 2.0 UX Refactor — Implementation Plan + +**Date**: 2026-03-24 +**Design Doc**: `docs/plans/2026-03-24-tui-ux-refactor-design.md` +**Branch**: `feat/tui-ux-refactor` (from `feat/tui-diff-view`) + +## Current Architecture Summary + +| File | Lines | Role | +|------|-------|------| +| `app.go` | 760 | Root Bubble Tea model; routes all messages; manages overlay/compare/diff via booleans & pointers | +| `miller.go` | 862 | Three-column Miller view; navigation stack; preview rendering | +| `column.go` | 511 | Scrollable list column with filter; emits `CursorChangedMsg` | +| `compare.go` | 634 | Side-by-side comparison with built-in fuzzy picker | +| `diffview.go` | 666 | Interactive diff viewer (unified/side-by-side/plain modes) | +| `overlay.go` | 135 | Fullscreen modal wrapping `ContentView` | +| `contentview.go` | 409 | Scrollable content viewer with line numbers + search | +| `styles.go` | 45 | Monochrome style tokens | +| `hintbar.go` | 128 | Context-sensitive key hints | +| `statusbar.go` | 66 | Bottom status line (breadcrumb + position + mode) | +| `tabbar.go` | 104 | Horizontal tab bar with click zones | +| `tab.go` | 105 | Tab struct (Miller + nodes + project path) | +| `picker.go` | 443 | Project path picker (standalone + embedded) | +| `preview.go` | 242 | Async preview engine with cache + cancellation | +| `help.go` | 50 | Help overlay text | + +### Current Routing in `app.go` + +The `Update()` method uses a priority chain of `if` checks: +1. `a.picker != nil` → delegate to picker +2. `a.diffView != nil` → delegate to DiffView +3. `a.compare.IsVisible()` → delegate to CompareView +4. `a.overlay.IsVisible()` → delegate to Overlay +5. `a.showHelp` → dismiss help +6. default → `updateNormalMode()` → Miller view + +State sync is manual: every branch calls `a.syncTabBar()`, `a.syncStatusBar()`, `a.syncHintBar()` — 30+ call sites total. + +### Key Coupling Points + +- `App` holds `overlay Overlay`, `compare CompareView`, `diffView *DiffView`, `picker *PickerModel`, `showHelp bool` — all direct struct fields +- `App.View()` has the same priority chain as `Update()` +- `syncHintBar()` maps the priority chain to hint sets +- `syncStatusBar()` reads directly into `tab.Miller.preview.mode`, `tab.Miller.current.cursor` +- Compare/Overlay/Diff each render their own chrome (title bar, status bar, hint bar) — not shared +- `CompareView` has a built-in fuzzy picker (`picker bool`, `pickerInput`, etc.) +- `MillerView` directly renders preview content inside its `View()` method + +--- + +## Phase 1: View System Foundation + +**Goal**: Introduce `View` interface + `ViewStack`; rewrite `app.go` to use them. All existing functionality preserved. + +### Step 1.1: Define core types — `view.go` (NEW, ~80 lines) + +```go +// view.go +package tui + +type ViewMode int +const ( + ModeBrowser ViewMode = iota + ModeOverlay + ModeCompare + ModeDiff + ModePicker +) + +type StatusInfo struct { + Breadcrumb []string + Position string // "3/47" + Mode string // "MDL", "NDSL" + Extra string // view-specific +} + +type Hint struct { Key string; Label string } // already exists in hintbar.go — REUSE it + +type View interface { + Update(tea.Msg) (View, tea.Cmd) + Render(width, height int) string + Hints() []Hint + StatusInfo() StatusInfo + Mode() ViewMode +} +``` + +**Notes**: +- `Hint` struct already exists in `hintbar.go`. Do NOT duplicate — import from same package. +- `View.Update()` returns `(View, tea.Cmd)` instead of `(tea.Model, tea.Cmd)` — this is intentional to stay within the `tui` package type system. +- `View.Render(width, height int) string` replaces `View()` — explicit dimensions for layout composability. + +### Step 1.2: Implement ViewStack — `viewstack.go` (NEW, ~60 lines) + +```go +// viewstack.go +package tui + +type ViewStack struct { + base View // always BrowserView + stack []View // pushed views (overlay/compare/diff) +} + +func NewViewStack(base View) ViewStack +func (vs *ViewStack) Active() View // top of stack, or base +func (vs *ViewStack) Push(v View) +func (vs *ViewStack) Pop() (View, bool) // returns popped view + ok; no-op if stack empty +func (vs *ViewStack) Depth() int // len(stack) + 1 (for base) +func (vs *ViewStack) SetActive(v View) // replace top of stack (or base if empty) +``` + +**Notes**: +- `SetActive()` is needed because `Update()` returns a new `View` value (Go value semantics). +- `Pop()` returns the popped view so the caller can inspect it if needed. + +### Step 1.3: Wrap MillerView as BrowserView — `browserview.go` (NEW, ~160 lines) + +This is a **wrapper**, not a rewrite. `BrowserView` embeds the existing `MillerView` and adds the `View` interface. + +```go +type BrowserView struct { + miller MillerView + tab *Tab + allNodes []*TreeNode + mxcliPath string + projectPath string +} +``` + +**Interface methods**: +- `Update(msg)` — delegates to `MillerView.Update()` for key/mouse/cursor/preview messages. Handles node-action keys (`b`, `m`, `c`, `d`, `y`) that currently live in `app.go:updateNormalMode()`. Returns `tea.Cmd` that may emit `PushViewMsg` (new message type) for overlay/compare/diff. +- `Render(w, h)` — calls `miller.SetSize(w, h)` + `miller.View()`. +- `Hints()` — returns `ListBrowsingHints` or `FilterActiveHints` based on `miller.focusedColumn().IsFilterActive()`. +- `StatusInfo()` — reads breadcrumb, position, mode from `miller`. +- `Mode()` — returns `ModeBrowser`. + +**New message types** (in `view.go`): +```go +type PushViewMsg struct { View View } +type PopViewMsg struct{} +``` + +These replace the implicit `a.overlay.Show()` / `a.compare.Show()` / `a.diffView = &dv` patterns. `App` handles them by calling `ViewStack.Push()`/`Pop()`. + +**What moves out of `app.go` into `BrowserView`**: +- `updateNormalMode()` keys: `b`, `m`, `c`, `d`, `y`, `r`, `z`, `/` (filter delegate) +- Helper methods: `runBsonOverlay()`, `runMDLOverlay()`, `loadBsonNDSL()`, `loadMDL()`, `loadForCompare()`, `openDiagram()` +- Overlay state: `overlayQName`, `overlayNodeType`, `overlayIsNDSL` + +**What stays in `app.go`**: +- Tab management keys: `t`, `T`, `W`, `1-9`, `[`, `]` +- Global keys: `q`, `?`, `ctrl+c` +- `LoadTreeMsg` handling (needs access to tabs array) +- Tab bar / chrome rendering + +### Step 1.4: Wrap Overlay as OverlayView — modify `overlay.go` (~40 lines added) + +Add `View` interface methods to existing `Overlay` struct: + +```go +func (o Overlay) Update(msg tea.Msg) (View, tea.Cmd) // wraps existing o.Update() +func (o Overlay) Render(w, h int) string // wraps existing o.View() +func (o Overlay) Hints() []Hint // returns OverlayHints +func (o Overlay) StatusInfo() StatusInfo // title + scroll position +func (o Overlay) Mode() ViewMode // ModeOverlay +``` + +**Key change**: When overlay closes (`esc`/`q` pressed), instead of setting `o.visible = false`, it returns a `tea.Cmd` that emits `PopViewMsg{}`. + +**Tab switching** (NDSL/MDL toggle): Currently handled by `app.go` reading `a.overlayQName`. This state needs to live in the `Overlay` (or a wrapping `OverlayView`). Two options: + +- **Option A**: Add `qname`, `nodeType`, `isNDSL`, `switchable` fields to `Overlay` and a callback `func(nodeType, qname string) tea.Cmd` for reloading. +- **Option B (recommended)**: Create a thin `OverlayView` wrapper struct that embeds `Overlay` and holds the reload context. + +Going with **Option B**: Create `OverlayView` struct in `overlay.go`: + +```go +type OverlayView struct { + overlay Overlay + qname string + nodeType string + isNDSL bool + mxcliPath string + projectPath string +} +``` + +The `OverlayView.Update()` handles `tab` for NDSL/MDL switching internally (moves logic from `app.go:260-285`). + +### Step 1.5: Wrap CompareView — modify `compare.go` (~30 lines added) + +Add `View` interface methods to existing `CompareView`: + +```go +func (c CompareView) Update(msg tea.Msg) (View, tea.Cmd) // wraps existing +func (c CompareView) Render(w, h int) string // sets size, calls View() +func (c CompareView) Hints() []Hint // CompareHints +func (c CompareView) StatusInfo() StatusInfo +func (c CompareView) Mode() ViewMode // ModeCompare +``` + +**Key change**: `esc`/`q` emits `PopViewMsg` instead of `c.visible = false`. + +**Issue**: `CompareView` currently stores `visible bool` and the `View()` method checks it. With ViewStack, visibility is implicit (it's on the stack or not). The `visible` field becomes redundant but can stay for backward compat during transition. + +**Diff launch**: When `D` is pressed in compare, it currently emits `DiffOpenMsg` which `app.go` handles by creating a `DiffView` and setting `a.diffView = &dv`. After refactor, it should emit `PushViewMsg{View: NewDiffViewWrapped(...)}`. + +### Step 1.6: Wrap DiffView — modify `diffview.go` (~30 lines added) + +Add `View` interface methods: + +```go +func (dv DiffView) UpdateView(msg tea.Msg) (View, tea.Cmd) // wraps existing, named differently to avoid conflict +func (dv DiffView) Render(w, h int) string +func (dv DiffView) Hints() []Hint // DiffViewHints +func (dv DiffView) StatusInfo() StatusInfo +func (dv DiffView) Mode() ViewMode // ModeDiff +``` + +**Naming conflict**: `DiffView` already has `func (dv DiffView) Update(msg tea.Msg) (DiffView, tea.Cmd)`. The `View` interface needs `Update(tea.Msg) (View, tea.Cmd)`. Solution: rename existing to `updateInternal` and add the interface method. + +**Key change**: `q`/`esc` emits `PopViewMsg` instead of `DiffCloseMsg`. + +### Step 1.7: Wrap PickerModel — `pickerview.go` (NEW, ~50 lines) + +```go +type PickerView struct { + picker PickerModel +} +``` + +Implements `View` interface. When picker completes, emits `PopViewMsg` + action message. + +### Step 1.8: Rewrite `app.go` (~250 lines, down from 760) + +The new `App` struct: + +```go +type App struct { + tabs []Tab + activeTab int + nextTabID int + + width int + height int + mxcliPath string + + views ViewStack // replaces overlay, compare, diffView, picker, showHelp + showHelp bool // help is special (rendered as overlay on top of chrome) + + tabBar TabBar + statusBar StatusBar + hintBar HintBar + previewEngine *PreviewEngine +} +``` + +**New `Update()` flow**: + +```go +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // 1. Handle PushViewMsg / PopViewMsg + // 2. Handle global keys (ctrl+c, tab management, help toggle) + // 3. Delegate to active view + active := a.views.Active() + updated, cmd := active.Update(msg) + a.views.SetActive(updated) + return a, cmd +} +``` + +**New `View()` flow**: + +```go +func (a App) View() string { + active := a.views.Active() + + // Chrome + header := a.renderHeader(active) // tab bar + mode badge + context + footer := a.renderFooter(active) // hint bar + status bar + + contentH := a.height - chromeHeight + content := active.Render(a.width, contentH) + + return header + "\n" + content + "\n" + footer +} +``` + +**Chrome rendering** is now declarative: +- `a.hintBar.SetHints(active.Hints())` — no more `syncHintBar()` +- Status bar reads from `active.StatusInfo()` — no more `syncStatusBar()` +- Tab bar sync only needed on tab changes — no more scattered `syncTabBar()` calls + +**Message routing changes**: + +| Old | New | +|-----|-----| +| `DiffOpenMsg` → `a.diffView = &dv` | `DiffOpenMsg` → `PushViewMsg{NewDiffView(...)}` | +| `DiffCloseMsg` → `a.diffView = nil` | `PopViewMsg` | +| `OpenOverlayMsg` → `a.overlay.Show(...)` | `PushViewMsg{NewOverlayView(...)}` | +| `ComparePickMsg` → `a.compare.SetLoading()` | Stays within `CompareView.Update()` | +| `CompareLoadMsg` → `a.compare.SetContent()` | Stays within `CompareView.Update()` | +| `CompareReloadMsg` → reload both panes | Stays within `CompareView.Update()` | + +**What `App` still handles directly**: +- `tea.WindowSizeMsg` — resize + propagate to active view +- `LoadTreeMsg` — update tab nodes, set items on BrowserView +- `PickerDoneMsg` — create new tab +- `CmdResultMsg` — push overlay with result +- Tab management keys (`t`, `T`, `W`, `1-9`, `[`, `]`) + +### Step 1.9: Handle message forwarding for async results + +**Problem**: `CompareLoadMsg`, `CompareReloadMsg`, `ComparePickMsg`, `PreviewReadyMsg`, `PreviewLoadingMsg` are emitted by async commands and need to reach the correct view. Currently `app.go` routes them explicitly. + +**Solution**: `App.Update()` forwards all non-global messages to `active.Update()`. The active view (CompareView, BrowserView, etc.) handles its own async messages. If a message doesn't match, it's a no-op. + +**Edge case**: `CompareLoadMsg` arrives after user has popped CompareView. This is harmless — the message hits BrowserView which ignores it. + +### Step 1.10: Verification + +1. `make build` — compile succeeds +2. `make test` — all existing tests pass +3. Manual test matrix: + - [ ] Launch TUI, navigate Miller columns (h/l/j/k) + - [ ] Press `b` → BSON overlay opens, `Tab` switches NDSL/MDL + - [ ] Press `m` → MDL overlay opens + - [ ] Press `Esc` → overlay closes + - [ ] Press `c` → compare view opens + - [ ] In compare: `/` picker, `1/2/3` mode switch, `D` diff + - [ ] In compare: `Esc` → closes + - [ ] In diff: `Tab` mode cycle, `q` → closes back to compare + - [ ] Tab management: `t` clone, `T` new project, `W` close, `1-9` switch + - [ ] Filter: `/` in column, type, `Enter`/`Esc` + - [ ] Mouse: click columns, scroll wheel, tab bar click + - [ ] Resize terminal window + - [ ] `y` yank in all views + +### Risk Areas — Phase 1 + +1. **Value semantics**: Go's Bubble Tea pattern uses value receivers. `View` interface methods must handle value/pointer semantics correctly. `MillerView` uses value receivers (`func (m MillerView) Update()`), so `BrowserView` wrapping it must copy correctly. `CompareView` already uses value receivers too. + +2. **Overlay state**: Moving `overlayQName/nodeType/isNDSL` from `App` to `OverlayView` means the overlay reload command needs `mxcliPath` and `projectPath`. These are passed at construction time. + +3. **Compare → Diff flow**: Currently `CompareView.Update()` emits `DiffOpenMsg` which `App` handles. After refactor, `CompareView` should emit `PushViewMsg` with a constructed `DiffView`. But `CompareView` doesn't have access to the `View` constructor. **Solution**: `CompareView` still emits `DiffOpenMsg`; `App.Update()` intercepts it before delegating to active view and does `ViewStack.Push(NewDiffView(...))`. + +4. **Compare needs `mxcliPath`/`projectPath`**: For `ComparePickMsg` handling, `App` currently calls `a.loadForCompare()` which uses `a.mxcliPath` and `tab.ProjectPath`. After refactor, `CompareView` needs these at construction time, or `BrowserView` handles `ComparePickMsg` and forwards the loaded content. + + **Solution**: `CompareView` stores `mxcliPath` and `projectPath` (passed at construction). Its `Update()` handles `ComparePickMsg` internally using these fields. `CompareLoadMsg` is already handled by `CompareView`. + +--- + +## Phase 2: Visual System + +**Goal**: Replace monochrome styles with semantic color tokens. Add focus indicators. Enhance chrome. + +**Prerequisite**: Phase 1 complete. + +### Step 2.1: Create `theme.go` (REPLACE `styles.go`, ~100 lines) + +```go +// theme.go +package tui + +var ( + // Semantic color tokens — AdaptiveColor for light/dark terminal support + FocusColor = lipgloss.AdaptiveColor{Light: "62", Dark: "63"} + AccentColor = lipgloss.AdaptiveColor{Light: "214", Dark: "214"} + MutedColor = lipgloss.AdaptiveColor{Light: "245", Dark: "243"} + AddedColor = lipgloss.AdaptiveColor{Light: "28", Dark: "114"} + RemovedColor = lipgloss.AdaptiveColor{Light: "124", Dark: "210"} + + // Derived styles (same names as styles.go for minimal diff) + SeparatorChar = "│" + SeparatorStyle = lipgloss.NewStyle().Foreground(MutedColor) + + ActiveTabStyle = lipgloss.NewStyle().Bold(true).Foreground(FocusColor).Underline(true) + InactiveTabStyle = lipgloss.NewStyle().Foreground(MutedColor) + + ColumnTitleStyle = lipgloss.NewStyle().Bold(true) + FocusedTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(FocusColor) + FocusedEdgeChar = "▎" + FocusedEdgeStyle = lipgloss.NewStyle().Foreground(FocusColor) + + SelectedItemStyle = lipgloss.NewStyle().Reverse(true) + DirectoryStyle = lipgloss.NewStyle().Bold(true) + LeafStyle = lipgloss.NewStyle() + + // ... rest of existing styles migrated with color tokens +) +``` + +**Migration**: Delete `styles.go`, create `theme.go`. All style variable names stay the same — zero downstream impact. + +### Step 2.2: Add focus indicators to columns — modify `column.go` (~20 lines changed) + +In `Column.View()`: +- When `c.focused`: render title with `FocusedTitleStyle`, prepend each line with `FocusedEdgeStyle.Render(FocusedEdgeChar)` (1 char, deduct from content width) +- When `!c.focused`: wrap entire output with `lipgloss.NewStyle().Faint(true)` (gray out) + +Changes to `column.go`: +1. `View()` method: check `c.focused` flag +2. Title rendering: `FocusedTitleStyle` vs `ColumnTitleStyle` +3. Line rendering: add `FocusedEdgeChar` prefix when focused +4. Width calculation: subtract 1 for edge char when focused + +### Step 2.3: Add Faint to unfocused columns — modify `miller.go` (~10 lines) + +In `MillerView.View()`: +- Parent column already has `SetFocused(false)` when current is focused +- Preview child column: add `SetFocused(false)` +- The Faint styling is handled by `Column.View()` from Step 2.2 + +### Step 2.4: Enhance preview mode label — modify `miller.go` (~5 lines) + +In `renderPreview()`: use `AccentColor` for the MDL/NDSL mode label instead of plain bold. + +### Step 2.5: Enhance header — modify `app.go` or new `chrome.go` (~60 lines) + +Create `renderHeader()` in `app.go` (or separate `chrome.go`): + +``` +❶ App.mpr ❷ Other.mpr Browse │ 3 modules, 47 entities +``` + +- Left: tabs with numbered circles (❶❷❸...) instead of `[1]` +- Center: ViewMode badge from `active.Mode()` → string +- Right: context summary — count modules/entities from `tab.AllNodes` + +`renderContextSummary(nodes []*TreeNode) string` — walks tree, counts by type. + +### Step 2.6: Enhance footer — modify `hintbar.go` + `statusbar.go` (~30 lines total) + +**HintBar** changes: +- Remove `:` separator between key and label (currently `Key:Label`, change to `Key Label`) +- This is a 1-line change in `View()`: `HintKeyStyle.Render(hint.Key) + " " + HintLabelStyle.Render(hint.Label)` + +**StatusBar** changes: +- Use `›` instead of ` > ` for breadcrumb separator +- Use `⎸` before mode indicator +- Add ViewStack depth indicator: `[Browse > Compare > Diff]` when depth > 1 +- `StatusBar` needs to accept `ViewStack.Depth()` and mode names — add `SetViewStackInfo(depth int, modes []string)` + +### Step 2.7: Verification + +1. `make build` + `make test` +2. Visual inspection: + - [ ] Focused column has blue title + blue left edge + - [ ] Unfocused columns are faint + - [ ] Tab bar shows colored underline on active tab + - [ ] Header shows mode badge + context summary + - [ ] Footer uses `›` breadcrumb, compact hints + - [ ] Dark terminal: colors readable + - [ ] Light terminal: colors readable (if testable) + +--- + +## Phase 3: Navigation + +**Goal**: Add global fuzzy jump, preview debounce, unified keybindings. + +**Prerequisite**: Phase 1 + Phase 2 complete. + +### Step 3.1: Implement global fuzzy jump — `jumper.go` (NEW, ~180 lines) + +```go +type JumperView struct { + input textinput.Model + items []PickerItem // all qualified names from tree + matches []pickerMatch // filtered + scored + cursor int + offset int + width int + height int + maxShow int // 12 +} +``` + +Implements `View` interface. + +**Reuse**: Copy fuzzy scoring from `compare.go:fuzzyScore()`. The `PickerItem` type is already defined in `compare.go`. Consider extracting `fuzzyScore` and `PickerItem` to a shared file (`fuzzy.go`) to avoid duplication. + +**On selection**: `JumperView.Update()` on `enter` emits a new `JumpToNodeMsg{QName string, NodeType string}`. `BrowserView.Update()` handles this by navigating the Miller columns: +1. Walk `allNodes` to find the path to the node +2. Reset nav stack +3. Drill in step by step to reach the target + +**Rendering**: Centered modal box (same pattern as compare picker and overlay): +``` +┌──────────────────────────────────────┐ +│ > cust_ │ +│ 🏢 MyModule.Customer Entity │ +│ 📄 MyModule.Customer_Overview Page │ +│ ⚡ MyModule.ACT_Customer_Create MF │ +└──────────────────────────────────────┘ +``` + +### Step 3.2: Extract fuzzy logic — `fuzzy.go` (NEW, ~50 lines) + +Move from `compare.go`: +- `type PickerItem struct` — stays in `compare.go` (or move here, update imports) +- `func fuzzyScore(target, query string) (bool, int)` → move to `fuzzy.go` +- `type pickerMatch struct` → move to `fuzzy.go` + +This avoids duplication between `JumperView` and `CompareView`'s picker. + +### Step 3.3: Add `navigateToNode()` to BrowserView — modify `browserview.go` (~60 lines) + +```go +func (bv *BrowserView) navigateToNode(qname string) tea.Cmd +``` + +Algorithm: +1. Walk `allNodes` recursively to find path: `[root, module, category, node]` +2. Reset Miller to root (`SetRootNodes`) +3. For each step in path: call `drillIn()` programmatically (set cursor to matching child, then drill) +4. Return final preview request command + +### Step 3.4: Add preview debounce — modify `miller.go` (~20 lines) + +In `handleCursorChanged()`: + +```go +type previewDebounceMsg struct { + node *TreeNode + counter int +} + +func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.Cmd) { + m.pendingPreviewNode = msg.Node + m.debounceCounter++ + counter := m.debounceCounter + return m, tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { + return previewDebounceMsg{node: msg.Node, counter: counter} + }) +} +``` + +Add `pendingPreviewNode *TreeNode` and `debounceCounter int` fields to `MillerView`. + +Handle `previewDebounceMsg` in `Update()`: +- If `msg.counter != m.debounceCounter`, ignore (superseded by newer cursor move) +- Otherwise, proceed with preview request + +**Child column preview** (non-leaf nodes): show immediately without debounce (purely local, no subprocess). + +### Step 3.5: Unify `q`/`Esc` as ViewStack.Pop — already done in Phase 1 + +Each wrapped view already emits `PopViewMsg` on `q`/`Esc`. `App.Update()` handles `PopViewMsg` by calling `ViewStack.Pop()`. + +For base view (BrowserView): `q` still quits the application (current behavior). `Esc` does nothing special in Browser mode (or could deactivate filter — already handled by Column). + +### Step 3.6: Add `Space` keybinding for global jump — modify `app.go` (~10 lines) + +In `App.Update()` global key handler: + +```go +case " ", "ctrl+p": + // Build item list from active tab's nodes + items := flattenQualifiedNames(tab.AllNodes) + jumper := NewJumperView(items, a.width, a.height) + a.views.Push(jumper) + return a, nil +``` + +### Step 3.7: Make `1-9` context-sensitive — modify `app.go` (~15 lines) + +In `App.Update()`: + +```go +case "1", "2", ..., "9": + if a.views.Active().Mode() == ModeBrowser { + // Tab switch (existing behavior) + a.switchToTab(idx) + } + // In other modes: let active view handle (compare uses 1/2/3 for mode) + // Already handled by delegation to active.Update() +``` + +Actually, current flow already works: if active view is CompareView, `App.Update()` delegates to it, and CompareView handles `1/2/3`. If active is BrowserView, `App.Update()` checks for tab management keys first. The key is to ensure `App` only intercepts `1-9` when active mode is Browser. + +### Step 3.8: Verification + +1. `make build` + `make test` +2. Manual testing: + - [ ] Press `Space` → fuzzy jump opens + - [ ] Type partial name → results filter + - [ ] Press `Enter` → navigates to node in Miller + - [ ] Press `Esc` → jump closes + - [ ] Fast j/k scrolling → preview updates with 150ms debounce (no subprocess flooding) + - [ ] Slow j/k → preview still updates + - [ ] `q` in overlay/compare/diff → pops back to previous view + - [ ] `q` in browser → quits app + +--- + +## Phase 4: Polish + +**Goal**: LLM anchors, clickable breadcrumb, help update, code cleanup. + +**Prerequisite**: Phase 1-3 complete. + +### Step 4.1: Add LLM anchor lines — modify each view's `Render()` (~5 lines each) + +Each view prepends a machine-parseable line to its rendered output: + +| View | Anchor | +|------|--------| +| BrowserView | `[mxcli:browse] MyModule > Entities > Customer 3/47 MDL` | +| OverlayView | `[mxcli:overlay] BSON: MyModule.Customer NDSL` | +| CompareView | `[mxcli:compare] Left: Entity.Customer Right: Entity.Order NDSL\|NDSL` | +| DiffView | `[mxcli:diff] unified +12 -8 3 hunks` | +| JumperView | `[mxcli:jump] > query_text 12 matches` | + +The anchor is the **first line** of `Render()` output, replacing one line from `chromeHeight`. + +### Step 4.2: Implement clickable breadcrumb — modify `statusbar.go` + `app.go` (~40 lines) + +Use Bubble Tea's zone manager or manual mouse zone tracking: + +```go +type BreadcrumbClickMsg struct { + Depth int // which breadcrumb segment was clicked (0 = root) +} +``` + +In `StatusBar.View()`: +- Track column ranges for each breadcrumb segment +- Store zones in `StatusBar.zones []breadcrumbZone` + +In `App.Update()` for `tea.MouseMsg`: +- Check if Y == last line (status bar) +- Call `statusBar.HitTest(x)` → `BreadcrumbClickMsg` +- If hit: call `miller.goBack()` N times to reach the clicked depth + +**Implementation detail**: `goBack()` N times means popping N entries from `MillerView.navStack`. Add `goBackToDepth(depth int)` method to `MillerView` that pops multiple levels. + +### Step 4.3: Update help text — modify `help.go` (~20 lines) + +Update `helpText` constant to reflect new keybindings: +- Add `Space` for fuzzy jump +- Remove `Tab` from browser mode (if changed) +- Update overlay/compare/diff sections + +### Step 4.4: Code cleanup — across multiple files + +1. **Remove dead code**: + - `a.syncHintBar()` calls — replaced by declarative `active.Hints()` + - `a.syncStatusBar()` calls — replaced by `active.StatusInfo()` + - `a.syncTabBar()` — only called on tab changes now + - `overlay.visible` field — visibility managed by ViewStack + - `compare.visible` field — visibility managed by ViewStack + - `showHelp` bool if help becomes a View + +2. **Remove `app.go` helper methods** that moved to views: + - `runBsonOverlay()`, `runMDLOverlay()` → `OverlayView` + - `loadBsonNDSL()`, `loadMDL()`, `loadForCompare()` → `CompareView` or `BrowserView` + - `openDiagram()` → `BrowserView` + +3. **Delete `styles.go`** (replaced by `theme.go` in Phase 2) + +4. **Update comments** in files that reference old routing logic + +### Step 4.5: Verification + +1. `make build` + `make test` +2. LLM anchor testing: + - [ ] `tmux capture-pane -p | grep '\[mxcli:'` shows current state + - [ ] Each view mode produces correct anchor +3. Breadcrumb clicking: + - [ ] Click middle segment → navigates back to that level + - [ ] Click root → goes to root +4. Help text accurate + +--- + +## Implementation Order Summary + +``` +Phase 1 (Foundation) — ~600 lines new/changed + 1.1 view.go (NEW) — types + 1.2 viewstack.go (NEW) — stack logic + 1.3 browserview.go (NEW) — Miller wrapper + 1.4 overlay.go (MODIFY) — OverlayView wrapper + 1.5 compare.go (MODIFY) — View interface + 1.6 diffview.go (MODIFY) — View interface + 1.7 pickerview.go (NEW) — Picker wrapper + 1.8 app.go (REWRITE) — ViewStack-based routing + 1.9 message routing — async result forwarding + 1.10 verification — full manual test + +Phase 2 (Visual) — ~200 lines new/changed + 2.1 theme.go (NEW, replaces styles.go) + 2.2 column.go (MODIFY) — focus indicators + 2.3 miller.go (MODIFY) — faint unfocused + 2.4 miller.go (MODIFY) — accent preview label + 2.5 app.go (MODIFY) — enhanced header + 2.6 hintbar.go + statusbar.go (MODIFY) + 2.7 verification + +Phase 3 (Navigation) — ~300 lines new/changed + 3.1 jumper.go (NEW) — fuzzy jump view + 3.2 fuzzy.go (NEW) — extracted fuzzy logic + 3.3 browserview.go (MODIFY) — navigateToNode + 3.4 miller.go (MODIFY) — preview debounce + 3.5 (already done in P1) + 3.6 app.go (MODIFY) — Space keybinding + 3.7 app.go (MODIFY) — context-sensitive 1-9 + 3.8 verification + +Phase 4 (Polish) — ~150 lines new/changed + 4.1 all views (MODIFY) — LLM anchors + 4.2 statusbar.go + app.go — clickable breadcrumb + 4.3 help.go (MODIFY) — updated help text + 4.4 cleanup — dead code removal + 4.5 verification +``` + +## File Impact Matrix + +| File | Phase 1 | Phase 2 | Phase 3 | Phase 4 | +|------|---------|---------|---------|---------| +| `view.go` | **NEW** | — | — | — | +| `viewstack.go` | **NEW** | — | — | — | +| `browserview.go` | **NEW** | — | MODIFY | MODIFY | +| `pickerview.go` | **NEW** | — | — | — | +| `jumper.go` | — | — | **NEW** | MODIFY | +| `fuzzy.go` | — | — | **NEW** | — | +| `theme.go` | — | **NEW** | — | — | +| `app.go` | **REWRITE** | MODIFY | MODIFY | MODIFY | +| `miller.go` | — | MODIFY | MODIFY | — | +| `column.go` | — | MODIFY | — | — | +| `compare.go` | MODIFY | — | — | MODIFY | +| `diffview.go` | MODIFY | — | — | MODIFY | +| `overlay.go` | MODIFY | — | — | MODIFY | +| `styles.go` | — | **DELETE** | — | — | +| `hintbar.go` | — | MODIFY | — | — | +| `statusbar.go` | — | MODIFY | — | MODIFY | +| `tabbar.go` | — | MODIFY | — | — | +| `help.go` | — | — | — | MODIFY | + +## Test Strategy + +- **Unit tests**: Add `viewstack_test.go` for Push/Pop/Active/Depth logic +- **Existing tests**: `preview_test.go` must continue to pass at every phase boundary +- **Manual tests**: Full test matrix at each phase end (see verification sections) +- **Build gate**: `make build && make test` after every step, not just phase boundaries +- **Regression signal**: If any existing keybinding stops working, it's a regression — fix before proceeding From b440732a4b624304bc842435bed9c331e459d4d1 Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 08:07:02 +0800 Subject: [PATCH 04/10] fix(tui): fix preview auto-trigger, scroll, hunk nav, and add type-prefixed search - Fix preview not auto-triggering on cursor move: previewDebounceMsg was silently discarded by BrowserView.Update() type switch - Fix Miller columns scroll bug: value-semantics caused SetSize() to operate on copies, leaving height=0 and maxVisible()=1 - Fix hunk navigation in Plain Diff mode: compute separate hunk indices from @@ headers, use activeHunkStarts() per view mode - Fix ]c/[c keybinding: implement Vim-style two-key sequence with pendingKey state instead of single-key ]/[ - Fix Plain Diff UTF-8 truncation: use hslice instead of byte-based len() for multi-byte character safety - Extract shared FuzzyList component from CompareView and JumperView - Add type-prefixed fuzzy search (e.g. mf:query filters by Microflow) using dynamic fuzzyScore matching against NodeType, no hardcoded map - Add unit tests for DiffEngine (14), DiffRenderer (16), FuzzyList (10) --- cmd/mxcli/tui/app.go | 24 ++- cmd/mxcli/tui/browserview.go | 2 +- cmd/mxcli/tui/compare.go | 100 +++---------- cmd/mxcli/tui/diffengine_test.go | 249 +++++++++++++++++++++++++++++++ cmd/mxcli/tui/diffrender_test.go | 223 +++++++++++++++++++++++++++ cmd/mxcli/tui/diffview.go | 83 +++++++---- cmd/mxcli/tui/fuzzylist.go | 109 ++++++++++++++ cmd/mxcli/tui/fuzzylist_test.go | 52 +++++++ cmd/mxcli/tui/jumper.go | 101 +++---------- 9 files changed, 756 insertions(+), 187 deletions(-) create mode 100644 cmd/mxcli/tui/diffengine_test.go create mode 100644 cmd/mxcli/tui/diffrender_test.go create mode 100644 cmd/mxcli/tui/fuzzylist.go create mode 100644 cmd/mxcli/tui/fuzzylist_test.go diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index b6503fb..874ed63 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -89,6 +89,12 @@ func (a *App) syncBrowserView() { bv := NewBrowserView(tab, a.mxcliPath, a.previewEngine) bv.allNodes = tab.AllNodes bv.compareItems = flattenQualifiedNames(tab.AllNodes) + // Ensure miller has current dimensions so scroll calculations in + // Update() work correctly (Render operates on a value copy). + if a.height > 0 { + contentH := max(5, a.height-chromeHeight) + bv.miller.SetSize(a.width, contentH) + } a.views.base = bv } @@ -362,6 +368,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Trace("app: resize %dx%d", msg.Width, msg.Height) a.width = msg.Width a.height = msg.Height + // Propagate content dimensions to the browser view so that + // subsequent Update() calls use correct column heights for + // scroll calculations (Render operates on a copy and cannot + // persist dimensions back). + if bv, ok := a.views.Active().(BrowserView); ok { + contentH := a.height - chromeHeight + if contentH < 5 { + contentH = 5 + } + bv.miller.SetSize(a.width, contentH) + a.views.SetActive(bv) + } return a, nil case LoadTreeMsg: @@ -377,13 +395,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { bv.allNodes = msg.Nodes bv.compareItems = flattenQualifiedNames(msg.Nodes) bv.miller = tab.Miller + if a.height > 0 { + contentH := max(5, a.height-chromeHeight) + bv.miller.SetSize(a.width, contentH) + } a.views.base = bv } } } return a, nil - case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg: + case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg, previewDebounceMsg: if a.views.Active().Mode() == ModeBrowser { updated, cmd := a.views.Active().Update(msg) a.views.SetActive(updated) diff --git a/cmd/mxcli/tui/browserview.go b/cmd/mxcli/tui/browserview.go index 51dbe90..68a91df 100644 --- a/cmd/mxcli/tui/browserview.go +++ b/cmd/mxcli/tui/browserview.go @@ -95,7 +95,7 @@ func (bv BrowserView) Render(width, height int) string { // so App can handle them. func (bv BrowserView) Update(msg tea.Msg) (View, tea.Cmd) { switch msg := msg.(type) { - case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg: + case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg, previewDebounceMsg: var cmd tea.Cmd bv.miller, cmd = bv.miller.Update(msg) return bv, cmd diff --git a/cmd/mxcli/tui/compare.go b/cmd/mxcli/tui/compare.go index 4f76e93..1a56a83 100644 --- a/cmd/mxcli/tui/compare.go +++ b/cmd/mxcli/tui/compare.go @@ -83,13 +83,10 @@ type CompareView struct { copiedFlash bool // Fuzzy picker - picker bool - pickerInput textinput.Model - pickerItems []PickerItem - pickerMatches []pickerMatch - pickerCursor int - pickerOffset int - pickerSide CompareFocus + picker bool + pickerInput textinput.Model + pickerList FuzzyList + pickerSide CompareFocus // Self-contained operation (for View interface) mxcliPath string @@ -134,7 +131,7 @@ func (c CompareView) paneDimensions() (int, int) { return pw, ph } -func (c *CompareView) SetItems(items []PickerItem) { c.pickerItems = items } +func (c *CompareView) SetItems(items []PickerItem) { c.pickerList = NewFuzzyList(items, pickerMaxShow) } func (c *CompareView) SetContent(side CompareFocus, title, nodeType, content string) { p := c.pane(side) @@ -175,70 +172,13 @@ func (c *CompareView) openPicker() { c.pickerSide = c.focus c.pickerInput.SetValue("") c.pickerInput.Focus() - c.pickerCursor = 0 - c.pickerOffset = 0 - c.filterPicker() + c.pickerList.Cursor = 0 + c.pickerList.Offset = 0 + c.pickerList.Filter("") } func (c *CompareView) closePicker() { c.picker = false; c.pickerInput.Blur() } -func (c *CompareView) filterPicker() { - query := strings.TrimSpace(c.pickerInput.Value()) - c.pickerMatches = nil - for _, it := range c.pickerItems { - if query == "" { - c.pickerMatches = append(c.pickerMatches, pickerMatch{item: it}) - continue - } - if ok, sc := fuzzyScore(it.QName, query); ok { - c.pickerMatches = append(c.pickerMatches, pickerMatch{item: it, score: sc}) - } - } - // Sort by score descending (insertion sort, small n) - for i := 1; i < len(c.pickerMatches); i++ { - for j := i; j > 0 && c.pickerMatches[j].score > c.pickerMatches[j-1].score; j-- { - c.pickerMatches[j], c.pickerMatches[j-1] = c.pickerMatches[j-1], c.pickerMatches[j] - } - } - if c.pickerCursor >= len(c.pickerMatches) { - c.pickerCursor = max(0, len(c.pickerMatches)-1) - } - c.pickerOffset = 0 -} - -func (c *CompareView) pickerDown() { - if len(c.pickerMatches) == 0 { - return - } - c.pickerCursor++ - if c.pickerCursor >= len(c.pickerMatches) { - c.pickerCursor = 0 - c.pickerOffset = 0 - } else if c.pickerCursor >= c.pickerOffset+pickerMaxShow { - c.pickerOffset = c.pickerCursor - pickerMaxShow + 1 - } -} - -func (c *CompareView) pickerUp() { - if len(c.pickerMatches) == 0 { - return - } - c.pickerCursor-- - if c.pickerCursor < 0 { - c.pickerCursor = len(c.pickerMatches) - 1 - c.pickerOffset = max(0, c.pickerCursor-pickerMaxShow+1) - } else if c.pickerCursor < c.pickerOffset { - c.pickerOffset = c.pickerCursor - } -} - -func (c CompareView) pickerSelected() PickerItem { - if len(c.pickerMatches) == 0 || c.pickerCursor >= len(c.pickerMatches) { - return PickerItem{} - } - return c.pickerMatches[c.pickerCursor].item -} - // --- Update --- func (c CompareView) updateInternal(msg tea.Msg) (CompareView, tea.Cmd) { @@ -275,7 +215,7 @@ func (c CompareView) updatePicker(msg tea.KeyMsg) (CompareView, tea.Cmd) { c.closePicker() return c, nil case "enter": - selected := c.pickerSelected() + selected := c.pickerList.Selected() c.closePicker() if selected.QName != "" { return c, func() tea.Msg { @@ -284,13 +224,13 @@ func (c CompareView) updatePicker(msg tea.KeyMsg) (CompareView, tea.Cmd) { } return c, nil case "up", "ctrl+p": - c.pickerUp() + c.pickerList.MoveUp() case "down", "ctrl+n": - c.pickerDown() + c.pickerList.MoveDown() default: var cmd tea.Cmd c.pickerInput, cmd = c.pickerInput.Update(msg) - c.filterPicker() + c.pickerList.Filter(c.pickerInput.Value()) return c, cmd } return c, nil @@ -556,27 +496,29 @@ func (c CompareView) renderPicker() string { sideLabel = "RIGHT" } + fl := &c.pickerList + var sb strings.Builder sb.WriteString(titleSt.Render(fmt.Sprintf("Pick object (%s)", sideLabel)) + "\n\n") sb.WriteString(c.pickerInput.View() + "\n\n") - end := min(c.pickerOffset+pickerMaxShow, len(c.pickerMatches)) - if c.pickerOffset > 0 { + end := fl.VisibleEnd() + if fl.Offset > 0 { sb.WriteString(dimSt.Render(" ↑ more") + "\n") } typeSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) - for i := c.pickerOffset; i < end; i++ { - it := c.pickerMatches[i].item - if i == c.pickerCursor { + for i := fl.Offset; i < end; i++ { + it := fl.Matches[i].item + if i == fl.Cursor { sb.WriteString(selSt.Render("▸ "+it.QName) + " " + typeSt.Render(it.NodeType) + "\n") } else { sb.WriteString(normSt.Render(" "+it.QName) + " " + dimSt.Render(it.NodeType) + "\n") } } - if end < len(c.pickerMatches) { + if end < len(fl.Matches) { sb.WriteString(dimSt.Render(" ↓ more") + "\n") } - sb.WriteString("\n" + dimSt.Render(fmt.Sprintf(" %d/%d matches", len(c.pickerMatches), len(c.pickerItems)))) + sb.WriteString("\n" + dimSt.Render(fmt.Sprintf(" %d/%d matches", len(fl.Matches), len(fl.Items)))) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). diff --git a/cmd/mxcli/tui/diffengine_test.go b/cmd/mxcli/tui/diffengine_test.go new file mode 100644 index 0000000..6c92b56 --- /dev/null +++ b/cmd/mxcli/tui/diffengine_test.go @@ -0,0 +1,249 @@ +package tui + +import "testing" + +func TestComputeDiff_BothEmpty(t *testing.T) { + result := ComputeDiff("", "") + if len(result.Lines) != 0 { + t.Errorf("expected 0 lines, got %d", len(result.Lines)) + } + if result.Stats.Additions != 0 || result.Stats.Deletions != 0 || result.Stats.Equal != 0 { + t.Errorf("expected zero stats, got %+v", result.Stats) + } +} + +func TestComputeDiff_IdenticalTexts(t *testing.T) { + text := "line1\nline2\nline3\n" + result := ComputeDiff(text, text) + + if result.Stats.Additions != 0 { + t.Errorf("additions = %d, want 0", result.Stats.Additions) + } + if result.Stats.Deletions != 0 { + t.Errorf("deletions = %d, want 0", result.Stats.Deletions) + } + if result.Stats.Equal != 3 { + t.Errorf("equal = %d, want 3", result.Stats.Equal) + } + + for i, dl := range result.Lines { + if dl.Type != DiffEqual { + t.Errorf("line %d: type = %v, want DiffEqual", i, dl.Type) + } + if dl.OldLineNo == 0 || dl.NewLineNo == 0 { + t.Errorf("line %d: line numbers should be non-zero for equal lines", i) + } + } +} + +func TestComputeDiff_OldEmpty(t *testing.T) { + result := ComputeDiff("", "alpha\nbeta\n") + + if result.Stats.Additions != 2 { + t.Errorf("additions = %d, want 2", result.Stats.Additions) + } + if result.Stats.Deletions != 0 { + t.Errorf("deletions = %d, want 0", result.Stats.Deletions) + } + for _, dl := range result.Lines { + if dl.Type != DiffInsert { + t.Errorf("expected DiffInsert, got %v", dl.Type) + } + if dl.OldLineNo != 0 { + t.Errorf("insert line should have OldLineNo=0, got %d", dl.OldLineNo) + } + } +} + +func TestComputeDiff_NewEmpty(t *testing.T) { + result := ComputeDiff("alpha\nbeta\n", "") + + if result.Stats.Deletions != 2 { + t.Errorf("deletions = %d, want 2", result.Stats.Deletions) + } + if result.Stats.Additions != 0 { + t.Errorf("additions = %d, want 0", result.Stats.Additions) + } + for _, dl := range result.Lines { + if dl.Type != DiffDelete { + t.Errorf("expected DiffDelete, got %v", dl.Type) + } + if dl.NewLineNo != 0 { + t.Errorf("delete line should have NewLineNo=0, got %d", dl.NewLineNo) + } + } +} + +func TestComputeDiff_SingleLineChange(t *testing.T) { + result := ComputeDiff("hello world\n", "hello earth\n") + + if result.Stats.Additions != 1 { + t.Errorf("additions = %d, want 1", result.Stats.Additions) + } + if result.Stats.Deletions != 1 { + t.Errorf("deletions = %d, want 1", result.Stats.Deletions) + } + + // Should have word-level segments for the paired change + foundDelete := false + foundInsert := false + for _, dl := range result.Lines { + switch dl.Type { + case DiffDelete: + foundDelete = true + if len(dl.Segments) == 0 { + t.Error("delete line should have word-level segments") + } + case DiffInsert: + foundInsert = true + if len(dl.Segments) == 0 { + t.Error("insert line should have word-level segments") + } + } + } + if !foundDelete { + t.Error("expected a DiffDelete line") + } + if !foundInsert { + t.Error("expected a DiffInsert line") + } +} + +func TestComputeDiff_MultiLineWithContext(t *testing.T) { + oldText := "line1\nline2\nline3\nline4\nline5\n" + newText := "line1\nLINE2\nline3\nline4\nline5\nextra\n" + + result := ComputeDiff(oldText, newText) + + if result.Stats.Equal != 4 { + t.Errorf("equal = %d, want 4", result.Stats.Equal) + } + // line2 -> LINE2 = 1 deletion + 1 addition; extra = 1 addition + if result.Stats.Additions != 2 { + t.Errorf("additions = %d, want 2", result.Stats.Additions) + } + if result.Stats.Deletions != 1 { + t.Errorf("deletions = %d, want 1", result.Stats.Deletions) + } +} + +func TestComputeDiff_PureAdditions(t *testing.T) { + oldText := "aaa\nccc\n" + newText := "aaa\nbbb\nccc\n" + result := ComputeDiff(oldText, newText) + + if result.Stats.Additions != 1 { + t.Errorf("additions = %d, want 1", result.Stats.Additions) + } + if result.Stats.Deletions != 0 { + t.Errorf("deletions = %d, want 0", result.Stats.Deletions) + } + if result.Stats.Equal != 2 { + t.Errorf("equal = %d, want 2", result.Stats.Equal) + } +} + +func TestComputeDiff_PureDeletions(t *testing.T) { + oldText := "aaa\nbbb\nccc\n" + newText := "aaa\nccc\n" + result := ComputeDiff(oldText, newText) + + if result.Stats.Deletions != 1 { + t.Errorf("deletions = %d, want 1", result.Stats.Deletions) + } + if result.Stats.Additions != 0 { + t.Errorf("additions = %d, want 0", result.Stats.Additions) + } +} + +func TestComputeDiff_TrailingNewline(t *testing.T) { + // With and without trailing newline should produce same diff for content + withNewline := ComputeDiff("a\nb\n", "a\nc\n") + withoutNewline := ComputeDiff("a\nb", "a\nc") + + if withNewline.Stats != withoutNewline.Stats { + t.Errorf("trailing newline should not affect diff stats: with=%+v without=%+v", + withNewline.Stats, withoutNewline.Stats) + } +} + +func TestComputeDiff_LineNumbersMonotonic(t *testing.T) { + oldText := "a\nb\nc\nd\n" + newText := "a\nX\nY\nc\nd\nZ\n" + result := ComputeDiff(oldText, newText) + + lastOld, lastNew := 0, 0 + for _, dl := range result.Lines { + if dl.OldLineNo > 0 { + if dl.OldLineNo < lastOld { + t.Errorf("OldLineNo went backwards: %d after %d", dl.OldLineNo, lastOld) + } + lastOld = dl.OldLineNo + } + if dl.NewLineNo > 0 { + if dl.NewLineNo < lastNew { + t.Errorf("NewLineNo went backwards: %d after %d", dl.NewLineNo, lastNew) + } + lastNew = dl.NewLineNo + } + } +} + +func TestSplitLines_Empty(t *testing.T) { + lines := splitLines("") + if lines != nil { + t.Errorf("expected nil for empty string, got %v", lines) + } +} + +func TestSplitLines_TrailingNewline(t *testing.T) { + lines := splitLines("a\nb\n") + if len(lines) != 2 { + t.Errorf("expected 2 lines, got %d: %v", len(lines), lines) + } +} + +func TestSplitLines_NoTrailingNewline(t *testing.T) { + lines := splitLines("a\nb") + if len(lines) != 2 { + t.Errorf("expected 2 lines, got %d: %v", len(lines), lines) + } +} + +func TestComputeWordSegments_PartialChange(t *testing.T) { + oldSegs, newSegs := computeWordSegments("hello world", "hello earth") + + if len(oldSegs) == 0 || len(newSegs) == 0 { + t.Fatal("expected non-empty segments") + } + + // Should have at least one unchanged segment ("hello ") and one changed segment + hasUnchanged := false + hasChanged := false + for _, s := range oldSegs { + if s.Changed { + hasChanged = true + } else { + hasUnchanged = true + } + } + if !hasUnchanged || !hasChanged { + t.Errorf("old segments should have both changed and unchanged parts, got %+v", oldSegs) + } +} + +func TestComputeWordSegments_IdenticalLines(t *testing.T) { + oldSegs, newSegs := computeWordSegments("same text", "same text") + + // All segments should be unchanged + for _, s := range oldSegs { + if s.Changed { + t.Errorf("old segment should not be changed: %+v", s) + } + } + for _, s := range newSegs { + if s.Changed { + t.Errorf("new segment should not be changed: %+v", s) + } + } +} diff --git a/cmd/mxcli/tui/diffrender_test.go b/cmd/mxcli/tui/diffrender_test.go new file mode 100644 index 0000000..dd0d2e7 --- /dev/null +++ b/cmd/mxcli/tui/diffrender_test.go @@ -0,0 +1,223 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderUnifiedDiff_NilResult(t *testing.T) { + lines := RenderUnifiedDiff(nil, "") + if lines != nil { + t.Errorf("expected nil for nil result, got %d lines", len(lines)) + } +} + +func TestRenderUnifiedDiff_EmptyResult(t *testing.T) { + result := &DiffResult{} + lines := RenderUnifiedDiff(result, "") + if lines != nil { + t.Errorf("expected nil for empty result, got %d lines", len(lines)) + } +} + +func TestRenderUnifiedDiff_CorrectLineCount(t *testing.T) { + result := ComputeDiff("aaa\nbbb\n", "aaa\nccc\n") + rendered := RenderUnifiedDiff(result, "") + + if len(rendered) != len(result.Lines) { + t.Errorf("rendered %d lines, want %d (one per diff line)", len(rendered), len(result.Lines)) + } +} + +func TestRenderUnifiedDiff_PrefixAndContent(t *testing.T) { + result := ComputeDiff("old\n", "new\n") + rendered := RenderUnifiedDiff(result, "") + + for i, rl := range rendered { + if rl.Prefix == "" { + t.Errorf("line %d: Prefix should not be empty", i) + } + // Content may contain ANSI codes but should not be empty for non-blank lines + } +} + +func TestRenderUnifiedDiff_EqualLinesHaveBothLineNumbers(t *testing.T) { + result := ComputeDiff("same\n", "same\n") + rendered := RenderUnifiedDiff(result, "") + + if len(rendered) != 1 { + t.Fatalf("expected 1 line, got %d", len(rendered)) + } + // Prefix should contain "1" twice (old and new line number) + prefix := rendered[0].Prefix + if strings.Count(stripANSI(prefix), "1") < 2 { + t.Errorf("equal line prefix should contain line number 1 twice, got prefix: %q", stripANSI(prefix)) + } +} + +func TestRenderSideBySideDiff_NilResult(t *testing.T) { + left, right := RenderSideBySideDiff(nil, "") + if left != nil || right != nil { + t.Error("expected nil for nil result") + } +} + +func TestRenderSideBySideDiff_EmptyResult(t *testing.T) { + result := &DiffResult{} + left, right := RenderSideBySideDiff(result, "") + if left != nil || right != nil { + t.Error("expected nil for empty result") + } +} + +func TestRenderSideBySideDiff_EqualLineCount(t *testing.T) { + result := ComputeDiff("aaa\nbbb\n", "aaa\nccc\n") + left, right := RenderSideBySideDiff(result, "") + + if len(left) != len(right) { + t.Errorf("left (%d) and right (%d) should have same number of lines", len(left), len(right)) + } +} + +func TestRenderSideBySideDiff_DeleteHasBlankOnRight(t *testing.T) { + result := ComputeDiff("aaa\nbbb\n", "aaa\n") + left, right := RenderSideBySideDiff(result, "") + _ = left + + // The delete line should produce a blank on the right + foundBlank := false + for _, rl := range right { + if rl.Blank { + foundBlank = true + break + } + } + if !foundBlank { + t.Error("expected a blank filler line on the right for a delete") + } +} + +func TestRenderSideBySideDiff_InsertHasBlankOnLeft(t *testing.T) { + result := ComputeDiff("aaa\n", "aaa\nbbb\n") + left, right := RenderSideBySideDiff(result, "") + + foundBlank := false + for _, rl := range left { + if rl.Blank { + foundBlank = true + break + } + } + if !foundBlank { + t.Error("expected a blank filler line on the left for an insert") + } + _ = right +} + +func TestRenderPlainUnifiedDiff_NilResult(t *testing.T) { + out := RenderPlainUnifiedDiff(nil, "old", "new") + if out != "" { + t.Errorf("expected empty string for nil result, got %q", out) + } +} + +func TestRenderPlainUnifiedDiff_EmptyResult(t *testing.T) { + result := &DiffResult{} + out := RenderPlainUnifiedDiff(result, "old", "new") + if out != "" { + t.Errorf("expected empty string for empty result, got %q", out) + } +} + +func TestRenderPlainUnifiedDiff_Headers(t *testing.T) { + result := ComputeDiff("old\n", "new\n") + out := RenderPlainUnifiedDiff(result, "file.txt", "file.txt") + + if !strings.HasPrefix(out, "--- a/file.txt\n+++ b/file.txt\n") { + t.Errorf("missing unified diff headers, got:\n%s", out) + } +} + +func TestRenderPlainUnifiedDiff_ContainsHunkHeader(t *testing.T) { + result := ComputeDiff("old\n", "new\n") + out := RenderPlainUnifiedDiff(result, "a", "b") + + if !strings.Contains(out, "@@") { + t.Errorf("expected @@ hunk header, got:\n%s", out) + } +} + +func TestRenderPlainUnifiedDiff_DeletesAndAdds(t *testing.T) { + result := ComputeDiff("alpha\nbeta\n", "alpha\ngamma\n") + out := RenderPlainUnifiedDiff(result, "a", "b") + + if !strings.Contains(out, "-beta") { + t.Errorf("expected -beta line, got:\n%s", out) + } + if !strings.Contains(out, "+gamma") { + t.Errorf("expected +gamma line, got:\n%s", out) + } + // Context line (unchanged) + if !strings.Contains(out, " alpha") { + t.Errorf("expected context line ' alpha', got:\n%s", out) + } +} + +func TestRenderPlainUnifiedDiff_IdenticalInputs(t *testing.T) { + text := "same\nlines\nhere\n" + result := ComputeDiff(text, text) + out := RenderPlainUnifiedDiff(result, "a", "b") + + // Should have headers and "no differences" marker + if !strings.Contains(out, "no differences") { + t.Errorf("identical inputs should produce 'no differences' marker, got:\n%s", out) + } +} + +func TestRenderPlainUnifiedDiff_NoANSI(t *testing.T) { + result := ComputeDiff("old\n", "new\n") + out := RenderPlainUnifiedDiff(result, "a", "b") + + if strings.Contains(out, "\x1b[") { + t.Error("plain diff should not contain ANSI escape sequences") + } +} + +func TestRenderSegments_EmptySegments(t *testing.T) { + out := renderSegments(nil, DiffInsert) + if out != "" { + t.Errorf("expected empty string for nil segments, got %q", out) + } +} + +func TestRenderSegments_EqualType(t *testing.T) { + segs := []DiffSegment{{Text: "hello", Changed: false}, {Text: " world", Changed: true}} + out := renderSegments(segs, DiffEqual) + + // For DiffEqual, renderSegments just concatenates text without styling + if out != "hello world" { + t.Errorf("expected plain concatenation for DiffEqual, got %q", out) + } +} + +func TestHslice_NoSkip(t *testing.T) { + out := hslice("hello", 0, 5) + if out != "hello" { + t.Errorf("expected 'hello', got %q", out) + } +} + +func TestHslice_Skip(t *testing.T) { + out := hslice("hello world", 6, 5) + if out != "world" { + t.Errorf("expected 'world', got %q", out) + } +} + +func TestHslice_TruncateTake(t *testing.T) { + out := hslice("hello world", 0, 5) + if out != "hello" { + t.Errorf("expected 'hello', got %q", out) + } +} + diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go index b01feca..8485772 100644 --- a/cmd/mxcli/tui/diffview.go +++ b/cmd/mxcli/tui/diffview.go @@ -61,6 +61,12 @@ type DiffView struct { searchQuery string matchLines []int matchIdx int + + // Two-key sequence state (e.g. ]c / [c for Vim hunk navigation) + pendingKey rune // ']' or '[' waiting for 'c', 0 if none + + // Mode-specific hunk starts + plainHunkStarts []int // hunk header indices in plainLines } // NewDiffView creates a DiffView from a DiffOpenMsg. @@ -99,9 +105,11 @@ func (dv *DiffView) renderAll() { func (dv *DiffView) computeHunkStarts() { dv.hunkStarts = nil + dv.plainHunkStarts = nil if dv.result == nil { return } + // Unified and Side-by-Side: 1:1 mapping with result.Lines for i, dl := range dv.result.Lines { if dl.Type == DiffEqual { continue @@ -110,6 +118,12 @@ func (dv *DiffView) computeHunkStarts() { dv.hunkStarts = append(dv.hunkStarts, i) } } + // Plain Diff: find @@ hunk header lines + for i, line := range dv.plainLines { + if strings.HasPrefix(line, "@@") { + dv.plainHunkStarts = append(dv.plainHunkStarts, i) + } + } } // SetSize updates dimensions. @@ -194,7 +208,24 @@ func (dv DiffView) updateSearch(msg tea.KeyMsg) (DiffView, tea.Cmd) { } func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { - switch msg.String() { + key := msg.String() + + // Handle two-key sequence: ]c / [c (Vim hunk navigation) + if dv.pendingKey != 0 { + pending := dv.pendingKey + dv.pendingKey = 0 + if key == "c" { + if pending == ']' { + dv.nextHunk() + } else { + dv.prevHunk() + } + return dv, nil + } + // Not 'c' — discard pending and fall through to handle this key normally + } + + switch key { case "q", "esc": return dv, func() tea.Msg { return PopViewMsg{} } @@ -256,11 +287,11 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { case "N": dv.prevMatch() - // Hunk navigation + // Hunk navigation: first key of ]c / [c sequence case "]": - dv.nextHunk() + dv.pendingKey = ']' case "[": - dv.prevHunk() + dv.pendingKey = '[' } return dv, nil @@ -284,30 +315,40 @@ func (dv *DiffView) scroll(delta int) { // --- Hunk navigation --- +// activeHunkStarts returns the hunk start indices appropriate for the current view mode. +func (dv *DiffView) activeHunkStarts() []int { + if dv.viewMode == DiffViewPlainDiff { + return dv.plainHunkStarts + } + return dv.hunkStarts +} + func (dv *DiffView) nextHunk() { - if len(dv.hunkStarts) == 0 { + starts := dv.activeHunkStarts() + if len(starts) == 0 { return } - for _, hs := range dv.hunkStarts { + for _, hs := range starts { if hs > dv.yOffset { dv.yOffset = clamp(hs, 0, dv.maxOffset()) return } } - dv.yOffset = clamp(dv.hunkStarts[0], 0, dv.maxOffset()) + dv.yOffset = clamp(starts[0], 0, dv.maxOffset()) } func (dv *DiffView) prevHunk() { - if len(dv.hunkStarts) == 0 { + starts := dv.activeHunkStarts() + if len(starts) == 0 { return } - for i := len(dv.hunkStarts) - 1; i >= 0; i-- { - if dv.hunkStarts[i] < dv.yOffset { - dv.yOffset = clamp(dv.hunkStarts[i], 0, dv.maxOffset()) + for i := len(starts) - 1; i >= 0; i-- { + if starts[i] < dv.yOffset { + dv.yOffset = clamp(starts[i], 0, dv.maxOffset()) return } } - dv.yOffset = clamp(dv.hunkStarts[len(dv.hunkStarts)-1], 0, dv.maxOffset()) + dv.yOffset = clamp(starts[len(starts)-1], 0, dv.maxOffset()) } // --- Search --- @@ -423,7 +464,7 @@ func (dv DiffView) View() string { hints = append(hints, keySt.Render("j/k")+" "+dimSt.Render("vert")) hints = append(hints, keySt.Render("h/l")+" "+dimSt.Render("horiz")) hints = append(hints, keySt.Render("Tab")+" "+dimSt.Render("mode")) - hints = append(hints, keySt.Render("]/[")+" "+dimSt.Render("hunk")) + hints = append(hints, keySt.Render("]c/[c")+" "+dimSt.Render("hunk")) hints = append(hints, keySt.Render("/")+" "+dimSt.Render("search")) if si := dv.searchInfo(); si != "" { hints = append(hints, keySt.Render("n/N")+" "+activeSt.Render(si)) @@ -617,21 +658,11 @@ func (dv DiffView) renderPlainDiff(viewH int) string { lineIdx := dv.yOffset + vi var line string if lineIdx < total { - line = lines[lineIdx] - // Apply horizontal scroll - if dv.xOffset > 0 && len(line) > dv.xOffset { - line = line[dv.xOffset:] - } else if dv.xOffset > 0 { - line = "" - } - // Truncate to width - if len(line) > contentW { - line = line[:contentW] - } + line = hslice(lines[lineIdx], dv.xOffset, contentW) } - // Pad to fill width - if pad := contentW - len(line); pad > 0 { + // Pad to fill width (use visual width for multi-byte safety) + if pad := contentW - lipgloss.Width(line); pad > 0 { line += strings.Repeat(" ", pad) } diff --git a/cmd/mxcli/tui/fuzzylist.go b/cmd/mxcli/tui/fuzzylist.go new file mode 100644 index 0000000..c418531 --- /dev/null +++ b/cmd/mxcli/tui/fuzzylist.go @@ -0,0 +1,109 @@ +package tui + +import "strings" + +// FuzzyList implements a reusable fuzzy-search list with cursor navigation. +// Used by CompareView's picker and JumperView. +type FuzzyList struct { + Items []PickerItem + Matches []pickerMatch + Cursor int + Offset int + MaxShow int +} + +// NewFuzzyList creates a FuzzyList with the given items and visible row limit. +func NewFuzzyList(items []PickerItem, maxShow int) FuzzyList { + fl := FuzzyList{Items: items, MaxShow: maxShow} + fl.Filter("") + return fl +} + +// Filter updates the match list based on the query string. +// Supports type-prefixed queries like "mf:query" where the prefix is +// fuzzy-matched against NodeType (e.g. "mf" matches "Microflow"). +func (fl *FuzzyList) Filter(query string) { + query = strings.TrimSpace(query) + fl.Matches = nil + + // Parse optional type prefix: "prefix:nameQuery" + typeFilter, nameQuery := "", query + if idx := strings.IndexByte(query, ':'); idx > 0 && idx < len(query)-1 { + typeFilter = query[:idx] + nameQuery = query[idx+1:] + } else if idx > 0 && idx == len(query)-1 { + // Trailing colon with no name query — filter by type only + typeFilter = query[:idx] + nameQuery = "" + } + + for _, it := range fl.Items { + if query == "" { + fl.Matches = append(fl.Matches, pickerMatch{item: it}) + continue + } + // Type filter: fuzzy match prefix against NodeType + if typeFilter != "" { + if ok, _ := fuzzyScore(it.NodeType, typeFilter); !ok { + continue + } + } + // Name query: fuzzy match against QName (or match all if empty) + if nameQuery == "" { + fl.Matches = append(fl.Matches, pickerMatch{item: it}) + } else if ok, sc := fuzzyScore(it.QName, nameQuery); ok { + fl.Matches = append(fl.Matches, pickerMatch{item: it, score: sc}) + } + } + // Sort by score descending (insertion sort, small n) + for i := 1; i < len(fl.Matches); i++ { + for j := i; j > 0 && fl.Matches[j].score > fl.Matches[j-1].score; j-- { + fl.Matches[j], fl.Matches[j-1] = fl.Matches[j-1], fl.Matches[j] + } + } + if fl.Cursor >= len(fl.Matches) { + fl.Cursor = max(0, len(fl.Matches)-1) + } + fl.Offset = 0 +} + +// MoveDown advances the cursor, wrapping to the top. +func (fl *FuzzyList) MoveDown() { + if len(fl.Matches) == 0 { + return + } + fl.Cursor++ + if fl.Cursor >= len(fl.Matches) { + fl.Cursor = 0 + fl.Offset = 0 + } else if fl.Cursor >= fl.Offset+fl.MaxShow { + fl.Offset = fl.Cursor - fl.MaxShow + 1 + } +} + +// MoveUp moves the cursor up, wrapping to the bottom. +func (fl *FuzzyList) MoveUp() { + if len(fl.Matches) == 0 { + return + } + fl.Cursor-- + if fl.Cursor < 0 { + fl.Cursor = len(fl.Matches) - 1 + fl.Offset = max(0, fl.Cursor-fl.MaxShow+1) + } else if fl.Cursor < fl.Offset { + fl.Offset = fl.Cursor + } +} + +// Selected returns the currently highlighted item, or an empty PickerItem. +func (fl FuzzyList) Selected() PickerItem { + if len(fl.Matches) == 0 || fl.Cursor >= len(fl.Matches) { + return PickerItem{} + } + return fl.Matches[fl.Cursor].item +} + +// VisibleEnd returns the index just past the last visible match. +func (fl FuzzyList) VisibleEnd() int { + return min(fl.Offset+fl.MaxShow, len(fl.Matches)) +} diff --git a/cmd/mxcli/tui/fuzzylist_test.go b/cmd/mxcli/tui/fuzzylist_test.go new file mode 100644 index 0000000..b79478d --- /dev/null +++ b/cmd/mxcli/tui/fuzzylist_test.go @@ -0,0 +1,52 @@ +package tui + +import "testing" + +func TestFuzzyListFilterTypePrefix(t *testing.T) { + items := []PickerItem{ + {QName: "MyModule.ProcessOrder", NodeType: "Microflow"}, + {QName: "MyModule.OnClick", NodeType: "Nanoflow"}, + {QName: "MyModule.ApprovalFlow", NodeType: "Workflow"}, + {QName: "MyModule.OrderPage", NodeType: "Page"}, + {QName: "MyModule.Customer", NodeType: "Entity"}, + } + fl := NewFuzzyList(items, 10) + + tests := []struct { + query string + expectedCount int + expectType string // if set, all matches must have this NodeType + }{ + {"", 5, ""}, + {"mf:", 1, "Microflow"}, + {"nf:", 1, "Nanoflow"}, + {"wf:", 1, "Workflow"}, + {"pg:", 1, "Page"}, + {"en:", 1, "Entity"}, + {"mf:process", 1, "Microflow"}, + {"mf:nonexistent", 0, ""}, + {"microflow:", 1, "Microflow"}, + // No colon: plain fuzzy match across all types + {"order", 2, ""}, // ProcessOrder + OrderPage + } + + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + fl.Filter(tt.query) + if len(fl.Matches) != tt.expectedCount { + names := make([]string, len(fl.Matches)) + for i, m := range fl.Matches { + names[i] = m.item.QName + } + t.Errorf("Filter(%q): got %d matches %v, want %d", tt.query, len(fl.Matches), names, tt.expectedCount) + } + if tt.expectType != "" { + for _, m := range fl.Matches { + if m.item.NodeType != tt.expectType { + t.Errorf("Filter(%q): got NodeType %q, want %q", tt.query, m.item.NodeType, tt.expectType) + } + } + } + }) + } +} diff --git a/cmd/mxcli/tui/jumper.go b/cmd/mxcli/tui/jumper.go index 5541fb0..d7a6b90 100644 --- a/cmd/mxcli/tui/jumper.go +++ b/cmd/mxcli/tui/jumper.go @@ -19,90 +19,29 @@ type JumpToNodeMsg struct { // JumperView is a fuzzy-search modal for jumping to any node in the project. type JumperView struct { - input textinput.Model - items []PickerItem - matches []pickerMatch - cursor int - offset int - width int - height int + input textinput.Model + list FuzzyList + width int + height int } // NewJumperView creates a JumperView populated with the given items. func NewJumperView(items []PickerItem, width, height int) JumperView { ti := textinput.New() ti.Prompt = "❯ " - ti.Placeholder = "jump to..." + ti.Placeholder = "jump to... (mf: nf: wf: pg: en:)" ti.CharLimit = 200 ti.Focus() jv := JumperView{ input: ti, - items: items, + list: NewFuzzyList(items, jumperMaxShow), width: width, height: height, } - jv.filterItems() return jv } -func (jv *JumperView) filterItems() { - query := strings.TrimSpace(jv.input.Value()) - jv.matches = nil - for _, it := range jv.items { - if query == "" { - jv.matches = append(jv.matches, pickerMatch{item: it}) - continue - } - if ok, sc := fuzzyScore(it.QName, query); ok { - jv.matches = append(jv.matches, pickerMatch{item: it, score: sc}) - } - } - // Sort by score descending (insertion sort, small n) - for i := 1; i < len(jv.matches); i++ { - for j := i; j > 0 && jv.matches[j].score > jv.matches[j-1].score; j-- { - jv.matches[j], jv.matches[j-1] = jv.matches[j-1], jv.matches[j] - } - } - if jv.cursor >= len(jv.matches) { - jv.cursor = max(0, len(jv.matches)-1) - } - jv.offset = 0 -} - -func (jv *JumperView) moveDown() { - if len(jv.matches) == 0 { - return - } - jv.cursor++ - if jv.cursor >= len(jv.matches) { - jv.cursor = 0 - jv.offset = 0 - } else if jv.cursor >= jv.offset+jumperMaxShow { - jv.offset = jv.cursor - jumperMaxShow + 1 - } -} - -func (jv *JumperView) moveUp() { - if len(jv.matches) == 0 { - return - } - jv.cursor-- - if jv.cursor < 0 { - jv.cursor = len(jv.matches) - 1 - jv.offset = max(0, jv.cursor-jumperMaxShow+1) - } else if jv.cursor < jv.offset { - jv.offset = jv.cursor - } -} - -func (jv JumperView) selected() PickerItem { - if len(jv.matches) == 0 || jv.cursor >= len(jv.matches) { - return PickerItem{} - } - return jv.matches[jv.cursor].item -} - // --- View interface --- // Update handles key messages for the jumper modal. @@ -113,7 +52,7 @@ func (jv JumperView) Update(msg tea.Msg) (View, tea.Cmd) { case "esc": return jv, func() tea.Msg { return PopViewMsg{} } case "enter": - sel := jv.selected() + sel := jv.list.Selected() if sel.QName != "" { qname := sel.QName nodeType := sel.NodeType @@ -121,13 +60,13 @@ func (jv JumperView) Update(msg tea.Msg) (View, tea.Cmd) { } return jv, func() tea.Msg { return PopViewMsg{} } case "up", "ctrl+p": - jv.moveUp() + jv.list.MoveUp() case "down", "ctrl+n": - jv.moveDown() + jv.list.MoveDown() default: var cmd tea.Cmd jv.input, cmd = jv.input.Update(msg) - jv.filterItems() + jv.list.Filter(jv.input.Value()) return jv, cmd } @@ -147,34 +86,36 @@ func (jv JumperView) Render(width, height int) string { boxWidth := max(30, min(60, width-10)) + fl := &jv.list + // LLM anchor embedded at the top of the box content query := strings.TrimSpace(jv.input.Value()) - anchor := fmt.Sprintf("[mxcli:jump] > %s %d matches", query, len(jv.matches)) + anchor := fmt.Sprintf("[mxcli:jump] > %s %d matches", query, len(fl.Matches)) anchorStr := lipgloss.NewStyle().Foreground(MutedColor).Faint(true).Render(anchor) var sb strings.Builder sb.WriteString(anchorStr + "\n") sb.WriteString(jv.input.View() + "\n\n") - end := min(jv.offset+jumperMaxShow, len(jv.matches)) - if jv.offset > 0 { + end := fl.VisibleEnd() + if fl.Offset > 0 { sb.WriteString(dimSt.Render(" ↑ more") + "\n") } - for i := jv.offset; i < end; i++ { - it := jv.matches[i].item + for i := fl.Offset; i < end; i++ { + it := fl.Matches[i].item icon := IconFor(it.NodeType) label := icon + " " + it.QName typeLabel := it.NodeType - if i == jv.cursor { + if i == fl.Cursor { sb.WriteString(selSt.Render(" "+label) + " " + typeSt.Render(typeLabel) + "\n") } else { sb.WriteString(normSt.Render(" "+label) + " " + dimSt.Render(typeLabel) + "\n") } } - if end < len(jv.matches) { + if end < len(fl.Matches) { sb.WriteString(dimSt.Render(" ↓ more") + "\n") } - sb.WriteString("\n" + dimSt.Render(fmt.Sprintf(" %d/%d", len(jv.matches), len(jv.items)))) + sb.WriteString("\n" + dimSt.Render(fmt.Sprintf(" %d/%d", len(fl.Matches), len(fl.Items)))) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). @@ -202,7 +143,7 @@ func (jv JumperView) Hints() []Hint { func (jv JumperView) StatusInfo() StatusInfo { return StatusInfo{ Mode: "Jump", - Position: fmt.Sprintf("%d/%d", len(jv.matches), len(jv.items)), + Position: fmt.Sprintf("%d/%d", len(jv.list.Matches), len(jv.list.Items)), } } From 513212f1014e97273e8bef8597c969be9370dbce Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 11:56:37 +0800 Subject: [PATCH 05/10] refactor(tui): apply code review fixes to diff view - Remove dead leftOffset/rightOffset/syncScroll/focus fields from DiffView - Replace time.Sleep with tea.Tick in CompareView flash clear - Consolidate duplicate stripANSI/stripAnsi into single stripAnsi (clipboard.go) - Add OSC sequence limitation comment to hslice - Add y:yank hint to DiffViewHints - Extract scrollbarGeometry/scrollbarChar helpers, remove 3x duplicated scrollbar code - Move diff color palette from diffrender.go to theme.go as AdaptiveColor pairs - Run go mod tidy (promote sergi/go-diff to direct dependency) --- cmd/mxcli/tui/compare.go | 5 +- cmd/mxcli/tui/contentview.go | 27 +----- cmd/mxcli/tui/diffrender.go | 44 ++++----- cmd/mxcli/tui/diffrender_test.go | 4 +- cmd/mxcli/tui/diffview.go | 153 +++++++++---------------------- cmd/mxcli/tui/hintbar.go | 1 + cmd/mxcli/tui/miller.go | 2 +- cmd/mxcli/tui/theme.go | 16 ++++ go.mod | 7 +- go.sum | 13 ++- 10 files changed, 90 insertions(+), 182 deletions(-) diff --git a/cmd/mxcli/tui/compare.go b/cmd/mxcli/tui/compare.go index 1a56a83..6c56261 100644 --- a/cmd/mxcli/tui/compare.go +++ b/cmd/mxcli/tui/compare.go @@ -306,10 +306,7 @@ func (c CompareView) updateNormal(msg tea.KeyMsg) (CompareView, tea.Cmd) { case "y": _ = writeClipboard(c.focusedPane().content.PlainText()) c.copiedFlash = true - return c, func() tea.Msg { - time.Sleep(time.Second) - return compareFlashClearMsg{} - } + return c, tea.Tick(time.Second, func(_ time.Time) tea.Msg { return compareFlashClearMsg{} }) // Scroll — forward j/k/arrows/pgup/pgdn/g/G to focused viewport default: diff --git a/cmd/mxcli/tui/contentview.go b/cmd/mxcli/tui/contentview.go index 8358dad..63743fc 100644 --- a/cmd/mxcli/tui/contentview.go +++ b/cmd/mxcli/tui/contentview.go @@ -53,7 +53,7 @@ func (v ContentView) PlainText() string { if i > 0 { sb.WriteByte('\n') } - sb.WriteString(stripANSI(line)) + sb.WriteString(stripAnsi(line)) } return sb.String() } @@ -123,7 +123,7 @@ func (v *ContentView) buildMatchLines() { q := strings.ToLower(v.searchQuery) for i, line := range v.lines { // Strip ANSI for matching - if strings.Contains(strings.ToLower(stripANSI(line)), q) { + if strings.Contains(strings.ToLower(stripAnsi(line)), q) { v.matchLines = append(v.matchLines, i) } } @@ -157,27 +157,6 @@ func (v *ContentView) scrollToMatch() { v.yOffset = clamp(target-v.height/2, 0, v.maxOffset()) } -// stripANSI removes ANSI escape sequences from a string for plain-text matching. -func stripANSI(s string) string { - var sb strings.Builder - sb.Grow(len(s)) - inEsc := false - for i := 0; i < len(s); i++ { - if s[i] == '\x1b' { - inEsc = true - continue - } - if inEsc { - if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') { - inEsc = false - } - continue - } - sb.WriteByte(s[i]) - } - return sb.String() -} - // --- Update --- func (v ContentView) Update(msg tea.Msg) (ContentView, tea.Cmd) { @@ -379,7 +358,7 @@ func (v ContentView) View() string { // highlightMatches highlights all occurrences of query in the line (case-insensitive). // Works with ANSI-colored text by matching on stripped text and applying style around matches. func highlightMatches(line, query string, style lipgloss.Style) string { - plain := stripANSI(line) + plain := stripAnsi(line) lowerPlain := strings.ToLower(plain) lowerQuery := strings.ToLower(query) diff --git a/cmd/mxcli/tui/diffrender.go b/cmd/mxcli/tui/diffrender.go index e6c157e..c2e9d9b 100644 --- a/cmd/mxcli/tui/diffrender.go +++ b/cmd/mxcli/tui/diffrender.go @@ -7,20 +7,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Diff color palette. -var ( - diffAddedFg = lipgloss.Color("#00D787") - diffAddedChangedFg = lipgloss.Color("#FFFFFF") - diffAddedChangedBg = lipgloss.Color("#005F00") - - diffRemovedFg = lipgloss.Color("#FF5F87") - diffRemovedChangedFg = lipgloss.Color("#FFFFFF") - diffRemovedChangedBg = lipgloss.Color("#5F0000") - - diffEqualGutter = lipgloss.Color("#626262") - diffGutterAddedFg = lipgloss.Color("#00D787") - diffGutterRemovedFg = lipgloss.Color("#FF5F87") -) // RenderPlainUnifiedDiff generates a standard unified diff string (no ANSI colors). // This format is directly understood by LLMs and tools like patch/git. @@ -158,20 +144,20 @@ func RenderUnifiedDiff(result *DiffResult, lang string) []DiffRenderedLine { switch dl.Type { case DiffEqual: - gutter = gutterCharSt.Foreground(diffEqualGutter).Render("│") + gutter = gutterCharSt.Foreground(DiffEqualGutter).Render("│") oldNo = lineNoSt.Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) newNo = lineNoSt.Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) content = highlightLine(dl.Content, lang) case DiffInsert: - gutter = gutterCharSt.Foreground(diffGutterAddedFg).Render("+") + gutter = gutterCharSt.Foreground(DiffGutterAddedFg).Render("+") oldNo = lineNoSt.Render(strings.Repeat(" ", lineNoW)) - newNo = lipgloss.NewStyle().Foreground(diffGutterAddedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + newNo = lipgloss.NewStyle().Foreground(DiffGutterAddedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) content = renderSegments(dl.Segments, DiffInsert) case DiffDelete: - gutter = gutterCharSt.Foreground(diffGutterRemovedFg).Render("-") - oldNo = lipgloss.NewStyle().Foreground(diffGutterRemovedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + gutter = gutterCharSt.Foreground(DiffGutterRemovedFg).Render("-") + oldNo = lipgloss.NewStyle().Foreground(DiffGutterRemovedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) newNo = lineNoSt.Render(strings.Repeat(" ", lineNoW)) content = renderSegments(dl.Segments, DiffDelete) } @@ -220,13 +206,13 @@ func RenderSideBySideDiff(result *DiffResult, lang string) (left, right []SideBy case DiffDelete: content := renderSegments(dl.Segments, DiffDelete) - oldNo := lipgloss.NewStyle().Foreground(diffGutterRemovedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + " " + oldNo := lipgloss.NewStyle().Foreground(DiffGutterRemovedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.OldLineNo)) + " " left = append(left, SideBySideRenderedLine{Prefix: oldNo, Content: content}) right = append(right, SideBySideRenderedLine{Prefix: blankPrefix, Blank: true}) case DiffInsert: content := renderSegments(dl.Segments, DiffInsert) - newNo := lipgloss.NewStyle().Foreground(diffGutterAddedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + " " + newNo := lipgloss.NewStyle().Foreground(DiffGutterAddedFg).Render(fmt.Sprintf("%*d", lineNoW, dl.NewLineNo)) + " " left = append(left, SideBySideRenderedLine{Prefix: blankPrefix, Blank: true}) right = append(right, SideBySideRenderedLine{Prefix: newNo, Content: content}) } @@ -240,16 +226,16 @@ func renderSegments(segments []DiffSegment, lineType DiffLineType) string { return "" } - var normalFg, changedFg, changedBg lipgloss.Color + var normalFg, changedFg, changedBg lipgloss.TerminalColor switch lineType { case DiffInsert: - normalFg = diffAddedFg - changedFg = diffAddedChangedFg - changedBg = diffAddedChangedBg + normalFg = DiffAddedFg + changedFg = DiffAddedChangedFg + changedBg = DiffAddedChangedBg case DiffDelete: - normalFg = diffRemovedFg - changedFg = diffRemovedChangedFg - changedBg = diffRemovedChangedBg + normalFg = DiffRemovedFg + changedFg = DiffRemovedChangedFg + changedBg = DiffRemovedChangedBg default: var sb strings.Builder for _, seg := range segments { @@ -288,6 +274,8 @@ func highlightLine(content, lang string) string { // hslice returns a horizontal slice of an ANSI-colored string, // skipping the first `skip` visual columns and returning up to `take` visual columns. +// Only CSI sequences (ESC [ ... letter) are handled; OSC/DCS/hyperlink escapes are not +// parsed. This is safe for the diff renderer which only emits lipgloss SGR sequences. func hslice(s string, skip, take int) string { if skip == 0 { return truncateToWidth(s, take) diff --git a/cmd/mxcli/tui/diffrender_test.go b/cmd/mxcli/tui/diffrender_test.go index dd0d2e7..6ae669b 100644 --- a/cmd/mxcli/tui/diffrender_test.go +++ b/cmd/mxcli/tui/diffrender_test.go @@ -50,8 +50,8 @@ func TestRenderUnifiedDiff_EqualLinesHaveBothLineNumbers(t *testing.T) { } // Prefix should contain "1" twice (old and new line number) prefix := rendered[0].Prefix - if strings.Count(stripANSI(prefix), "1") < 2 { - t.Errorf("equal line prefix should contain line number 1 twice, got prefix: %q", stripANSI(prefix)) + if strings.Count(stripAnsi(prefix), "1") < 2 { + t.Errorf("equal line prefix should contain line number 1 twice, got prefix: %q", stripAnsi(prefix)) } } diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go index 8485772..1be5636 100644 --- a/cmd/mxcli/tui/diffview.go +++ b/cmd/mxcli/tui/diffview.go @@ -49,12 +49,6 @@ type DiffView struct { width int height int - // Side-by-side state - syncScroll bool - focus int // 0=left, 1=right - leftOffset int - rightOffset int - // Search searching bool searchInput textinput.Model @@ -82,7 +76,6 @@ func NewDiffView(msg DiffOpenMsg, width, height int) DiffView { title: msg.Title, width: width, height: height, - syncScroll: true, searchInput: ti, } @@ -244,12 +237,8 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { dv.scroll(-dv.contentHeight()) case "g", "home": dv.yOffset = 0 - dv.leftOffset = 0 - dv.rightOffset = 0 case "G", "end": dv.yOffset = dv.maxOffset() - dv.leftOffset = dv.maxOffset() - dv.rightOffset = dv.maxOffset() // Horizontal scroll case "h", "left": @@ -269,8 +258,6 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { } dv.yOffset = 0 dv.xOffset = 0 - dv.leftOffset = 0 - dv.rightOffset = 0 // Yank unified diff to clipboard case "y": @@ -298,19 +285,7 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { } func (dv *DiffView) scroll(delta int) { - if dv.viewMode == DiffViewSideBySide && !dv.syncScroll { - if dv.focus == 0 { - dv.leftOffset = clamp(dv.leftOffset+delta, 0, dv.maxOffset()) - } else { - dv.rightOffset = clamp(dv.rightOffset+delta, 0, dv.maxOffset()) - } - } else { - dv.yOffset = clamp(dv.yOffset+delta, 0, dv.maxOffset()) - if dv.syncScroll { - dv.leftOffset = dv.yOffset - dv.rightOffset = dv.yOffset - } - } + dv.yOffset = clamp(dv.yOffset+delta, 0, dv.maxOffset()) } // --- Hunk navigation --- @@ -421,8 +396,8 @@ func (dv DiffView) View() string { dimSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) keySt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true) activeSt := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) - addSt := lipgloss.NewStyle().Foreground(diffAddedFg).Bold(true) - delSt := lipgloss.NewStyle().Foreground(diffRemovedFg).Bold(true) + addSt := lipgloss.NewStyle().Foreground(DiffAddedFg).Bold(true) + delSt := lipgloss.NewStyle().Foreground(DiffRemovedFg).Bold(true) // Title bar var modeLabel string @@ -494,27 +469,9 @@ func (dv DiffView) View() string { func (dv DiffView) renderUnified(viewH int) string { lines := dv.unified total := len(lines) - showScrollbar := total > viewH - - trackSt := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - thumbSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) - - var thumbStart, thumbEnd int - if showScrollbar { - thumbSize := max(1, viewH*viewH/total) - if m := dv.maxOffset(); m > 0 { - thumbStart = dv.yOffset * (viewH - thumbSize) / m - } - thumbEnd = thumbStart + thumbSize - } - - scrollW := 0 - if showScrollbar { - scrollW = 1 - } + thumbStart, thumbEnd, scrollW := scrollbarGeometry(total, viewH, dv.yOffset) - // Calculate content width after prefix - // Prefix is fixed, content gets the remaining width + // Prefix is fixed (sticky line numbers); content gets remaining width. prefixW := 0 if len(lines) > 0 { prefixW = lipgloss.Width(lines[0].Prefix) @@ -527,9 +484,7 @@ func (dv DiffView) renderUnified(viewH int) string { var line string if lineIdx < total { rl := lines[lineIdx] - // Prefix is sticky (always visible) content := hslice(rl.Content, dv.xOffset, contentW) - // Pad content to fill width if pad := contentW - lipgloss.Width(content); pad > 0 { content += strings.Repeat(" ", pad) } @@ -537,15 +492,9 @@ func (dv DiffView) renderUnified(viewH int) string { } else { line = strings.Repeat(" ", dv.width-scrollW) } - - if showScrollbar { - if vi >= thumbStart && vi < thumbEnd { - line += thumbSt.Render("█") - } else { - line += trackSt.Render("│") - } + if scrollW > 0 { + line += scrollbarChar(vi, thumbStart, thumbEnd) } - sb.WriteString(line) if vi < viewH-1 { sb.WriteString("\n") @@ -557,28 +506,11 @@ func (dv DiffView) renderUnified(viewH int) string { func (dv DiffView) renderSideBySide(viewH int) string { dividerSt := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) total := len(dv.sideLeft) - showScrollbar := total > viewH - - trackSt := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - thumbSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + thumbStart, thumbEnd, scrollW := scrollbarGeometry(total, viewH, dv.yOffset) - var thumbStart, thumbEnd int - if showScrollbar { - thumbSize := max(1, viewH*viewH/total) - if m := dv.maxOffset(); m > 0 { - thumbStart = dv.yOffset * (viewH - thumbSize) / m - } - thumbEnd = thumbStart + thumbSize - } - - scrollW := 0 - if showScrollbar { - scrollW = 1 - } - dividerW := 3 // " │ " + const dividerW = 3 // " │ " paneTotal := (dv.width - dividerW - scrollW) / 2 - // Calculate prefix width from rendered data prefixW := 0 if len(dv.sideLeft) > 0 { prefixW = lipgloss.Width(dv.sideLeft[0].Prefix) @@ -613,15 +545,9 @@ func (dv DiffView) renderSideBySide(viewH int) string { } line := leftStr + dividerSt.Render(" │ ") + rightStr - - if showScrollbar { - if vi >= thumbStart && vi < thumbEnd { - line += thumbSt.Render("█") - } else { - line += trackSt.Render("│") - } + if scrollW > 0 { + line += scrollbarChar(vi, thumbStart, thumbEnd) } - sb.WriteString(line) if vi < viewH-1 { sb.WriteString("\n") @@ -633,24 +559,7 @@ func (dv DiffView) renderSideBySide(viewH int) string { func (dv DiffView) renderPlainDiff(viewH int) string { lines := dv.plainLines total := len(lines) - showScrollbar := total > viewH - - trackSt := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - thumbSt := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) - - var thumbStart, thumbEnd int - if showScrollbar { - thumbSize := max(1, viewH*viewH/total) - if m := dv.maxOffset(); m > 0 { - thumbStart = dv.yOffset * (viewH - thumbSize) / m - } - thumbEnd = thumbStart + thumbSize - } - - scrollW := 0 - if showScrollbar { - scrollW = 1 - } + thumbStart, thumbEnd, scrollW := scrollbarGeometry(total, viewH, dv.yOffset) contentW := dv.width - scrollW var sb strings.Builder @@ -660,20 +569,13 @@ func (dv DiffView) renderPlainDiff(viewH int) string { if lineIdx < total { line = hslice(lines[lineIdx], dv.xOffset, contentW) } - // Pad to fill width (use visual width for multi-byte safety) if pad := contentW - lipgloss.Width(line); pad > 0 { line += strings.Repeat(" ", pad) } - - if showScrollbar { - if vi >= thumbStart && vi < thumbEnd { - line += thumbSt.Render("█") - } else { - line += trackSt.Render("│") - } + if scrollW > 0 { + line += scrollbarChar(vi, thumbStart, thumbEnd) } - sb.WriteString(line) if vi < viewH-1 { sb.WriteString("\n") @@ -682,6 +584,33 @@ func (dv DiffView) renderPlainDiff(viewH int) string { return sb.String() } +// scrollbarGeometry computes thumb position for a scrollbar given content/viewport metrics. +// Returns (thumbStart, thumbEnd, scrollW); scrollW is 0 when no scrollbar is needed. +func scrollbarGeometry(total, viewH, yOffset int) (thumbStart, thumbEnd, scrollW int) { + if total <= viewH { + return 0, 0, 0 + } + maxOff := total - viewH + thumbSize := max(1, viewH*viewH/total) + if maxOff > 0 { + thumbStart = yOffset * (viewH - thumbSize) / maxOff + } + return thumbStart, thumbStart + thumbSize, 1 +} + +var ( + scrollTrackSt = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + scrollThumbSt = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) +) + +// scrollbarChar returns the rendered scrollbar character for row vi. +func scrollbarChar(vi, thumbStart, thumbEnd int) string { + if vi >= thumbStart && vi < thumbEnd { + return scrollThumbSt.Render("█") + } + return scrollTrackSt.Render("│") +} + func (dv DiffView) scrollPercent() int { m := dv.maxOffset() if m <= 0 { diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index a03f5d1..ac940ca 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -68,6 +68,7 @@ var ( {Key: "Tab", Label: "mode"}, {Key: "]c/[c", Label: "hunk"}, {Key: "/", Label: "search"}, + {Key: "y", Label: "yank"}, {Key: "q", Label: "close"}, } ) diff --git a/cmd/mxcli/tui/miller.go b/cmd/mxcli/tui/miller.go index e9b2e8a..bacb358 100644 --- a/cmd/mxcli/tui/miller.go +++ b/cmd/mxcli/tui/miller.go @@ -885,7 +885,7 @@ func findImagePathAtClick(contentLines, imagePaths []string, clickedVLine, scrol if srcIdx < 0 || srcIdx >= len(contentLines) { continue } - plain := stripANSI(contentLines[srcIdx]) + plain := stripAnsi(contentLines[srcIdx]) i := strings.Index(plain, "FROM FILE '") if i == -1 { continue diff --git a/cmd/mxcli/tui/theme.go b/cmd/mxcli/tui/theme.go index 931f560..b5c59f0 100644 --- a/cmd/mxcli/tui/theme.go +++ b/cmd/mxcli/tui/theme.go @@ -11,6 +11,22 @@ var ( RemovedColor = lipgloss.AdaptiveColor{Light: "124", Dark: "210"} ) +// Diff view color palette — centralized so the entire diff color scheme can be +// adjusted in one place. AdaptiveColor picks Light/Dark based on terminal background. +var ( + DiffAddedFg = lipgloss.AdaptiveColor{Light: "#00875f", Dark: "#00D787"} + DiffAddedChangedFg = lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#ffffff"} + DiffAddedChangedBg = lipgloss.AdaptiveColor{Light: "#005F00", Dark: "#005F00"} + + DiffRemovedFg = lipgloss.AdaptiveColor{Light: "#AF005F", Dark: "#FF5F87"} + DiffRemovedChangedFg = lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#ffffff"} + DiffRemovedChangedBg = lipgloss.AdaptiveColor{Light: "#5F0000", Dark: "#5F0000"} + + DiffEqualGutter = lipgloss.AdaptiveColor{Light: "241", Dark: "241"} + DiffGutterAddedFg = lipgloss.AdaptiveColor{Light: "#00875f", Dark: "#00D787"} + DiffGutterRemovedFg = lipgloss.AdaptiveColor{Light: "#AF005F", Dark: "#FF5F87"} +) + var ( // Column separator: dim vertical bar between panels. SeparatorChar = "│" diff --git a/go.mod b/go.mod index 136a549..b626610 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,17 @@ module github.com/mendixlabs/mxcli go 1.26.0 require ( + github.com/alecthomas/chroma/v2 v2.23.1 github.com/antlr4-go/antlr/v4 v4.13.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/chzyer/readline v1.5.1 github.com/jackc/pgx/v5 v5.8.0 + github.com/mattn/go-runewidth v0.0.19 github.com/microsoft/go-mssqldb v1.9.8 github.com/pmezard/go-difflib v1.0.0 + github.com/sergi/go-diff v1.4.0 github.com/sijms/go-ora/v2 v2.9.0 github.com/spf13/cobra v1.8.0 go.lsp.dev/jsonrpc2 v0.10.0 @@ -24,7 +27,6 @@ require ( ) require ( - github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect @@ -48,7 +50,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -56,10 +57,8 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.6.1 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.3.4 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index c5fb96a..a9befa8 100644 --- a/go.sum +++ b/go.sum @@ -10,16 +10,18 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -34,8 +36,6 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= @@ -74,6 +74,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -124,8 +126,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+Pauc= @@ -226,7 +226,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= From e1c84c234bfb39d2739775f5854c1be1070312d5 Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 18:30:50 +0800 Subject: [PATCH 06/10] feat(tui): add MDL execution view with inline file picker Add ExecView (key 'x') for executing MDL scripts from within the TUI: - Textarea for pasting/typing MDL with Ctrl+E to execute - Inline file picker (Ctrl+O) with path completion filtering .mdl files - Results displayed in overlay, project tree auto-refreshes on success Closes #30 --- cmd/mxcli/tui/app.go | 18 + cmd/mxcli/tui/execview.go | 485 ++++++++++++++++++++++ cmd/mxcli/tui/execview_test.go | 18 + cmd/mxcli/tui/help.go | 1 + cmd/mxcli/tui/hintbar.go | 6 + cmd/mxcli/tui/view.go | 3 + docs/plans/2026-03-25-tui-exec-mdl.md | 557 ++++++++++++++++++++++++++ 7 files changed, 1088 insertions(+) create mode 100644 cmd/mxcli/tui/execview.go create mode 100644 cmd/mxcli/tui/execview_test.go create mode 100644 docs/plans/2026-03-25-tui-exec-mdl.md diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 874ed63..50e7c00 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -265,6 +265,19 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.views.Push(ov) return a, nil + case execShowResultMsg: + // Pop the ExecView + a.views.Pop() + // Show result in overlay + content := DetectAndHighlight(msg.Content) + ov := NewOverlayView("Exec Result", content, a.width, a.height, OverlayViewOpts{}) + a.views.Push(ov) + // If execution succeeded, refresh tree + if msg.Success { + return a, a.Init() + } + return a, nil + case tea.KeyMsg: Trace("app: key=%q picker=%v mode=%v help=%v", msg.String(), a.picker != nil, a.views.Active().Mode(), a.showHelp) if msg.String() == "ctrl+c" { @@ -508,6 +521,11 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { } return func() tea.Msg { return nil } + case "x": + ev := NewExecView(a.mxcliPath, a.activeTabProjectPath(), a.width, a.height) + a.views.Push(ev) + return func() tea.Msg { return nil } + case "c": cv := NewCompareView() cv.mxcliPath = a.mxcliPath diff --git a/cmd/mxcli/tui/execview.go b/cmd/mxcli/tui/execview.go new file mode 100644 index 0000000..204551c --- /dev/null +++ b/cmd/mxcli/tui/execview.go @@ -0,0 +1,485 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ExecDoneMsg carries the result of MDL execution. +type ExecDoneMsg struct { + Output string + Err error +} + +// execShowResultMsg signals App to show exec result and optionally refresh tree. +type execShowResultMsg struct { + Content string + Success bool +} + +// execFileLoadedMsg carries the content of a loaded MDL file. +type execFileLoadedMsg struct { + Path string + Content string + Err error +} + +const execPickerMaxVisible = 10 + +// ExecView provides a textarea for entering/pasting MDL scripts and executing them. +// It has two modes: editor mode (textarea) and file picker mode (path input with completion). +type ExecView struct { + textarea textarea.Model + mxcliPath string + projectPath string + width int + height int + executing bool + flash string + loadedPath string // path of the currently loaded file (for status display) + + // File picker state (inline, not a separate View) + picking bool + pathInput textinput.Model + pathCandidates []mdlCandidate + pathCursor int + pathScroll int +} + +// mdlCandidate is a filesystem entry shown in the MDL file picker. +type mdlCandidate struct { + fullPath string + name string + isDir bool + isMDL bool +} + +func (c mdlCandidate) icon() string { + if c.isMDL { + return "📄" + } + if c.isDir { + return "📁" + } + return "·" +} + +// NewExecView creates an ExecView with a textarea for MDL input. +func NewExecView(mxcliPath, projectPath string, width, height int) ExecView { + ta := textarea.New() + ta.Placeholder = "Paste or type MDL script here...\n\nCtrl+E to execute, Ctrl+O to open file, Esc to close" + ta.ShowLineNumbers = true + ta.Focus() + ta.SetWidth(width - 4) + ta.SetHeight(height - 6) + + pi := textinput.New() + pi.Placeholder = "/path/to/script.mdl" + pi.Prompt = " File: " + pi.CharLimit = 500 + + return ExecView{ + textarea: ta, + pathInput: pi, + mxcliPath: mxcliPath, + projectPath: projectPath, + width: width, + height: height, + } +} + +// Mode returns ModeExec. +func (ev ExecView) Mode() ViewMode { + return ModeExec +} + +// Hints returns context-sensitive hints. +func (ev ExecView) Hints() []Hint { + if ev.picking { + return []Hint{ + {Key: "Tab", Label: "complete"}, + {Key: "Enter", Label: "open"}, + {Key: "Esc", Label: "back"}, + } + } + return ExecViewHints +} + +// StatusInfo returns display data for the status bar. +func (ev ExecView) StatusInfo() StatusInfo { + if ev.picking { + return StatusInfo{ + Breadcrumb: []string{"Execute MDL", "Open File"}, + Mode: "Exec", + } + } + lines := strings.Count(ev.textarea.Value(), "\n") + 1 + extra := "" + if ev.loadedPath != "" { + extra = filepath.Base(ev.loadedPath) + } + return StatusInfo{ + Breadcrumb: []string{"Execute MDL"}, + Position: fmt.Sprintf("L%d", lines), + Mode: "Exec", + Extra: extra, + } +} + +// Render returns the ExecView rendered string. +func (ev ExecView) Render(width, height int) string { + if ev.picking { + return ev.renderPicker(width, height) + } + + ev.textarea.SetWidth(width - 4) + ev.textarea.SetHeight(height - 6) + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Padding(0, 1) + title := titleStyle.Render("Execute MDL") + + statusLine := "" + if ev.executing { + statusLine = lipgloss.NewStyle().Foreground(AccentColor).Render(" Executing...") + } else if ev.flash != "" { + statusLine = lipgloss.NewStyle().Foreground(MutedColor).Render(" " + ev.flash) + } + + content := lipgloss.JoinVertical(lipgloss.Left, + title, + ev.textarea.View(), + statusLine, + ) + + return lipgloss.NewStyle().Padding(1, 2).Render(content) +} + +func (ev ExecView) renderPicker(width, height int) string { + dimStyle := lipgloss.NewStyle().Foreground(MutedColor) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + mdlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(AccentColor) + + var sb strings.Builder + sb.WriteString(titleStyle.Render("Open MDL File") + "\n\n") + sb.WriteString(ev.pathInput.View() + "\n\n") + + if len(ev.pathCandidates) == 0 { + sb.WriteString(dimStyle.Render("Type a path, use Tab to complete") + "\n") + } else { + end := ev.pathScroll + execPickerMaxVisible + if end > len(ev.pathCandidates) { + end = len(ev.pathCandidates) + } + if ev.pathScroll > 0 { + sb.WriteString(dimStyle.Render(" ↑ more above") + "\n") + } + for i := ev.pathScroll; i < end; i++ { + c := ev.pathCandidates[i] + suffix := "" + if c.isDir { + suffix = "/" + } + label := c.icon() + " " + c.name + suffix + if i == ev.pathCursor { + if c.isMDL { + sb.WriteString(mdlStyle.Render("> "+label) + "\n") + } else { + sb.WriteString(selectedStyle.Render("> "+label) + "\n") + } + } else { + sb.WriteString(normalStyle.Render(" "+label) + "\n") + } + } + if end < len(ev.pathCandidates) { + sb.WriteString(dimStyle.Render(" ↓ more below") + "\n") + } + sb.WriteString("\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf("%d items", len(ev.pathCandidates))) + "\n") + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(AccentColor). + Padding(1, 2). + Width(min(70, width-4)) + + content := boxStyle.Render(sb.String()) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content) +} + +// Update handles input and internal messages. +func (ev ExecView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case ExecDoneMsg: + ev.executing = false + content := msg.Output + if msg.Err != nil { + content = "-- Error:\n" + msg.Output + } + return ev, func() tea.Msg { + return execShowResultMsg{Content: content, Success: msg.Err == nil} + } + + case execFileLoadedMsg: + ev.picking = false + ev.textarea.Focus() + if msg.Err != nil { + ev.flash = fmt.Sprintf("Error: %v", msg.Err) + return ev, nil + } + if msg.Content != "" { + ev.textarea.SetValue(msg.Content) + ev.loadedPath = msg.Path + ev.flash = fmt.Sprintf("Loaded: %s", filepath.Base(msg.Path)) + } + return ev, nil + + case tea.WindowSizeMsg: + ev.width = msg.Width + ev.height = msg.Height + ev.textarea.SetWidth(msg.Width - 4) + ev.textarea.SetHeight(msg.Height - 6) + return ev, nil + + case tea.KeyMsg: + if ev.executing { + return ev, nil + } + if ev.picking { + return ev.updatePicker(msg) + } + return ev.updateEditor(msg) + } + + var cmd tea.Cmd + ev.textarea, cmd = ev.textarea.Update(msg) + return ev, cmd +} + +func (ev ExecView) updateEditor(msg tea.KeyMsg) (View, tea.Cmd) { + switch msg.String() { + case "esc": + if ev.flash != "" { + ev.flash = "" + return ev, nil + } + return ev, func() tea.Msg { return PopViewMsg{} } + + case "ctrl+e": + mdlText := strings.TrimSpace(ev.textarea.Value()) + if mdlText == "" { + ev.flash = "Nothing to execute" + return ev, nil + } + ev.executing = true + return ev, ev.executeMDL(mdlText) + + case "ctrl+o": + ev.picking = true + ev.textarea.Blur() + ev.pathInput.SetValue("") + ev.pathCandidates = nil + ev.pathCursor = 0 + ev.pathScroll = 0 + // Start from working directory + cwd, _ := os.Getwd() + ev.pathInput.SetValue(cwd + string(os.PathSeparator)) + ev.pathInput.CursorEnd() + ev.pathInput.Focus() + ev.refreshMDLCandidates() + return ev, nil + } + + var cmd tea.Cmd + ev.textarea, cmd = ev.textarea.Update(msg) + return ev, cmd +} + +func (ev ExecView) updatePicker(msg tea.KeyMsg) (View, tea.Cmd) { + switch msg.String() { + case "esc": + ev.picking = false + ev.textarea.Focus() + return ev, nil + + case "up": + ev.pickerCursorUp() + return ev, nil + + case "down": + ev.pickerCursorDown() + return ev, nil + + case "tab": + if len(ev.pathCandidates) > 0 { + ev.applyMDLCandidate() + } + return ev, nil + + case "enter": + if len(ev.pathCandidates) > 0 { + c := ev.pathCandidates[ev.pathCursor] + if c.isMDL { + // Load the file + return ev, ev.loadFile(c.fullPath) + } + // Directory: drill in + ev.applyMDLCandidate() + return ev, nil + } + // Try loading whatever path is in the input + val := strings.TrimSpace(ev.pathInput.Value()) + if val != "" && strings.HasSuffix(strings.ToLower(val), ".mdl") { + return ev, ev.loadFile(val) + } + return ev, nil + + default: + var cmd tea.Cmd + ev.pathInput, cmd = ev.pathInput.Update(msg) + ev.pathCursor = 0 + ev.pathScroll = 0 + ev.refreshMDLCandidates() + return ev, cmd + } +} + +// refreshMDLCandidates lists filesystem entries, showing directories and .mdl files. +func (ev *ExecView) refreshMDLCandidates() { + val := strings.TrimSpace(ev.pathInput.Value()) + if val == "" { + ev.pathCandidates = nil + return + } + + dir := val + prefix := "" + if !strings.HasSuffix(val, string(os.PathSeparator)) { + dir = filepath.Dir(val) + prefix = strings.ToLower(filepath.Base(val)) + } + + entries, err := os.ReadDir(dir) + if err != nil { + ev.pathCandidates = nil + return + } + + var candidates []mdlCandidate + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") { + continue // skip hidden files + } + if prefix != "" && !strings.HasPrefix(strings.ToLower(name), prefix) { + continue + } + full := filepath.Join(dir, name) + isMDL := !e.IsDir() && strings.HasSuffix(strings.ToLower(name), ".mdl") + // Show directories and .mdl files only + if !e.IsDir() && !isMDL { + continue + } + candidates = append(candidates, mdlCandidate{ + fullPath: full, + name: name, + isDir: e.IsDir(), + isMDL: isMDL, + }) + } + ev.pathCandidates = candidates + if ev.pathCursor >= len(candidates) { + ev.pathCursor = 0 + ev.pathScroll = 0 + } +} + +func (ev *ExecView) pickerCursorDown() { + if len(ev.pathCandidates) == 0 { + return + } + ev.pathCursor++ + if ev.pathCursor >= len(ev.pathCandidates) { + ev.pathCursor = 0 + ev.pathScroll = 0 + } else if ev.pathCursor >= ev.pathScroll+execPickerMaxVisible { + ev.pathScroll = ev.pathCursor - execPickerMaxVisible + 1 + } +} + +func (ev *ExecView) pickerCursorUp() { + if len(ev.pathCandidates) == 0 { + return + } + ev.pathCursor-- + if ev.pathCursor < 0 { + ev.pathCursor = len(ev.pathCandidates) - 1 + ev.pathScroll = max(0, ev.pathCursor-execPickerMaxVisible+1) + } else if ev.pathCursor < ev.pathScroll { + ev.pathScroll = ev.pathCursor + } +} + +func (ev *ExecView) applyMDLCandidate() { + if len(ev.pathCandidates) == 0 { + return + } + c := ev.pathCandidates[ev.pathCursor] + if c.isDir { + ev.pathInput.SetValue(c.fullPath + string(os.PathSeparator)) + } else { + ev.pathInput.SetValue(c.fullPath) + } + ev.pathInput.CursorEnd() + ev.pathCursor = 0 + ev.pathScroll = 0 + ev.refreshMDLCandidates() +} + +// loadFile reads a file and returns execFileLoadedMsg. +func (ev ExecView) loadFile(path string) tea.Cmd { + return func() tea.Msg { + content, err := os.ReadFile(path) + if err != nil { + return execFileLoadedMsg{Err: fmt.Errorf("read %s: %w", path, err)} + } + return execFileLoadedMsg{Path: path, Content: string(content)} + } +} + +// executeMDL writes MDL to a temp file and runs `mxcli exec`. +func (ev ExecView) executeMDL(mdlText string) tea.Cmd { + mxcliPath := ev.mxcliPath + projectPath := ev.projectPath + return func() tea.Msg { + tmpFile, err := os.CreateTemp("", "mxcli-exec-*.mdl") + if err != nil { + return ExecDoneMsg{Output: fmt.Sprintf("Failed to create temp file: %v", err), Err: err} + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.WriteString(mdlText); err != nil { + tmpFile.Close() + return ExecDoneMsg{Output: fmt.Sprintf("Failed to write temp file: %v", err), Err: err} + } + tmpFile.Close() + + args := []string{"exec"} + if projectPath != "" { + args = append(args, "-p", projectPath) + } + args = append(args, tmpPath) + out, err := runMxcli(mxcliPath, args...) + return ExecDoneMsg{Output: out, Err: err} + } +} diff --git a/cmd/mxcli/tui/execview_test.go b/cmd/mxcli/tui/execview_test.go new file mode 100644 index 0000000..5244863 --- /dev/null +++ b/cmd/mxcli/tui/execview_test.go @@ -0,0 +1,18 @@ +package tui + +import "testing" + +func TestExecView_Mode(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + if ev.Mode() != ModeExec { + t.Errorf("expected ModeExec, got %v", ev.Mode()) + } +} + +func TestExecView_StatusInfo(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + info := ev.StatusInfo() + if info.Mode != "Exec" { + t.Errorf("expected mode 'Exec', got %q", info.Mode) + } +} diff --git a/cmd/mxcli/tui/help.go b/cmd/mxcli/tui/help.go index 32eec0e..f9dd7f3 100644 --- a/cmd/mxcli/tui/help.go +++ b/cmd/mxcli/tui/help.go @@ -21,6 +21,7 @@ const helpText = ` y copy to clipboard r refresh project tree z zen mode (zoom panel) + x execute MDL script Tab switch MDL / NDSL preview t new tab (same project) T new tab (pick project) diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index ac940ca..6eede3a 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -42,6 +42,7 @@ var ( {Key: "t", Label: "tab"}, {Key: "T", Label: "new project"}, {Key: "1-9", Label: "switch tab"}, + {Key: "x", Label: "exec"}, {Key: "?", Label: "help"}, } FilterActiveHints = []Hint{ @@ -63,6 +64,11 @@ var ( {Key: "d", Label: "diff"}, {Key: "q", Label: "close"}, } + ExecViewHints = []Hint{ + {Key: "Ctrl+E", Label: "execute"}, + {Key: "Ctrl+O", Label: "open file"}, + {Key: "Esc", Label: "close"}, + } DiffViewHints = []Hint{ {Key: "j/k", Label: "scroll"}, {Key: "Tab", Label: "mode"}, diff --git a/cmd/mxcli/tui/view.go b/cmd/mxcli/tui/view.go index be57f00..15dec84 100644 --- a/cmd/mxcli/tui/view.go +++ b/cmd/mxcli/tui/view.go @@ -12,6 +12,7 @@ const ( ModeDiff ModePicker ModeJumper + ModeExec ) // String returns a human-readable label for the view mode. @@ -29,6 +30,8 @@ func (m ViewMode) String() string { return "Picker" case ModeJumper: return "Jump" + case ModeExec: + return "Exec" default: return "Unknown" } diff --git a/docs/plans/2026-03-25-tui-exec-mdl.md b/docs/plans/2026-03-25-tui-exec-mdl.md new file mode 100644 index 0000000..e6e8364 --- /dev/null +++ b/docs/plans/2026-03-25-tui-exec-mdl.md @@ -0,0 +1,557 @@ +# TUI MDL Execution Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add MDL script execution capability to the TUI — both from pasted text and from file selection. + +**Architecture:** New `ExecView` implementing the `View` interface, with a `textarea` for MDL input and a file picker fallback. Execution delegates to `runMxcli("exec", ...)` subprocess (consistent with existing TUI patterns). Results display in an OverlayView; project tree refreshes after successful execution. + +**Tech Stack:** `github.com/charmbracelet/bubbles/textarea`, existing TUI View/ViewStack infrastructure + +--- + +### Task 1: Add ModeExec and ExecResultMsg + +**Files:** +- Modify: `cmd/mxcli/tui/view.go` (add `ModeExec` constant and String case) +- Modify: `cmd/mxcli/tui/hintbar.go` (add `ExecViewHints`) + +**Step 1: Add ModeExec to ViewMode** + +In `cmd/mxcli/tui/view.go`, add `ModeExec` after `ModeJumper`: + +```go +const ( + ModeBrowser ViewMode = iota + ModeOverlay + ModeCompare + ModeDiff + ModePicker + ModeJumper + ModeExec +) +``` + +And in the `String()` method, add: +```go +case ModeExec: + return "Exec" +``` + +**Step 2: Add ExecViewHints to hintbar.go** + +```go +ExecViewHints = []Hint{ + {Key: "Ctrl+E", Label: "execute"}, + {Key: "Ctrl+O", Label: "open file"}, + {Key: "Esc", Label: "close"}, +} +``` + +**Step 3: Run build to verify** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go build ./cmd/mxcli/tui/...` +Expected: PASS (no new references yet) + +**Step 4: Commit** + +```bash +git add cmd/mxcli/tui/view.go cmd/mxcli/tui/hintbar.go +git commit -m "feat(tui): add ModeExec view mode and ExecViewHints" +``` + +--- + +### Task 2: Create ExecView with textarea + +**Files:** +- Create: `cmd/mxcli/tui/execview.go` +- Create: `cmd/mxcli/tui/execview_test.go` + +**Step 1: Write test for ExecView construction and Mode** + +Create `cmd/mxcli/tui/execview_test.go`: + +```go +package tui + +import "testing" + +func TestExecView_Mode(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + if ev.Mode() != ModeExec { + t.Errorf("expected ModeExec, got %v", ev.Mode()) + } +} + +func TestExecView_StatusInfo(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + info := ev.StatusInfo() + if info.Mode != "Exec" { + t.Errorf("expected mode 'Exec', got %q", info.Mode) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go test ./cmd/mxcli/tui/ -run TestExecView -v` +Expected: FAIL (NewExecView undefined) + +**Step 3: Implement ExecView** + +Create `cmd/mxcli/tui/execview.go`: + +```go +package tui + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ExecDoneMsg carries the result of MDL execution. +type ExecDoneMsg struct { + Output string + Err error +} + +// ExecView provides a textarea for entering/pasting MDL scripts and executing them. +type ExecView struct { + textarea textarea.Model + mxcliPath string + projectPath string + width int + height int + executing bool + flash string // status message +} + +// NewExecView creates an ExecView with a textarea for MDL input. +func NewExecView(mxcliPath, projectPath string, width, height int) ExecView { + ta := textarea.New() + ta.Placeholder = "Paste or type MDL script here...\n\nCtrl+E to execute, Ctrl+O to open file, Esc to close" + ta.ShowLineNumbers = true + ta.Focus() + ta.SetWidth(width - 4) + ta.SetHeight(height - 6) // room for title and status line + + return ExecView{ + textarea: ta, + mxcliPath: mxcliPath, + projectPath: projectPath, + width: width, + height: height, + } +} + +func (ev ExecView) Mode() ViewMode { + return ModeExec +} + +func (ev ExecView) Hints() []Hint { + return ExecViewHints +} + +func (ev ExecView) StatusInfo() StatusInfo { + lines := strings.Count(ev.textarea.Value(), "\n") + 1 + return StatusInfo{ + Breadcrumb: []string{"Execute MDL"}, + Position: fmt.Sprintf("L%d", lines), + Mode: "Exec", + } +} + +func (ev ExecView) Render(width, height int) string { + ev.textarea.SetWidth(width - 4) + ev.textarea.SetHeight(height - 6) + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(AccentColor).Padding(0, 1) + title := titleStyle.Render("Execute MDL") + + statusLine := "" + if ev.executing { + statusLine = lipgloss.NewStyle().Foreground(WarningColor).Render(" Executing...") + } else if ev.flash != "" { + statusLine = lipgloss.NewStyle().Foreground(MutedColor).Render(" " + ev.flash) + } + + content := lipgloss.JoinVertical(lipgloss.Left, + title, + ev.textarea.View(), + statusLine, + ) + + return lipgloss.NewStyle().Padding(1, 2).Render(content) +} + +func (ev ExecView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case ExecDoneMsg: + ev.executing = false + content := msg.Output + if msg.Err != nil { + content = "-- Error:\n" + msg.Output + } + // Push result overlay and pop exec view + return ev, func() tea.Msg { + return execShowResultMsg{Content: content, Success: msg.Err == nil} + } + + case tea.KeyMsg: + if ev.executing { + return ev, nil // ignore keys during execution + } + + switch msg.String() { + case "esc": + if ev.textarea.Value() == "" { + return ev, func() tea.Msg { return PopViewMsg{} } + } + // If there's content, first Esc clears flash; second Esc closes + if ev.flash != "" { + ev.flash = "" + return ev, nil + } + return ev, func() tea.Msg { return PopViewMsg{} } + + case "ctrl+e": + mdlText := strings.TrimSpace(ev.textarea.Value()) + if mdlText == "" { + ev.flash = "Nothing to execute" + return ev, nil + } + ev.executing = true + return ev, ev.executeMDL(mdlText) + + case "ctrl+o": + return ev, ev.openFileDialog() + } + + // Forward to textarea + var cmd tea.Cmd + ev.textarea, cmd = ev.textarea.Update(msg) + return ev, cmd + } + + var cmd tea.Cmd + ev.textarea, cmd = ev.textarea.Update(msg) + return ev, cmd +} + +// executeMDL writes MDL to a temp file and runs `mxcli exec`. +func (ev ExecView) executeMDL(mdlText string) tea.Cmd { + mxcliPath := ev.mxcliPath + projectPath := ev.projectPath + return func() tea.Msg { + // Write to temp file + tmpFile, err := os.CreateTemp("", "mxcli-exec-*.mdl") + if err != nil { + return ExecDoneMsg{Output: fmt.Sprintf("Failed to create temp file: %v", err), Err: err} + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.WriteString(mdlText); err != nil { + tmpFile.Close() + return ExecDoneMsg{Output: fmt.Sprintf("Failed to write temp file: %v", err), Err: err} + } + tmpFile.Close() + + args := []string{"exec"} + if projectPath != "" { + args = append(args, "-p", projectPath) + } + args = append(args, tmpPath) + out, err := runMxcli(mxcliPath, args...) + return ExecDoneMsg{Output: out, Err: err} + } +} + +// openFileDialog uses the system file picker or a simple stdin prompt. +// For now, it reads from a well-known env var or prompts via the picker. +func (ev ExecView) openFileDialog() tea.Cmd { + return func() tea.Msg { + return execOpenFileMsg{} + } +} + +// execShowResultMsg signals App to show exec result and optionally refresh tree. +type execShowResultMsg struct { + Content string + Success bool +} + +// execOpenFileMsg signals App to open the file picker for MDL files. +type execOpenFileMsg struct{} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go test ./cmd/mxcli/tui/ -run TestExecView -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/mxcli/tui/execview.go cmd/mxcli/tui/execview_test.go +git commit -m "feat(tui): add ExecView with textarea for MDL input" +``` + +--- + +### Task 3: Wire ExecView into App + +**Files:** +- Modify: `cmd/mxcli/tui/app.go` (handle key `x` in browser mode, handle ExecDoneMsg, execShowResultMsg, execOpenFileMsg) +- Modify: `cmd/mxcli/tui/help.go` (add exec entry) + +**Step 1: Add key `x` handler in `handleBrowserAppKeys`** + +In `app.go`, inside `handleBrowserAppKeys`, add before the final `return nil`: + +```go +case "x": + ev := NewExecView(a.mxcliPath, a.activeTabProjectPath(), a.width, a.height) + a.views.Push(ev) + return func() tea.Msg { return nil } +``` + +**Step 2: Handle execShowResultMsg in App.Update** + +Add a case in the `Update` switch: + +```go +case execShowResultMsg: + // Pop the ExecView + a.views.Pop() + // Show result in overlay + content := DetectAndHighlight(msg.Content) + ov := NewOverlayView("Exec Result", content, a.width, a.height, OverlayViewOpts{}) + a.views.Push(ov) + // If execution succeeded, refresh tree + if msg.Success { + return a, a.Init() + } + return a, nil +``` + +**Step 3: Handle execOpenFileMsg in App.Update** + +Add a case for file picker: + +```go +case execOpenFileMsg: + // Re-use the existing picker to pick an MDL file, then load its content + // into the ExecView textarea. + if execView, ok := a.views.Active().(ExecView); ok { + return a, execView.pickFile() + } + return a, nil +``` + +Add `pickFile` method to ExecView in `execview.go`: + +```go +// pickFile opens a native file dialog or reads path from env. +func (ev ExecView) pickFile() tea.Cmd { + return func() tea.Msg { + // Try zenity / kdialog for file selection + for _, picker := range []struct { + bin string + args []string + }{ + {"zenity", []string{"--file-selection", "--file-filter=MDL files (*.mdl)|*.mdl"}}, + {"kdialog", []string{"--getopenfilename", ".", "*.mdl"}}, + } { + if binPath, err := exec.LookPath(picker.bin); err == nil { + cmd := exec.Command(binPath, picker.args...) + out, err := cmd.Output() + if err == nil { + path := strings.TrimSpace(string(out)) + if path != "" { + content, err := os.ReadFile(path) + if err != nil { + return execFileLoadedMsg{Err: err} + } + return execFileLoadedMsg{Path: path, Content: string(content)} + } + } + return execFileLoadedMsg{} // user cancelled + } + } + return execFileLoadedMsg{Err: fmt.Errorf("no file picker available (install zenity or kdialog)")} + } +} +``` + +Add message type and handler in `execview.go`: + +```go +type execFileLoadedMsg struct { + Path string + Content string + Err error +} +``` + +And in ExecView.Update, add: + +```go +case execFileLoadedMsg: + if msg.Err != nil { + ev.flash = fmt.Sprintf("Error: %v", msg.Err) + return ev, nil + } + if msg.Content != "" { + ev.textarea.SetValue(msg.Content) + ev.flash = fmt.Sprintf("Loaded: %s", msg.Path) + } + return ev, nil +``` + +**Step 4: Update help text** + +In `help.go`, add under ACTIONS: + +``` + x execute MDL script +``` + +**Step 5: Add hint for x in ListBrowsingHints** + +In `hintbar.go`, add before `{Key: "?", Label: "help"}`: + +```go +{Key: "x", Label: "exec"}, +``` + +**Step 6: Build and verify** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go build ./cmd/mxcli/...` +Expected: PASS + +**Step 7: Commit** + +```bash +git add cmd/mxcli/tui/app.go cmd/mxcli/tui/execview.go cmd/mxcli/tui/help.go cmd/mxcli/tui/hintbar.go +git commit -m "feat(tui): wire ExecView into App with key 'x', file picker, and tree refresh" +``` + +--- + +### Task 4: Handle ExecDoneMsg forwarding in App + +**Files:** +- Modify: `cmd/mxcli/tui/app.go` + +The `ExecDoneMsg` is dispatched to the active view (ExecView) via the default case. The ExecView then emits `execShowResultMsg` which App handles. This should work with the existing message forwarding pattern. + +**Step 1: Verify the message flow** + +Verify that `ExecDoneMsg` flows through the default case in `App.Update`: + +```go +default: + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) + return a, cmd +``` + +This already forwards unknown messages to the active view, so `ExecDoneMsg` will reach `ExecView.Update`. + +**Step 2: Add `execShowResultMsg` and `execFileLoadedMsg` to App.Update** + +These need explicit cases because they require App-level actions: + +```go +case execShowResultMsg: + // handled in step 3.2 above +case execOpenFileMsg: + // handled in step 3.3 above +``` + +`execFileLoadedMsg` can flow through the default case to ExecView since it only modifies ExecView state. + +**Step 3: Manual test** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go run ./cmd/mxcli tui -p /mnt/data_sdd/gh/mxproj-GenAIDemo/App.mpr` + +- Press `x` → ExecView should appear with textarea +- Type `SHOW MODULES;` → Press Ctrl+E → Should execute and show result +- Press `q` to close result → Tree should refresh +- Press `x` again → Press Ctrl+O → File picker should open (if zenity installed) + +**Step 4: Commit if any fixes were needed** + +```bash +git add -u +git commit -m "fix(tui): fix exec message forwarding" +``` + +--- + +### Task 5: Add ExecView window size handling + +**Files:** +- Modify: `cmd/mxcli/tui/execview.go` + +**Step 1: Handle WindowSizeMsg** + +In ExecView.Update, before the default textarea forwarding, add: + +```go +case tea.WindowSizeMsg: + ev.width = msg.Width + ev.height = msg.Height + ev.textarea.SetWidth(msg.Width - 4) + ev.textarea.SetHeight(msg.Height - 6) + return ev, nil +``` + +**Step 2: Build and verify** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go build ./cmd/mxcli/...` + +**Step 3: Commit** + +```bash +git add cmd/mxcli/tui/execview.go +git commit -m "fix(tui): handle window resize in ExecView" +``` + +--- + +### Task 6: Final integration test and cleanup + +**Step 1: Run all TUI tests** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && go test ./cmd/mxcli/tui/ -v` +Expected: All pass + +**Step 2: Run full build** + +Run: `cd /mnt/data_sdd/gh/mxcli-wt-01 && make build` +Expected: Success + +**Step 3: Manual E2E test** + +1. `./bin/mxcli tui -p /mnt/data_sdd/gh/mxproj-GenAIDemo/App.mpr` +2. Press `x` → verify textarea appears +3. Paste: `SHOW MODULES;` → Ctrl+E → verify output overlay +4. Press `q` → verify back to browser, tree refreshed +5. Press `x` → Ctrl+O → verify file picker (or graceful error) +6. Press `Esc` → verify returns to browser + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat(tui): MDL execution from TUI (paste or file) - closes #30" +``` From 3db69a9b092dc6e7acc2f20ce69e6c279491b807 Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 20:07:08 +0800 Subject: [PATCH 07/10] feat(tui): add MPR file watching and mx check auto-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch MPR project files for external changes (fsnotify) and automatically refresh the tree and run mx check. Results are shown as a status bar badge (✗ 8E / ✓) and a scrollable overlay via the ! key. - Add Watcher with debounce (500ms), suppress window for self-modifications, and recursive mprcontents/ monitoring - Add checker that parses mx check output format and renders error/warning details in a line-number-free overlay - Export ResolveMx and add ~/.mxcli/mxbuild/ fallback lookup - Add HideLineNumbers option to ContentView/OverlayView --- cmd/mxcli/cmd_tui.go | 1 + cmd/mxcli/docker/build.go | 2 +- cmd/mxcli/docker/check.go | 15 +++- cmd/mxcli/tui/app.go | 69 ++++++++++++++- cmd/mxcli/tui/checker.go | 161 ++++++++++++++++++++++++++++++++++ cmd/mxcli/tui/checker_test.go | 94 ++++++++++++++++++++ cmd/mxcli/tui/contentview.go | 31 ++++--- cmd/mxcli/tui/hintbar.go | 1 + cmd/mxcli/tui/overlayview.go | 16 ++-- cmd/mxcli/tui/statusbar.go | 11 ++- cmd/mxcli/tui/theme.go | 8 ++ cmd/mxcli/tui/watcher.go | 141 +++++++++++++++++++++++++++++ cmd/mxcli/tui/watcher_test.go | 117 ++++++++++++++++++++++++ go.mod | 1 + go.sum | 6 ++ 15 files changed, 651 insertions(+), 23 deletions(-) create mode 100644 cmd/mxcli/tui/checker.go create mode 100644 cmd/mxcli/tui/checker_test.go create mode 100644 cmd/mxcli/tui/watcher.go create mode 100644 cmd/mxcli/tui/watcher_test.go diff --git a/cmd/mxcli/cmd_tui.go b/cmd/mxcli/cmd_tui.go index cc53efd..65dbb1d 100644 --- a/cmd/mxcli/cmd_tui.go +++ b/cmd/mxcli/cmd_tui.go @@ -59,6 +59,7 @@ Example: m := tui.NewApp(mxcliPath, projectPath) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + m.StartWatcher(p) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/cmd/mxcli/docker/build.go b/cmd/mxcli/docker/build.go index c07924d..39a8dc8 100644 --- a/cmd/mxcli/docker/build.go +++ b/cmd/mxcli/docker/build.go @@ -90,7 +90,7 @@ func Build(opts BuildOptions) error { // Step 4: Pre-build check if !opts.SkipCheck { fmt.Fprintln(w, "Checking project for errors...") - mxPath, err := resolveMx(opts.MxBuildPath) + mxPath, err := ResolveMx(opts.MxBuildPath) if err != nil { fmt.Fprintf(w, " Skipping check: %v\n", err) } else { diff --git a/cmd/mxcli/docker/check.go b/cmd/mxcli/docker/check.go index b6be42b..7326d54 100644 --- a/cmd/mxcli/docker/check.go +++ b/cmd/mxcli/docker/check.go @@ -39,7 +39,7 @@ func Check(opts CheckOptions) error { } // Resolve mx binary - mxPath, err := resolveMx(opts.MxBuildPath) + mxPath, err := ResolveMx(opts.MxBuildPath) if err != nil { return err } @@ -67,9 +67,9 @@ func mxBinaryName() string { return "mx" } -// resolveMx finds the mx executable. +// ResolveMx finds the mx executable. // Priority: derive from mxbuild path > PATH lookup. -func resolveMx(mxbuildPath string) (string, error) { +func ResolveMx(mxbuildPath string) (string, error) { if mxbuildPath != "" { // Resolve mxbuild first to handle directory paths resolvedMxBuild, err := resolveMxBuild(mxbuildPath) @@ -102,5 +102,14 @@ func resolveMx(mxbuildPath string) (string, error) { return p, nil } + // Try cached mxbuild installations (~/.mxcli/mxbuild/*/modeler/mx) + if home, err := os.UserHomeDir(); err == nil { + matches, _ := filepath.Glob(filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxBinaryName())) + if len(matches) > 0 { + // Use the last match (highest version when sorted lexicographically) + return matches[len(matches)-1], nil + } + } + return "", fmt.Errorf("mx not found; specify --mxbuild-path pointing to Mendix installation directory") } diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 50e7c00..61b4d8f 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -2,7 +2,10 @@ package tui import ( "fmt" + "os" + "path/filepath" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -32,6 +35,10 @@ type App struct { hintBar HintBar statusBar StatusBar previewEngine *PreviewEngine + + watcher *Watcher + checkErrors []CheckError // nil = no check run yet, empty = pass + checkRunning bool } // NewApp creates the root App model. @@ -58,6 +65,29 @@ func NewApp(mxcliPath, projectPath string) App { return app } +// StartWatcher begins watching MPR files for external changes. +// Call after tea.NewProgram is created but before p.Run(). +func (a *App) StartWatcher(prog *tea.Program) { + tab := a.activeTabPtr() + if tab == nil { + return + } + mprPath := tab.ProjectPath + contentsDir := "" + dir := filepath.Dir(mprPath) + candidate := filepath.Join(dir, "mprcontents") + if stat, err := os.Stat(candidate); err == nil && stat.IsDir() { + contentsDir = candidate + } + w, err := NewWatcher(mprPath, contentsDir, prog) + if err != nil { + Trace("app: failed to start watcher: %v", err) + return + } + a.watcher = w + Trace("app: watcher started for %s (contentsDir=%q)", mprPath, contentsDir) +} + func (a *App) activeTabPtr() *Tab { if a.activeTab >= 0 && a.activeTab < len(a.tabs) { return &a.tabs[a.activeTab] @@ -272,8 +302,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { content := DetectAndHighlight(msg.Content) ov := NewOverlayView("Exec Result", content, a.width, a.height, OverlayViewOpts{}) a.views.Push(ov) - // If execution succeeded, refresh tree + // If execution succeeded, suppress watcher (self-modification) and refresh tree if msg.Success { + if a.watcher != nil { + a.watcher.Suppress(2 * time.Second) + } return a, a.Init() } return a, nil @@ -281,6 +314,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: Trace("app: key=%q picker=%v mode=%v help=%v", msg.String(), a.picker != nil, a.views.Active().Mode(), a.showHelp) if msg.String() == "ctrl+c" { + if a.watcher != nil { + a.watcher.Close() + } return a, tea.Quit } @@ -418,6 +454,27 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case MprChangedMsg: + Trace("app: MprChangedMsg — refreshing tree and running mx check") + a.previewEngine.ClearCache() + projectPath := a.activeTabProjectPath() + return a, tea.Batch(a.Init(), runMxCheck(projectPath)) + + case MxCheckStartMsg: + a.checkRunning = true + return a, nil + + case MxCheckResultMsg: + a.checkRunning = false + if msg.Err != nil { + Trace("app: mx check error: %v", msg.Err) + a.checkErrors = nil + } else { + a.checkErrors = msg.Errors + Trace("app: mx check done: %d diagnostics", len(msg.Errors)) + } + return a, nil + case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg, previewDebounceMsg: if a.views.Active().Mode() == ModeBrowser { updated, cmd := a.views.Active().Update(msg) @@ -449,6 +506,9 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "q": + if a.watcher != nil { + a.watcher.Close() + } for i := range a.tabs { a.tabs[i].Miller.previewEngine.Cancel() } @@ -526,6 +586,12 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.views.Push(ev) return func() tea.Msg { return nil } + case "!", "\\!": + content := renderCheckResults(a.checkErrors) + ov := NewOverlayView("mx check", content, a.width, a.height, OverlayViewOpts{HideLineNumbers: true}) + a.views.Push(ov) + return func() tea.Msg { return nil } + case "c": cv := NewCompareView() cv.mxcliPath = a.mxcliPath @@ -635,6 +701,7 @@ func (a App) View() string { a.statusBar.SetBreadcrumb(info.Breadcrumb) a.statusBar.SetPosition(info.Position) a.statusBar.SetMode(info.Mode) + a.statusBar.SetCheckBadge(formatCheckBadge(a.checkErrors, a.checkRunning)) viewModeNames := a.collectViewModeNames() a.statusBar.SetViewDepth(a.views.Depth(), viewModeNames) statusLine := StatusBarStyle.Width(a.width).Render(a.statusBar.View(a.width)) diff --git a/cmd/mxcli/tui/checker.go b/cmd/mxcli/tui/checker.go new file mode 100644 index 0000000..ad2323f --- /dev/null +++ b/cmd/mxcli/tui/checker.go @@ -0,0 +1,161 @@ +package tui + +import ( + "bufio" + "os/exec" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +// CheckError represents a single mx check diagnostic. +type CheckError struct { + Severity string // "ERROR" or "WARNING" + Code string // e.g. "CE0001" + Message string + Location string // e.g. "Module.Microflow" (may be empty) +} + +// MxCheckResultMsg carries the result of an async mx check run. +type MxCheckResultMsg struct { + Errors []CheckError + Err error +} + +// MxCheckStartMsg signals that a check run has started. +type MxCheckStartMsg struct{} + +// checkOutputPattern matches mx check output lines like: +// [error] [CE1613] "The selected association no longer exists." at Combo box 'cmbPriority' +// [warning] [CW0001] "Some warning" at Page 'MyPage' +var checkOutputPattern = regexp.MustCompile(`^\[(error|warning)\]\s+\[(\w+)\]\s+"(.+?)"\s+at\s+(.+?)\s*$`) + +// runMxCheck returns a tea.Cmd that runs mx check asynchronously. +func runMxCheck(projectPath string) tea.Cmd { + return tea.Batch( + func() tea.Msg { return MxCheckStartMsg{} }, + func() tea.Msg { + mxPath, err := docker.ResolveMx("") + if err != nil { + Trace("checker: mx not found: %v", err) + return MxCheckResultMsg{Err: err} + } + + Trace("checker: running %s check %s", mxPath, projectPath) + cmd := exec.Command(mxPath, "check", projectPath) + out, err := cmd.CombinedOutput() + output := string(out) + + errors := parseCheckOutput(output) + Trace("checker: done, %d diagnostics, err=%v", len(errors), err) + + // mx check returns non-zero exit code when there are errors, + // but we still want to show the parsed errors — only propagate + // err if we got no parseable output at all. + if err != nil && len(errors) == 0 { + return MxCheckResultMsg{Err: err} + } + return MxCheckResultMsg{Errors: errors} + }, + ) +} + +// parseCheckOutput extracts CheckError entries from mx check stdout. +func parseCheckOutput(output string) []CheckError { + var errors []CheckError + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + matches := checkOutputPattern.FindStringSubmatch(line) + if matches == nil { + continue + } + errors = append(errors, CheckError{ + Severity: strings.ToUpper(matches[1]), + Code: matches[2], + Message: matches[3], + Location: matches[4], + }) + } + return errors +} + +// renderCheckResults formats check errors for display in an overlay. +func renderCheckResults(errors []CheckError) string { + if len(errors) == 0 { + return CheckPassStyle.Render("✓ Project check passed — no errors or warnings") + } + + var sb strings.Builder + var errorCount, warningCount int + for _, e := range errors { + if e.Severity == "ERROR" { + errorCount++ + } else { + warningCount++ + } + } + + // Summary header + sb.WriteString(CheckHeaderStyle.Render("mx check Results")) + sb.WriteString("\n") + var summaryParts []string + if errorCount > 0 { + summaryParts = append(summaryParts, CheckErrorStyle.Render("● "+itoa(errorCount)+" errors")) + } + if warningCount > 0 { + summaryParts = append(summaryParts, CheckWarnStyle.Render("● "+itoa(warningCount)+" warnings")) + } + sb.WriteString(strings.Join(summaryParts, " ")) + sb.WriteString("\n\n") + + // Detail lines — severity+code on first line, message and location on next + for _, e := range errors { + var label string + if e.Severity == "ERROR" { + label = CheckErrorStyle.Render(e.Severity + " " + e.Code) + } else { + label = CheckWarnStyle.Render(e.Severity + " " + e.Code) + } + sb.WriteString(label + "\n") + sb.WriteString(" " + e.Message + "\n") + if e.Location != "" { + sb.WriteString(" " + CheckLocStyle.Render("at "+e.Location) + "\n") + } + sb.WriteString("\n") + } + return sb.String() +} + +// formatCheckBadge returns a compact badge string for the status bar. +func formatCheckBadge(errors []CheckError, running bool) string { + if running { + return CheckRunningStyle.Render("⟳ checking") + } + if errors == nil { + return "" // no check has run yet + } + if len(errors) == 0 { + return CheckPassStyle.Render("✓") + } + var errorCount, warningCount int + for _, e := range errors { + if e.Severity == "ERROR" { + errorCount++ + } else { + warningCount++ + } + } + var parts []string + if errorCount > 0 { + parts = append(parts, CheckErrorStyle.Render("✗ "+itoa(errorCount)+"E")) + } + if warningCount > 0 { + parts = append(parts, CheckWarnStyle.Render(itoa(warningCount)+"W")) + } + return strings.Join(parts, " ") +} + diff --git a/cmd/mxcli/tui/checker_test.go b/cmd/mxcli/tui/checker_test.go new file mode 100644 index 0000000..1dc66f7 --- /dev/null +++ b/cmd/mxcli/tui/checker_test.go @@ -0,0 +1,94 @@ +package tui + +import "testing" + +func TestParseCheckOutput(t *testing.T) { + input := `Checking your app for issues... +Checking the version of the mpr file. +The mpr file version is '11.6.4'. +Loading the mpr file. +Checking app for errors... +[error] [CE1613] "The selected association 'MyModule.Priority' no longer exists." at Combo box 'cmbPriority' +[warning] [CW0001] "Unused variable '$var' in microflow" at Microflow 'MyModule.DoSomething' +[error] [CE0463] "Widget definition changed for DataGrid2" at Page 'MyModule.CustomerList' +The app contains: 2 errors. +` + + errors := parseCheckOutput(input) + if len(errors) != 3 { + t.Fatalf("expected 3 errors, got %d", len(errors)) + } + + // First error + if errors[0].Severity != "ERROR" { + t.Errorf("expected ERROR, got %q", errors[0].Severity) + } + if errors[0].Code != "CE1613" { + t.Errorf("expected CE1613, got %q", errors[0].Code) + } + if errors[0].Location != "Combo box 'cmbPriority'" { + t.Errorf("unexpected location: %q", errors[0].Location) + } + + // Second: warning + if errors[1].Severity != "WARNING" { + t.Errorf("expected WARNING, got %q", errors[1].Severity) + } + if errors[1].Code != "CW0001" { + t.Errorf("expected CW0001, got %q", errors[1].Code) + } + + // Third: error + if errors[2].Code != "CE0463" { + t.Errorf("expected CE0463, got %q", errors[2].Code) + } +} + +func TestParseCheckOutputEmpty(t *testing.T) { + errors := parseCheckOutput("Project check passed.\n") + if len(errors) != 0 { + t.Fatalf("expected 0 errors, got %d", len(errors)) + } +} + +func TestParseCheckOutputIgnoresNonMatchingLines(t *testing.T) { + input := `Checking your app for issues... +Loading the mpr file. +The app contains: 0 errors. +` + errors := parseCheckOutput(input) + if len(errors) != 0 { + t.Fatalf("expected 0 errors from non-matching lines, got %d", len(errors)) + } +} + +func TestFormatCheckBadge(t *testing.T) { + // No check run yet + badge := formatCheckBadge(nil, false) + if badge != "" { + t.Errorf("expected empty badge, got %q", badge) + } + + // Running + badge = formatCheckBadge(nil, true) + if badge == "" { + t.Error("expected non-empty badge for running state") + } + + // Pass + badge = formatCheckBadge([]CheckError{}, false) + if badge == "" { + t.Error("expected non-empty badge for pass state") + } + + // Errors + errors := []CheckError{ + {Severity: "ERROR", Code: "CE0001"}, + {Severity: "WARNING", Code: "CW0001"}, + {Severity: "ERROR", Code: "CE0002"}, + } + badge = formatCheckBadge(errors, false) + if badge == "" { + t.Error("expected non-empty badge with errors") + } +} diff --git a/cmd/mxcli/tui/contentview.go b/cmd/mxcli/tui/contentview.go index 63743fc..cc28424 100644 --- a/cmd/mxcli/tui/contentview.go +++ b/cmd/mxcli/tui/contentview.go @@ -16,7 +16,8 @@ type ContentView struct { yOffset int width int height int - gutterW int + gutterW int + hideLineNumbers bool // Search state searching bool @@ -248,7 +249,11 @@ func (v ContentView) View() string { total := len(v.lines) showScrollbar := total > v.height - contentW := v.width - v.gutterW - 1 + effectiveGutterW := v.gutterW + if v.hideLineNumbers { + effectiveGutterW = 0 + } + contentW := v.width - effectiveGutterW - 1 if showScrollbar { contentW-- } @@ -287,16 +292,20 @@ func (v ContentView) View() string { lineIdx := v.yOffset + vi var line string if lineIdx < total { - num := fmt.Sprintf("%*d", v.gutterW-1, lineIdx+1) - - // Style line number based on match status var gutter string - if lineIdx == currentMatchLine { - gutter = currentMatchNumSt.Render(num) + " " - } else if matchSet[lineIdx] { - gutter = matchLineNumSt.Render(num) + " " + if v.hideLineNumbers { + gutter = "" } else { - gutter = lineNumSt.Render(num) + " " + num := fmt.Sprintf("%*d", v.gutterW-1, lineIdx+1) + + // Style line number based on match status + if lineIdx == currentMatchLine { + gutter = currentMatchNumSt.Render(num) + " " + } else if matchSet[lineIdx] { + gutter = matchLineNumSt.Render(num) + " " + } else { + gutter = lineNumSt.Render(num) + " " + } } content := v.lines[lineIdx] @@ -325,7 +334,7 @@ func (v ContentView) View() string { line = gutter + content } else { - line = strings.Repeat(" ", v.gutterW+contentW) + line = strings.Repeat(" ", effectiveGutterW+contentW) } // Scrollbar diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index 6eede3a..feb96e6 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -43,6 +43,7 @@ var ( {Key: "T", Label: "new project"}, {Key: "1-9", Label: "switch tab"}, {Key: "x", Label: "exec"}, + {Key: "!", Label: "check"}, {Key: "?", Label: "help"}, } FilterActiveHints = []Hint{ diff --git a/cmd/mxcli/tui/overlayview.go b/cmd/mxcli/tui/overlayview.go index 5628aca..0e6a025 100644 --- a/cmd/mxcli/tui/overlayview.go +++ b/cmd/mxcli/tui/overlayview.go @@ -16,12 +16,13 @@ type overlayContentMsg struct { // OverlayViewOpts holds optional configuration for an OverlayView. type OverlayViewOpts struct { - QName string - NodeType string - IsNDSL bool - Switchable bool - MxcliPath string - ProjectPath string + QName string + NodeType string + IsNDSL bool + Switchable bool + MxcliPath string + ProjectPath string + HideLineNumbers bool } // OverlayView wraps an Overlay to satisfy the View interface, @@ -49,6 +50,9 @@ func NewOverlayView(title, content string, width, height int, opts OverlayViewOp ov.overlay = NewOverlay() ov.overlay.switchable = opts.Switchable ov.overlay.Show(title, content, width, height) + if opts.HideLineNumbers { + ov.overlay.content.hideLineNumbers = true + } return ov } diff --git a/cmd/mxcli/tui/statusbar.go b/cmd/mxcli/tui/statusbar.go index 7881618..a99fab4 100644 --- a/cmd/mxcli/tui/statusbar.go +++ b/cmd/mxcli/tui/statusbar.go @@ -18,6 +18,7 @@ type StatusBar struct { breadcrumb []string position string // e.g. "3/4" mode string // e.g. "MDL" or "NDSL" + checkBadge string // e.g. "✗ 3E 2W" or "✓" (pre-styled) viewDepth int viewModes []string zones []breadcrumbZone // clickable breadcrumb zones @@ -43,6 +44,11 @@ func (s *StatusBar) SetMode(mode string) { s.mode = mode } +// SetCheckBadge sets the mx check status badge (pre-styled string). +func (s *StatusBar) SetCheckBadge(badge string) { + s.checkBadge = badge +} + // SetViewDepth sets the view stack depth and mode names for breadcrumb display. func (s *StatusBar) SetViewDepth(depth int, modes []string) { s.viewDepth = depth @@ -101,8 +107,11 @@ func (s *StatusBar) View(width int) string { } left := " " + strings.Join(crumbParts, sep) - // Build right side: position + mode + // Build right side: check badge + position + mode var rightParts []string + if s.checkBadge != "" { + rightParts = append(rightParts, s.checkBadge) + } if s.position != "" { rightParts = append(rightParts, PositionStyle.Render(s.position)) } diff --git a/cmd/mxcli/tui/theme.go b/cmd/mxcli/tui/theme.go index b5c59f0..7d0aacc 100644 --- a/cmd/mxcli/tui/theme.go +++ b/cmd/mxcli/tui/theme.go @@ -70,4 +70,12 @@ var ( FocusedEdgeChar = "▎" FocusedEdgeStyle = lipgloss.NewStyle().Foreground(FocusColor) AccentStyle = lipgloss.NewStyle().Foreground(AccentColor) + + // mx check result styles + CheckErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "196"}) + CheckWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "172", Dark: "214"}) + CheckPassStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "114"}) + CheckLocStyle = lipgloss.NewStyle().Foreground(MutedColor) + CheckHeaderStyle = lipgloss.NewStyle().Bold(true) + CheckRunningStyle = lipgloss.NewStyle().Foreground(MutedColor).Italic(true) ) diff --git a/cmd/mxcli/tui/watcher.go b/cmd/mxcli/tui/watcher.go new file mode 100644 index 0000000..9041ae7 --- /dev/null +++ b/cmd/mxcli/tui/watcher.go @@ -0,0 +1,141 @@ +package tui + +import ( + "os" + "path/filepath" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/fsnotify/fsnotify" +) + +// MprChangedMsg signals that the MPR project files were modified externally. +type MprChangedMsg struct{} + +// MsgSender abstracts the Send method for testability. +type MsgSender interface { + Send(msg tea.Msg) +} + +// programSender wraps a tea.Program to satisfy MsgSender. +type programSender struct{ prog *tea.Program } + +func (p programSender) Send(msg tea.Msg) { p.prog.Send(msg) } + +// Watcher monitors MPR project files for changes and notifies the TUI. +type Watcher struct { + fsw *fsnotify.Watcher + done chan struct{} + mu sync.Mutex + suppressEnd time.Time +} + +const watchDebounce = 500 * time.Millisecond + +// NewWatcher creates a file watcher that sends MprChangedMsg to prog. +// - mprPath: path to the .mpr file +// - contentsDir: path to mprcontents/ directory (empty string for v1) +func NewWatcher(mprPath, contentsDir string, prog *tea.Program) (*Watcher, error) { + return newWatcher(mprPath, contentsDir, programSender{prog: prog}) +} + +func newWatcher(mprPath, contentsDir string, sender MsgSender) (*Watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + w := &Watcher{ + fsw: fsw, + done: make(chan struct{}), + } + + if contentsDir != "" { + // MPR v2: mprcontents/ has a 2-level hash directory structure (e.g. f3/26/). + // fsnotify does not recurse, so walk all subdirectories and add each one. + err = filepath.WalkDir(contentsDir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return fsw.Add(path) + } + return nil + }) + } else { + err = fsw.Add(mprPath) + } + if err != nil { + fsw.Close() + return nil, err + } + + go w.run(sender) + return w, nil +} + +func (w *Watcher) run(sender MsgSender) { + var debounceTimer *time.Timer + + for { + select { + case <-w.done: + if debounceTimer != nil { + debounceTimer.Stop() + } + return + + case event, ok := <-w.fsw.Events: + if !ok { + return + } + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) && !event.Has(fsnotify.Remove) { + continue + } + ext := filepath.Ext(event.Name) + if ext != ".mpr" && ext != ".mxunit" && ext != "" { + continue + } + + w.mu.Lock() + suppressed := time.Now().Before(w.suppressEnd) + w.mu.Unlock() + if suppressed { + continue + } + + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(watchDebounce, func() { + sender.Send(MprChangedMsg{}) + }) + + case _, ok := <-w.fsw.Errors: + if !ok { + return + } + Trace("watcher: fsnotify error") + } + } +} + +// Suppress causes the watcher to ignore changes for the given duration. +// Use this when mxcli itself modifies the MPR (e.g., exec command). +func (w *Watcher) Suppress(d time.Duration) { + w.mu.Lock() + w.suppressEnd = time.Now().Add(d) + w.mu.Unlock() +} + +// Close stops the watcher and releases resources. +func (w *Watcher) Close() { + select { + case <-w.done: + return + default: + } + close(w.done) + w.fsw.Close() +} diff --git a/cmd/mxcli/tui/watcher_test.go b/cmd/mxcli/tui/watcher_test.go new file mode 100644 index 0000000..33b2e8c --- /dev/null +++ b/cmd/mxcli/tui/watcher_test.go @@ -0,0 +1,117 @@ +package tui + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// mockSender captures MprChangedMsg sends for testing. +type mockSender struct { + count atomic.Int32 +} + +func (m *mockSender) Send(msg tea.Msg) { + if _, ok := msg.(MprChangedMsg); ok { + m.count.Add(1) + } +} + +func TestWatcherDebounce(t *testing.T) { + dir := t.TempDir() + unitFile := filepath.Join(dir, "test.mxunit") + if err := os.WriteFile(unitFile, []byte("a"), 0644); err != nil { + t.Fatal(err) + } + + sender := &mockSender{} + w, err := newWatcher("", dir, sender) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // Rapidly write 5 times — should debounce into a single message + for i := range 5 { + _ = os.WriteFile(unitFile, []byte{byte('a' + i)}, 0644) + time.Sleep(50 * time.Millisecond) + } + + // Wait for debounce to fire (500ms + margin) + time.Sleep(700 * time.Millisecond) + + got := sender.count.Load() + if got != 1 { + t.Errorf("expected 1 debounced message, got %d", got) + } +} + +func TestWatcherSuppress(t *testing.T) { + dir := t.TempDir() + unitFile := filepath.Join(dir, "test.mxunit") + if err := os.WriteFile(unitFile, []byte("a"), 0644); err != nil { + t.Fatal(err) + } + + sender := &mockSender{} + w, err := newWatcher("", dir, sender) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // Suppress for 2 seconds + w.Suppress(2 * time.Second) + + // Write during suppress window + _ = os.WriteFile(unitFile, []byte("b"), 0644) + time.Sleep(700 * time.Millisecond) + + got := sender.count.Load() + if got != 0 { + t.Errorf("expected 0 messages during suppress, got %d", got) + } +} + +func TestWatcherCloseIdempotent(t *testing.T) { + dir := t.TempDir() + unitFile := filepath.Join(dir, "test.mxunit") + _ = os.WriteFile(unitFile, []byte("a"), 0644) + + sender := &mockSender{} + w, err := newWatcher("", dir, sender) + if err != nil { + t.Fatal(err) + } + + // Double close should not panic + w.Close() + w.Close() +} + +func TestWatcherIgnoresNonMxunitFiles(t *testing.T) { + dir := t.TempDir() + unitFile := filepath.Join(dir, "test.mxunit") + _ = os.WriteFile(unitFile, []byte("a"), 0644) + + sender := &mockSender{} + w, err := newWatcher("", dir, sender) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // Write a .tmp file — should be ignored + tmpFile := filepath.Join(dir, "test.tmp") + _ = os.WriteFile(tmpFile, []byte("b"), 0644) + time.Sleep(700 * time.Millisecond) + + got := sender.count.Load() + if got != 0 { + t.Errorf("expected 0 messages for .tmp file, got %d", got) + } +} diff --git a/go.mod b/go.mod index b626610..1ce5f90 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/chzyer/readline v1.5.1 + github.com/fsnotify/fsnotify v1.9.0 github.com/jackc/pgx/v5 v5.8.0 github.com/mattn/go-runewidth v0.0.19 github.com/microsoft/go-mssqldb v1.9.8 diff --git a/go.sum b/go.sum index a9befa8..584716d 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= @@ -22,6 +24,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -61,6 +65,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= From 6e0bdfd8851ab097835e6e134a39b62256c3288e Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 20:37:52 +0800 Subject: [PATCH 08/10] =?UTF-8?q?refactor(tui):=20fix=20code=20review=20is?= =?UTF-8?q?sues=20=E2=80=94=20encapsulation,=20docs,=20and=20test=20covera?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Base(), SetBase(), ModeNames() to ViewStack; replace direct field access in app.go - Clarify value-receiver Render() intent in DiffView and OverlayView with comments - Add 12 ExecView tests: file picker, cursor navigation, scroll, editor commands - Add 3 ViewStack tests for new Base/SetBase/ModeNames methods - Document watcher extension filter intent for MPR v2 extensionless files --- cmd/mxcli/tui/app.go | 16 +-- cmd/mxcli/tui/diffview.go | 2 + cmd/mxcli/tui/execview_test.go | 199 +++++++++++++++++++++++++++++++- cmd/mxcli/tui/overlayview.go | 8 +- cmd/mxcli/tui/viewstack.go | 19 +++ cmd/mxcli/tui/viewstack_test.go | 43 +++++++ cmd/mxcli/tui/watcher.go | 1 + 7 files changed, 273 insertions(+), 15 deletions(-) diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 61b4d8f..0eacc45 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -125,7 +125,7 @@ func (a *App) syncBrowserView() { contentH := max(5, a.height-chromeHeight) bv.miller.SetSize(a.width, contentH) } - a.views.base = bv + a.views.SetBase(bv) } // --- Init --- @@ -194,9 +194,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Pop the jumper view first a.views.Pop() // Navigate browser to the target node - if bv, ok := a.views.base.(BrowserView); ok { + if bv, ok := a.views.Base().(BrowserView); ok { cmd := bv.navigateToNode(msg.QName) - a.views.base = bv + a.views.SetBase(bv) if tab := a.activeTabPtr(); tab != nil { tab.Miller = bv.miller tab.UpdateLabel() @@ -440,7 +440,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tab.Miller.SetRootNodes(msg.Nodes) a.syncTabBar() // Update browser view if it's the base - if bv, ok := a.views.base.(BrowserView); ok { + if bv, ok := a.views.Base().(BrowserView); ok { bv.allNodes = msg.Nodes bv.compareItems = flattenQualifiedNames(msg.Nodes) bv.miller = tab.Miller @@ -448,7 +448,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { contentH := max(5, a.height-chromeHeight) bv.miller.SetSize(a.width, contentH) } - a.views.base = bv + a.views.SetBase(bv) } } } @@ -783,11 +783,7 @@ func renderContextSummary(nodes []*TreeNode) string { // collectViewModeNames returns the mode names for all views in the stack. func (a App) collectViewModeNames() []string { - names := []string{a.views.base.Mode().String()} - for _, v := range a.views.stack { - names = append(names, v.Mode().String()) - } - return names + return a.views.ModeNames() } // inferBsonType maps tree node types to valid bson object types. diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go index 1be5636..4a5bb46 100644 --- a/cmd/mxcli/tui/diffview.go +++ b/cmd/mxcli/tui/diffview.go @@ -628,6 +628,8 @@ func (dv DiffView) Update(msg tea.Msg) (View, tea.Cmd) { } // Render satisfies the View interface, with an LLM anchor prefix. +// DiffView uses a value receiver: width/height are set on the local copy +// so that View() picks them up within this call. The original is unaffected. func (dv DiffView) Render(width, height int) string { dv.width = width dv.height = height diff --git a/cmd/mxcli/tui/execview_test.go b/cmd/mxcli/tui/execview_test.go index 5244863..bb31e49 100644 --- a/cmd/mxcli/tui/execview_test.go +++ b/cmd/mxcli/tui/execview_test.go @@ -1,6 +1,34 @@ package tui -import "testing" +import ( + "os" + "path/filepath" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// fakeKeyMsg creates a tea.KeyMsg for the given key string. +func fakeKeyMsg(key string) tea.KeyMsg { + switch key { + case "ctrl+e": + return tea.KeyMsg{Type: tea.KeyCtrlE} + case "ctrl+o": + return tea.KeyMsg{Type: tea.KeyCtrlO} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } +} func TestExecView_Mode(t *testing.T) { ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) @@ -16,3 +44,172 @@ func TestExecView_StatusInfo(t *testing.T) { t.Errorf("expected mode 'Exec', got %q", info.Mode) } } + +func TestExecView_StatusInfo_Picking(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.picking = true + info := ev.StatusInfo() + if len(info.Breadcrumb) != 2 || info.Breadcrumb[1] != "Open File" { + t.Errorf("expected breadcrumb [Execute MDL, Open File], got %v", info.Breadcrumb) + } +} + +func TestRefreshMDLCandidates(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files and directories + os.WriteFile(filepath.Join(tmpDir, "script1.mdl"), []byte("SHOW ENTITIES"), 0644) + os.WriteFile(filepath.Join(tmpDir, "script2.mdl"), []byte("SHOW MODULES"), 0644) + os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("not mdl"), 0644) + os.WriteFile(filepath.Join(tmpDir, ".hidden.mdl"), []byte("hidden"), 0644) + os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755) + + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.pathInput.SetValue(tmpDir + string(os.PathSeparator)) + ev.refreshMDLCandidates() + + // Should include 2 .mdl files + 1 directory, exclude .txt and hidden files + if len(ev.pathCandidates) != 3 { + names := make([]string, len(ev.pathCandidates)) + for i, c := range ev.pathCandidates { + names[i] = c.name + } + t.Fatalf("expected 3 candidates (2 mdl + 1 dir), got %d: %v", len(ev.pathCandidates), names) + } + + mdlCount := 0 + dirCount := 0 + for _, c := range ev.pathCandidates { + if c.isMDL { + mdlCount++ + } + if c.isDir { + dirCount++ + } + } + if mdlCount != 2 { + t.Errorf("expected 2 MDL files, got %d", mdlCount) + } + if dirCount != 1 { + t.Errorf("expected 1 directory, got %d", dirCount) + } +} + +func TestRefreshMDLCandidates_WithPrefix(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "alpha.mdl"), []byte(""), 0644) + os.WriteFile(filepath.Join(tmpDir, "beta.mdl"), []byte(""), 0644) + + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.pathInput.SetValue(filepath.Join(tmpDir, "al")) + ev.refreshMDLCandidates() + + if len(ev.pathCandidates) != 1 { + t.Fatalf("expected 1 candidate matching 'al' prefix, got %d", len(ev.pathCandidates)) + } + if ev.pathCandidates[0].name != "alpha.mdl" { + t.Errorf("expected alpha.mdl, got %s", ev.pathCandidates[0].name) + } +} + +func TestRefreshMDLCandidates_EmptyPath(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.pathInput.SetValue("") + ev.refreshMDLCandidates() + + if ev.pathCandidates != nil { + t.Errorf("expected nil candidates for empty path, got %d", len(ev.pathCandidates)) + } +} + +func TestPickerCursorDown_Wraps(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.pathCandidates = []mdlCandidate{ + {name: "a.mdl", isMDL: true}, + {name: "b.mdl", isMDL: true}, + {name: "c.mdl", isMDL: true}, + } + ev.pathCursor = 2 + + ev.pickerCursorDown() + if ev.pathCursor != 0 { + t.Errorf("expected cursor to wrap to 0, got %d", ev.pathCursor) + } +} + +func TestPickerCursorUp_Wraps(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.pathCandidates = []mdlCandidate{ + {name: "a.mdl", isMDL: true}, + {name: "b.mdl", isMDL: true}, + {name: "c.mdl", isMDL: true}, + } + ev.pathCursor = 0 + + ev.pickerCursorUp() + if ev.pathCursor != 2 { + t.Errorf("expected cursor to wrap to 2, got %d", ev.pathCursor) + } +} + +func TestPickerCursorDown_ScrollsViewport(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + // Create more candidates than execPickerMaxVisible + candidates := make([]mdlCandidate, 15) + for i := range candidates { + candidates[i] = mdlCandidate{name: "f.mdl", isMDL: true} + } + ev.pathCandidates = candidates + ev.pathCursor = execPickerMaxVisible - 1 + ev.pathScroll = 0 + + ev.pickerCursorDown() + if ev.pathScroll != 1 { + t.Errorf("expected scroll to advance to 1, got %d", ev.pathScroll) + } +} + +func TestPickerCursorUp_ScrollsViewport(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + candidates := make([]mdlCandidate, 15) + for i := range candidates { + candidates[i] = mdlCandidate{name: "f.mdl", isMDL: true} + } + ev.pathCandidates = candidates + ev.pathCursor = 5 + ev.pathScroll = 5 + + ev.pickerCursorUp() + if ev.pathCursor != 4 { + t.Errorf("expected cursor at 4, got %d", ev.pathCursor) + } + if ev.pathScroll != 4 { + t.Errorf("expected scroll at 4, got %d", ev.pathScroll) + } +} + +func TestPickerCursorDown_EmptyCandidates(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.pathCandidates = nil + ev.pickerCursorDown() // should not panic +} + +func TestUpdateEditor_CtrlE_EmptyTextarea(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + // textarea starts empty + updated, _ := ev.updateEditor(fakeKeyMsg("ctrl+e")) + updatedEV := updated.(ExecView) + if updatedEV.flash != "Nothing to execute" { + t.Errorf("expected flash 'Nothing to execute', got %q", updatedEV.flash) + } +} + +func TestUpdateEditor_Esc_ClearsFlash(t *testing.T) { + ev := NewExecView("mxcli", "/tmp/test.mpr", 80, 24) + ev.flash = "some message" + updated, _ := ev.updateEditor(fakeKeyMsg("esc")) + updatedEV := updated.(ExecView) + if updatedEV.flash != "" { + t.Errorf("expected flash cleared, got %q", updatedEV.flash) + } +} diff --git a/cmd/mxcli/tui/overlayview.go b/cmd/mxcli/tui/overlayview.go index 0e6a025..eaf3a6b 100644 --- a/cmd/mxcli/tui/overlayview.go +++ b/cmd/mxcli/tui/overlayview.go @@ -86,11 +86,11 @@ func (ov OverlayView) Update(msg tea.Msg) (View, tea.Cmd) { } // Render returns the overlay's rendered string at the given dimensions with an LLM anchor prefix. +// OverlayView uses a value receiver: dimensions are set on the local copy +// so that View() picks them up within this call. The original is unaffected. func (ov OverlayView) Render(width, height int) string { - if ov.overlay.width != width || ov.overlay.height != height { - ov.overlay.width = width - ov.overlay.height = height - } + ov.overlay.width = width + ov.overlay.height = height rendered := ov.overlay.View() // Embed LLM anchor as muted prefix on the first line diff --git a/cmd/mxcli/tui/viewstack.go b/cmd/mxcli/tui/viewstack.go index 1dae8f8..29377da 100644 --- a/cmd/mxcli/tui/viewstack.go +++ b/cmd/mxcli/tui/viewstack.go @@ -49,3 +49,22 @@ func (vs *ViewStack) SetActive(v View) { vs.base = v } } + +// Base returns the base view (always present, never popped). +func (vs *ViewStack) Base() View { + return vs.base +} + +// SetBase replaces the base view. +func (vs *ViewStack) SetBase(v View) { + vs.base = v +} + +// ModeNames returns the mode names for all views (base + stacked) in order. +func (vs *ViewStack) ModeNames() []string { + names := []string{vs.base.Mode().String()} + for _, v := range vs.stack { + names = append(names, v.Mode().String()) + } + return names +} diff --git a/cmd/mxcli/tui/viewstack_test.go b/cmd/mxcli/tui/viewstack_test.go index db23909..e81d9f5 100644 --- a/cmd/mxcli/tui/viewstack_test.go +++ b/cmd/mxcli/tui/viewstack_test.go @@ -117,3 +117,46 @@ func TestSetActive_EmptyStack_ReplacesBase(t *testing.T) { t.Errorf("Depth() = %d, want 1", got) } } + +func TestBase_ReturnsBaseView(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + vs.Push(mockView{mode: ModeOverlay}) + + if got := vs.Base(); got.Mode() != ModeBrowser { + t.Errorf("Base() = %v, want %v", got.Mode(), ModeBrowser) + } +} + +func TestSetBase_ReplacesBaseView(t *testing.T) { + base := mockView{mode: ModeBrowser} + vs := NewViewStack(base) + vs.Push(mockView{mode: ModeOverlay}) + + vs.SetBase(mockView{mode: ModeCompare}) + + if got := vs.Base(); got.Mode() != ModeCompare { + t.Errorf("Base() = %v, want %v", got.Mode(), ModeCompare) + } + // Active should still be the stacked view + if got := vs.Active(); got.Mode() != ModeOverlay { + t.Errorf("Active() = %v, want %v (stack unaffected)", got.Mode(), ModeOverlay) + } +} + +func TestModeNames(t *testing.T) { + vs := NewViewStack(mockView{mode: ModeBrowser}) + vs.Push(mockView{mode: ModeDiff}) + vs.Push(mockView{mode: ModeOverlay}) + + names := vs.ModeNames() + if len(names) != 3 { + t.Fatalf("ModeNames() len = %d, want 3", len(names)) + } + expected := []ViewMode{ModeBrowser, ModeDiff, ModeOverlay} + for i, exp := range expected { + if names[i] != exp.String() { + t.Errorf("ModeNames()[%d] = %q, want %q", i, names[i], exp.String()) + } + } +} diff --git a/cmd/mxcli/tui/watcher.go b/cmd/mxcli/tui/watcher.go index 9041ae7..fbfd7cc 100644 --- a/cmd/mxcli/tui/watcher.go +++ b/cmd/mxcli/tui/watcher.go @@ -94,6 +94,7 @@ func (w *Watcher) run(sender MsgSender) { continue } ext := filepath.Ext(event.Name) + // Allow .mpr, .mxunit, and extensionless files (MPR v2 mprcontents/ hash files). if ext != ".mpr" && ext != ".mxunit" && ext != "" { continue } From 0e70bf843ea6783dc57ffb6fec4ec0d88645092e Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 21:14:08 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(tui):=20address=20PR=20#27=20review?= =?UTF-8?q?=20=E2=80=94=20overlay=20switching,=20search,=20dead=20code=20c?= =?UTF-8?q?leanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: fix overlay tab switching by passing OverlayViewOpts through OpenOverlayMsg - Critical: fix PlainDiff search by making buildMatchLines mode-aware - Critical: fix compare hint key d → D - Moderate: fix CJK wide-char handling in hslice/truncateToWidth with runewidth - Moderate: replace 10 nil-cmd goroutines with shared handledCmd variable - Moderate: remove redundant CompareView.visible field (ViewStack handles visibility) - Dead code: remove overlayQName/overlayNodeType/overlayIsNDSL fields - Dead code: remove unreachable "c"/"r" key cases in BrowserView.handleKey - Dead code: remove unused compareItems field and assignments - Minor: extract magic numbers to horizontalScrollStep / previewDebounceDelay constants - Minor: fix pluralization bug ("indexs" → "indexes") with irregularPlurals map - Minor: deduplicate loadBsonNDSL into shared package-level function --- cmd/mxcli/tui/app.go | 62 +++++++++++++++++++++++++-------- cmd/mxcli/tui/browserview.go | 67 +++++++++++------------------------- cmd/mxcli/tui/compare.go | 30 +--------------- cmd/mxcli/tui/diffrender.go | 9 +++-- cmd/mxcli/tui/diffview.go | 24 +++++++++---- cmd/mxcli/tui/hintbar.go | 2 +- cmd/mxcli/tui/miller.go | 6 +++- cmd/mxcli/tui/tab.go | 1 + 8 files changed, 100 insertions(+), 101 deletions(-) diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 0eacc45..de9e299 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -14,6 +14,11 @@ import ( // chromeHeight is the vertical space consumed by tab bar (1) + hint bar (1) + status bar (1). const chromeHeight = 3 +// handledCmd is returned by handleBrowserAppKeys to signal that a key was +// consumed without producing a follow-up message. Using a shared variable +// avoids allocating a new closure on every handled keystroke. +var handledCmd tea.Cmd = func() tea.Msg { return nil } + // compareFlashClearMsg is sent 1 s after a clipboard copy in compare view. type compareFlashClearMsg struct{} @@ -118,7 +123,6 @@ func (a *App) syncBrowserView() { } bv := NewBrowserView(tab, a.mxcliPath, a.previewEngine) bv.allNodes = tab.AllNodes - bv.compareItems = flattenQualifiedNames(tab.AllNodes) // Ensure miller has current dimensions so scroll calculations in // Update() work correctly (Render operates on a value copy). if a.height > 0 { @@ -162,7 +166,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // --- View creation messages --- case OpenOverlayMsg: - ov := NewOverlayView(msg.Title, msg.Content, a.width, a.height, OverlayViewOpts{}) + ov := NewOverlayView(msg.Title, msg.Content, a.width, a.height, msg.Opts) a.views.Push(ov) return a, nil @@ -442,7 +446,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update browser view if it's the base if bv, ok := a.views.Base().(BrowserView); ok { bv.allNodes = msg.Nodes - bv.compareItems = flattenQualifiedNames(msg.Nodes) bv.miller = tab.Miller if a.height > 0 { contentH := max(5, a.height-chromeHeight) @@ -524,14 +527,14 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.syncBrowserView() a.syncTabBar() } - return func() tea.Msg { return nil } + return handledCmd case "T": p := NewEmbeddedPicker() p.width = a.width p.height = a.height a.picker = &p - return func() tea.Msg { return nil } + return handledCmd case "W": if len(a.tabs) > 1 { @@ -543,7 +546,7 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.syncBrowserView() a.syncTabBar() } - return func() tea.Msg { return nil } + return handledCmd case "1", "2", "3", "4", "5", "6", "7", "8", "9": idx := int(msg.String()[0]-'0') - 1 @@ -552,7 +555,7 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.syncBrowserView() a.syncTabBar() } - return func() tea.Msg { return nil } + return handledCmd case "[": if a.activeTab > 0 { @@ -560,7 +563,7 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.syncBrowserView() a.syncTabBar() } - return func() tea.Msg { return nil } + return handledCmd case "]": if a.activeTab < len(a.tabs)-1 { @@ -568,7 +571,7 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.syncBrowserView() a.syncTabBar() } - return func() tea.Msg { return nil } + return handledCmd case "r": return a.Init() @@ -579,18 +582,18 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { jumper := NewJumperView(items, a.width, a.height) a.views.Push(jumper) } - return func() tea.Msg { return nil } + return handledCmd case "x": ev := NewExecView(a.mxcliPath, a.activeTabProjectPath(), a.width, a.height) a.views.Push(ev) - return func() tea.Msg { return nil } + return handledCmd case "!", "\\!": content := renderCheckResults(a.checkErrors) ov := NewOverlayView("mx check", content, a.width, a.height, OverlayViewOpts{HideLineNumbers: true}) a.views.Push(ov) - return func() tea.Msg { return nil } + return handledCmd case "c": cv := NewCompareView() @@ -606,7 +609,7 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { } } a.views.Push(cv) - return func() tea.Msg { return nil } + return handledCmd } return nil @@ -740,6 +743,12 @@ type CmdResultMsg struct { Err error } +// irregularPlurals maps singular type names to their correct plural forms +// for types where simply appending "s" produces incorrect English. +var irregularPlurals = map[string]string{ + "Index": "indexes", +} + // renderContextSummary counts top-level node types and returns a compact summary. func renderContextSummary(nodes []*TreeNode) string { if len(nodes) == 0 { @@ -772,7 +781,11 @@ func renderContextSummary(nodes []*TreeNode) string { // Add remaining types not in the predefined order for k, c := range counts { if !used[k] { - parts = append(parts, fmt.Sprintf("%d %s", c, strings.ToLower(k)+"s")) + plural, ok := irregularPlurals[k] + if !ok { + plural = strings.ToLower(k) + "s" + } + parts = append(parts, fmt.Sprintf("%d %s", c, plural)) } } if len(parts) > 3 { @@ -797,3 +810,24 @@ func inferBsonType(nodeType string) string { return "" } } + +// loadBsonNDSL runs mxcli bson dump in NDSL format and returns a CompareLoadMsg. +// Shared by BrowserView and CompareView to avoid duplicate implementations. +func loadBsonNDSL(mxcliPath, projectPath, qname, nodeType string, side CompareFocus) tea.Cmd { + return func() tea.Msg { + bsonType := inferBsonType(nodeType) + if bsonType == "" { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, + Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType), + Err: fmt.Errorf("unsupported type")} + } + args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", + "--type", bsonType, "--object", qname} + out, err := runMxcli(mxcliPath, args...) + out = StripBanner(out) + if err != nil { + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} + } + return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)} + } +} diff --git a/cmd/mxcli/tui/browserview.go b/cmd/mxcli/tui/browserview.go index 68a91df..7de7be6 100644 --- a/cmd/mxcli/tui/browserview.go +++ b/cmd/mxcli/tui/browserview.go @@ -16,15 +16,10 @@ type BrowserView struct { miller MillerView tab *Tab allNodes []*TreeNode - compareItems []PickerItem mxcliPath string projectPath string previewEngine *PreviewEngine - // Overlay state for NDSL/MDL tab switching context - overlayQName string - overlayNodeType string - overlayIsNDSL bool } // NewBrowserView creates a BrowserView wrapping the Miller view from the given tab. @@ -125,10 +120,7 @@ func (bv BrowserView) handleKey(msg tea.KeyMsg) (View, tea.Cmd) { node := bv.miller.SelectedNode() if node != nil && node.QualifiedName != "" { if bsonType := inferBsonType(node.Type); bsonType != "" { - bv.overlayQName = node.QualifiedName - bv.overlayNodeType = node.Type - bv.overlayIsNDSL = true - return bv, bv.runBsonOverlay(bsonType, node.QualifiedName) + return bv, bv.runBsonOverlay(bsonType, node.QualifiedName, node.Type) } } return bv, nil @@ -136,21 +128,10 @@ func (bv BrowserView) handleKey(msg tea.KeyMsg) (View, tea.Cmd) { case "m": node := bv.miller.SelectedNode() if node != nil && node.QualifiedName != "" { - bv.overlayQName = node.QualifiedName - bv.overlayNodeType = node.Type - bv.overlayIsNDSL = false return bv, bv.runMDLOverlay(node.Type, node.QualifiedName) } return bv, nil - case "c": - node := bv.miller.SelectedNode() - var loadCmd tea.Cmd - if node != nil && node.QualifiedName != "" { - loadCmd = bv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft) - } - return bv, loadCmd - case "d": node := bv.miller.SelectedNode() if node != nil && node.QualifiedName != "" { @@ -165,10 +146,6 @@ func (bv BrowserView) handleKey(msg tea.KeyMsg) (View, tea.Cmd) { } return bv, nil - case "r": - // Return nil — App handles refresh via Init() - return bv, nil - case "z": bv.miller.zenMode = !bv.miller.zenMode bv.miller.relayout() @@ -190,9 +167,21 @@ func (bv BrowserView) handleKey(msg tea.KeyMsg) (View, tea.Cmd) { // --- Load helpers (moved from app.go) --- -func (bv BrowserView) runBsonOverlay(bsonType, qname string) tea.Cmd { +func (bv BrowserView) overlayOpts(qname, nodeType string, isNDSL bool) OverlayViewOpts { + return OverlayViewOpts{ + QName: qname, + NodeType: nodeType, + IsNDSL: isNDSL, + Switchable: true, + MxcliPath: bv.mxcliPath, + ProjectPath: bv.projectPath, + } +} + +func (bv BrowserView) runBsonOverlay(bsonType, qname, nodeType string) tea.Cmd { mxcliPath := bv.mxcliPath projectPath := bv.projectPath + opts := bv.overlayOpts(qname, nodeType, true) return func() tea.Msg { args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", "--type", bsonType, "--object", qname} @@ -200,45 +189,29 @@ func (bv BrowserView) runBsonOverlay(bsonType, qname string) tea.Cmd { out = StripBanner(out) title := fmt.Sprintf("BSON: %s", qname) if err != nil { - return OpenOverlayMsg{Title: title, Content: "Error: " + out} + return OpenOverlayMsg{Title: title, Content: "Error: " + out, Opts: opts} } - return OpenOverlayMsg{Title: title, Content: HighlightNDSL(out)} + return OpenOverlayMsg{Title: title, Content: HighlightNDSL(out), Opts: opts} } } func (bv BrowserView) runMDLOverlay(nodeType, qname string) tea.Cmd { mxcliPath := bv.mxcliPath projectPath := bv.projectPath + opts := bv.overlayOpts(qname, nodeType, false) return func() tea.Msg { out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname)) out = StripBanner(out) title := fmt.Sprintf("MDL: %s", qname) if err != nil { - return OpenOverlayMsg{Title: title, Content: "Error: " + out} + return OpenOverlayMsg{Title: title, Content: "Error: " + out, Opts: opts} } - return OpenOverlayMsg{Title: title, Content: DetectAndHighlight(out)} + return OpenOverlayMsg{Title: title, Content: DetectAndHighlight(out), Opts: opts} } } func (bv BrowserView) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd { - mxcliPath := bv.mxcliPath - projectPath := bv.projectPath - return func() tea.Msg { - bsonType := inferBsonType(nodeType) - if bsonType == "" { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, - Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType), - Err: fmt.Errorf("unsupported type")} - } - args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", - "--type", bsonType, "--object", qname} - out, err := runMxcli(mxcliPath, args...) - out = StripBanner(out) - if err != nil { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} - } - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)} - } + return loadBsonNDSL(bv.mxcliPath, bv.projectPath, qname, nodeType, side) } // navigateToNode resets the miller view to root and drills down to the node diff --git a/cmd/mxcli/tui/compare.go b/cmd/mxcli/tui/compare.go index 6c56261..b974352 100644 --- a/cmd/mxcli/tui/compare.go +++ b/cmd/mxcli/tui/compare.go @@ -74,7 +74,6 @@ func (p comparePane) lineInfo() string { // CompareView is a side-by-side comparison overlay (lazygit-style). type CompareView struct { - visible bool kind CompareKind focus CompareFocus left comparePane @@ -107,7 +106,6 @@ func NewCompareView() CompareView { } func (c *CompareView) Show(kind CompareKind, w, h int) { - c.visible = true c.kind = kind c.focus = CompareFocusLeft c.width = w @@ -182,10 +180,6 @@ func (c *CompareView) closePicker() { c.picker = false; c.pickerInput.Blur() } // --- Update --- func (c CompareView) updateInternal(msg tea.Msg) (CompareView, tea.Cmd) { - if !c.visible { - return c, nil - } - switch msg := msg.(type) { case tea.KeyMsg: if c.picker { @@ -247,7 +241,6 @@ func (c CompareView) updateNormal(msg tea.KeyMsg) (CompareView, tea.Cmd) { switch msg.String() { case "esc", "q": - c.visible = false return c, func() tea.Msg { return PopViewMsg{} } // Focus switching — lazygit style: Tab only @@ -357,10 +350,6 @@ func (c *CompareView) syncOtherPane() { // --- View --- func (c CompareView) View() string { - if !c.visible { - return "" - } - pw, _ := c.paneDimensions() // Pane rendering @@ -598,24 +587,7 @@ func (c CompareView) Mode() ViewMode { // loadBsonNDSL loads BSON NDSL content for a compare pane. func (c CompareView) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd { - mxcliPath := c.mxcliPath - projectPath := c.projectPath - return func() tea.Msg { - bsonType := inferBsonType(nodeType) - if bsonType == "" { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, - Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType), - Err: fmt.Errorf("unsupported type")} - } - args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl", - "--type", bsonType, "--object", qname} - out, err := runMxcli(mxcliPath, args...) - out = StripBanner(out) - if err != nil { - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err} - } - return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)} - } + return loadBsonNDSL(c.mxcliPath, c.projectPath, qname, nodeType, side) } // loadMDL loads MDL content for a compare pane. diff --git a/cmd/mxcli/tui/diffrender.go b/cmd/mxcli/tui/diffrender.go index c2e9d9b..f27c7d0 100644 --- a/cmd/mxcli/tui/diffrender.go +++ b/cmd/mxcli/tui/diffrender.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) @@ -301,7 +302,8 @@ func hslice(s string, skip, take int) string { } continue } - visW++ + rw := runewidth.RuneWidth(r) + visW += rw if visW <= skip { continue } @@ -335,10 +337,11 @@ func truncateToWidth(s string, maxW int) string { } continue } - visW++ - if visW > maxW { + rw := runewidth.RuneWidth(r) + if visW+rw > maxW { break } + visW += rw result.WriteRune(r) } return result.String() diff --git a/cmd/mxcli/tui/diffview.go b/cmd/mxcli/tui/diffview.go index 4a5bb46..e757978 100644 --- a/cmd/mxcli/tui/diffview.go +++ b/cmd/mxcli/tui/diffview.go @@ -18,6 +18,9 @@ const ( DiffViewPlainDiff // standard unified diff text (LLM-friendly) ) +// horizontalScrollStep is the number of columns to shift per horizontal scroll event. +const horizontalScrollStep = 8 + // DiffOpenMsg requests opening a diff view. type DiffOpenMsg struct { OldText string @@ -166,9 +169,9 @@ func (dv DiffView) updateInternal(msg tea.Msg) (DiffView, tea.Cmd) { case tea.MouseButtonWheelDown: dv.scroll(3) case tea.MouseButtonWheelLeft: - dv.xOffset = max(0, dv.xOffset-8) + dv.xOffset = max(0, dv.xOffset-horizontalScrollStep) case tea.MouseButtonWheelRight: - dv.xOffset += 8 + dv.xOffset += horizontalScrollStep } } @@ -242,9 +245,9 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { // Horizontal scroll case "h", "left": - dv.xOffset = max(0, dv.xOffset-8) + dv.xOffset = max(0, dv.xOffset-horizontalScrollStep) case "l", "right": - dv.xOffset += 8 + dv.xOffset += horizontalScrollStep // View mode toggle: Unified → Side-by-Side → Plain Diff → Unified case "tab": @@ -258,6 +261,8 @@ func (dv DiffView) updateNormal(msg tea.KeyMsg) (DiffView, tea.Cmd) { } dv.yOffset = 0 dv.xOffset = 0 + // Rebuild search matches for the new mode (indices differ between modes) + dv.buildMatchLines() // Yank unified diff to clipboard case "y": @@ -345,8 +350,15 @@ func (dv *DiffView) buildMatchLines() { return } q := strings.ToLower(dv.searchQuery) - // Search in the raw content of DiffResult lines (not rendered) - if dv.result != nil { + if dv.viewMode == DiffViewPlainDiff { + // In PlainDiff mode, search against plainLines (which have different indices) + for i, line := range dv.plainLines { + if strings.Contains(strings.ToLower(line), q) { + dv.matchLines = append(dv.matchLines, i) + } + } + } else if dv.result != nil { + // Unified and Side-by-Side: search DiffResult lines (1:1 index mapping) for i, dl := range dv.result.Lines { if strings.Contains(strings.ToLower(dl.Content), q) { dv.matchLines = append(dv.matchLines, i) diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index feb96e6..fb3d7d8 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -62,7 +62,7 @@ var ( {Key: "/", Label: "search"}, {Key: "s", Label: "sync scroll"}, {Key: "1/2/3", Label: "mode"}, - {Key: "d", Label: "diff"}, + {Key: "D", Label: "diff"}, {Key: "q", Label: "close"}, } ExecViewHints = []Hint{ diff --git a/cmd/mxcli/tui/miller.go b/cmd/mxcli/tui/miller.go index bacb358..5b56d4f 100644 --- a/cmd/mxcli/tui/miller.go +++ b/cmd/mxcli/tui/miller.go @@ -20,6 +20,10 @@ const ( minCurrentWidth = 15 ) +// previewDebounceDelay is the delay before requesting a preview for a leaf node, +// preventing subprocess flooding during rapid cursor movement. +const previewDebounceDelay = 150 * time.Millisecond + // MillerFocus indicates which pane has keyboard focus. type MillerFocus int @@ -229,7 +233,7 @@ func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.C counter := m.debounceCounter if node.QualifiedName != "" && node.Type != "" { - return m, tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { + return m, tea.Tick(previewDebounceDelay, func(t time.Time) tea.Msg { return previewDebounceMsg{node: node, counter: counter} }) } diff --git a/cmd/mxcli/tui/tab.go b/cmd/mxcli/tui/tab.go index 7e625ee..df63dae 100644 --- a/cmd/mxcli/tui/tab.go +++ b/cmd/mxcli/tui/tab.go @@ -16,6 +16,7 @@ type LoadTreeMsg struct { type OpenOverlayMsg struct { Title string Content string + Opts OverlayViewOpts // optional context for tab-switching, etc. } // OpenImageOverlayMsg requests a full-size image overlay for a list of image paths. From 2085e9d1962c0d0a718b2175e14654393c39bd0e Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 25 Mar 2026 22:10:31 +0800 Subject: [PATCH 10/10] fix(tui): code review fixes + check overlay rerun with JSON output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: fix Watcher.Close() race condition with sync.Once - Fix renderCheckResults nil/empty ambiguity (nil = no check run yet) - Fix fsnotify error value being discarded in watcher - Add value-receiver intent comments on ExecView.Render - Add terminal compatibility comment for "\\!" key binding - Document ResolveMx lexicographic version sort limitation Check overlay improvements: - Add "r" key to rerun mx check from within the overlay - Switch from text parsing to mx check -j JSON output for richer location info (document-name, element-name, module-name) - Format locations as qualified names: MyModule.PageName (Page) - Live-update overlay content during check run (spinner → results) - Add refreshable/RefreshMsg plumbing to OverlayView and Overlay --- cmd/mxcli/docker/check.go | 6 +- cmd/mxcli/tui/app.go | 24 ++++- cmd/mxcli/tui/checker.go | 163 +++++++++++++++++++++-------- cmd/mxcli/tui/checker_test.go | 192 +++++++++++++++++++++++++++------- cmd/mxcli/tui/execview.go | 2 + cmd/mxcli/tui/help.go | 1 + cmd/mxcli/tui/overlay.go | 4 + cmd/mxcli/tui/overlayview.go | 15 +++ cmd/mxcli/tui/watcher.go | 18 ++-- 9 files changed, 332 insertions(+), 93 deletions(-) diff --git a/cmd/mxcli/docker/check.go b/cmd/mxcli/docker/check.go index 7326d54..6763088 100644 --- a/cmd/mxcli/docker/check.go +++ b/cmd/mxcli/docker/check.go @@ -102,11 +102,13 @@ func ResolveMx(mxbuildPath string) (string, error) { return p, nil } - // Try cached mxbuild installations (~/.mxcli/mxbuild/*/modeler/mx) + // Try cached mxbuild installations (~/.mxcli/mxbuild/*/modeler/mx). + // NOTE: lexicographic sort is imperfect for versions (e.g. "9.x" > "10.x"), + // but this is a fallback-of-last-resort — in practice users typically have + // only one mxbuild version installed. if home, err := os.UserHomeDir(); err == nil { matches, _ := filepath.Glob(filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxBinaryName())) if len(matches) > 0 { - // Use the last match (highest version when sorted lexicographically) return matches[len(matches)-1], nil } } diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index de9e299..1a03b75 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -463,8 +463,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { projectPath := a.activeTabProjectPath() return a, tea.Batch(a.Init(), runMxCheck(projectPath)) + case MxCheckRerunMsg: + Trace("app: manual mx check rerun requested") + projectPath := a.activeTabProjectPath() + return a, runMxCheck(projectPath) + case MxCheckStartMsg: a.checkRunning = true + // Update check overlay content if it's currently visible + if ov, ok := a.views.Active().(OverlayView); ok && ov.refreshable { + ov.overlay.Show("mx check", CheckRunningStyle.Render("⟳ Running mx check..."), ov.overlay.width, ov.overlay.height) + a.views.SetActive(ov) + } return a, nil case MxCheckResultMsg: @@ -476,6 +486,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.checkErrors = msg.Errors Trace("app: mx check done: %d diagnostics", len(msg.Errors)) } + // Update check overlay content if it's currently visible + if ov, ok := a.views.Active().(OverlayView); ok && ov.refreshable { + content := renderCheckResults(a.checkErrors) + ov.overlay.Show("mx check", content, ov.overlay.width, ov.overlay.height) + a.views.SetActive(ov) + } return a, nil case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg, previewDebounceMsg: @@ -589,9 +605,13 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { a.views.Push(ev) return handledCmd - case "!", "\\!": + case "!", "\\!": // some terminals send "\\!" for shifted-1; accept both forms content := renderCheckResults(a.checkErrors) - ov := NewOverlayView("mx check", content, a.width, a.height, OverlayViewOpts{HideLineNumbers: true}) + ov := NewOverlayView("mx check", content, a.width, a.height, OverlayViewOpts{ + HideLineNumbers: true, + Refreshable: true, + RefreshMsg: MxCheckRerunMsg{}, + }) a.views.Push(ov) return handledCmd diff --git a/cmd/mxcli/tui/checker.go b/cmd/mxcli/tui/checker.go index ad2323f..d4a44c0 100644 --- a/cmd/mxcli/tui/checker.go +++ b/cmd/mxcli/tui/checker.go @@ -1,9 +1,9 @@ package tui import ( - "bufio" + "encoding/json" + "os" "os/exec" - "regexp" "strings" tea "github.com/charmbracelet/bubbletea" @@ -13,10 +13,12 @@ import ( // CheckError represents a single mx check diagnostic. type CheckError struct { - Severity string // "ERROR" or "WARNING" - Code string // e.g. "CE0001" - Message string - Location string // e.g. "Module.Microflow" (may be empty) + Severity string // "ERROR" or "WARNING" + Code string // e.g. "CE0001" + Message string + DocumentName string // e.g. "Page 'P_ComboBox_Enum'" (from JSON output) + ElementName string // e.g. "Property 'Association' of combo box 'cmbPriority'" + ModuleName string // e.g. "MyFirstModule" } // MxCheckResultMsg carries the result of an async mx check run. @@ -28,12 +30,29 @@ type MxCheckResultMsg struct { // MxCheckStartMsg signals that a check run has started. type MxCheckStartMsg struct{} -// checkOutputPattern matches mx check output lines like: -// [error] [CE1613] "The selected association no longer exists." at Combo box 'cmbPriority' -// [warning] [CW0001] "Some warning" at Page 'MyPage' -var checkOutputPattern = regexp.MustCompile(`^\[(error|warning)\]\s+\[(\w+)\]\s+"(.+?)"\s+at\s+(.+?)\s*$`) +// MxCheckRerunMsg requests a manual re-run of mx check (e.g. from overlay "r" key). +type MxCheckRerunMsg struct{} + +// mxCheckJSON mirrors the JSON structure produced by `mx check -j`. +type mxCheckJSON struct { + Errors []mxCheckEntry `json:"errors"` + Warnings []mxCheckEntry `json:"warnings"` +} + +type mxCheckEntry struct { + Code string `json:"code"` + Message string `json:"message"` + Locations []mxCheckLocation `json:"locations"` +} + +type mxCheckLocation struct { + ModuleName string `json:"module-name"` + DocumentName string `json:"document-name"` + ElementName string `json:"element-name"` +} // runMxCheck returns a tea.Cmd that runs mx check asynchronously. +// Uses `-j` for JSON output to get document-level location information. func runMxCheck(projectPath string) tea.Cmd { return tea.Batch( func() tea.Msg { return MxCheckStartMsg{} }, @@ -44,47 +63,82 @@ func runMxCheck(projectPath string) tea.Cmd { return MxCheckResultMsg{Err: err} } - Trace("checker: running %s check %s", mxPath, projectPath) - cmd := exec.Command(mxPath, "check", projectPath) - out, err := cmd.CombinedOutput() - output := string(out) + jsonFile, err := os.CreateTemp("", "mx-check-*.json") + if err != nil { + return MxCheckResultMsg{Err: err} + } + jsonPath := jsonFile.Name() + jsonFile.Close() + defer os.Remove(jsonPath) + + Trace("checker: running %s check %s -j %s", mxPath, projectPath, jsonPath) + cmd := exec.Command(mxPath, "check", projectPath, "-j", jsonPath) + _, runErr := cmd.CombinedOutput() + + checkErrors, parseErr := parseCheckJSON(jsonPath) + if parseErr != nil { + Trace("checker: JSON parse error: %v", parseErr) + // If JSON parsing fails, return the run error + if runErr != nil { + return MxCheckResultMsg{Err: runErr} + } + return MxCheckResultMsg{Err: parseErr} + } - errors := parseCheckOutput(output) - Trace("checker: done, %d diagnostics, err=%v", len(errors), err) + Trace("checker: done, %d diagnostics", len(checkErrors)) // mx check returns non-zero exit code when there are errors, - // but we still want to show the parsed errors — only propagate - // err if we got no parseable output at all. - if err != nil && len(errors) == 0 { - return MxCheckResultMsg{Err: err} + // but we still want to show the parsed errors. + if runErr != nil && len(checkErrors) == 0 { + return MxCheckResultMsg{Err: runErr} } - return MxCheckResultMsg{Errors: errors} + return MxCheckResultMsg{Errors: checkErrors} }, ) } -// parseCheckOutput extracts CheckError entries from mx check stdout. -func parseCheckOutput(output string) []CheckError { - var errors []CheckError - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - matches := checkOutputPattern.FindStringSubmatch(line) - if matches == nil { - continue - } - errors = append(errors, CheckError{ - Severity: strings.ToUpper(matches[1]), - Code: matches[2], - Message: matches[3], - Location: matches[4], - }) - } - return errors +// parseCheckJSON reads the JSON file produced by `mx check -j` and converts to CheckError slice. +func parseCheckJSON(jsonPath string) ([]CheckError, error) { + data, err := os.ReadFile(jsonPath) + if err != nil { + return nil, err + } + + var result mxCheckJSON + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + var checkErrors []CheckError + for _, entry := range result.Errors { + checkErrors = append(checkErrors, entryToCheckError(entry, "ERROR")) + } + for _, entry := range result.Warnings { + checkErrors = append(checkErrors, entryToCheckError(entry, "WARNING")) + } + return checkErrors, nil +} + +func entryToCheckError(entry mxCheckEntry, severity string) CheckError { + ce := CheckError{ + Severity: severity, + Code: entry.Code, + Message: entry.Message, + } + if len(entry.Locations) > 0 { + loc := entry.Locations[0] + ce.ModuleName = loc.ModuleName + ce.DocumentName = loc.DocumentName + ce.ElementName = loc.ElementName + } + return ce } // renderCheckResults formats check errors for display in an overlay. func renderCheckResults(errors []CheckError) string { + if errors == nil { + return "No check has been run yet. Changes to the project will trigger an automatic check." + } if len(errors) == 0 { return CheckPassStyle.Render("✓ Project check passed — no errors or warnings") } @@ -112,7 +166,7 @@ func renderCheckResults(errors []CheckError) string { sb.WriteString(strings.Join(summaryParts, " ")) sb.WriteString("\n\n") - // Detail lines — severity+code on first line, message and location on next + // Detail lines for _, e := range errors { var label string if e.Severity == "ERROR" { @@ -122,14 +176,38 @@ func renderCheckResults(errors []CheckError) string { } sb.WriteString(label + "\n") sb.WriteString(" " + e.Message + "\n") - if e.Location != "" { - sb.WriteString(" " + CheckLocStyle.Render("at "+e.Location) + "\n") + if e.DocumentName != "" { + loc := formatDocLocation(e.ModuleName, e.DocumentName) + if e.ElementName != "" { + loc += " > " + e.ElementName + } + sb.WriteString(" " + CheckLocStyle.Render(loc) + "\n") } sb.WriteString("\n") } return sb.String() } +// formatDocLocation converts mx JSON document-name (e.g. "Page 'P_ComboBox'") +// into a qualified name like "MyModule.P_ComboBox (Page)". +func formatDocLocation(moduleName, documentName string) string { + // documentName format: "Type 'Name'" — extract type and name + if idx := strings.Index(documentName, " '"); idx > 0 { + docType := documentName[:idx] + docName := strings.TrimSuffix(documentName[idx+2:], "'") + qname := docName + if moduleName != "" { + qname = moduleName + "." + docName + } + return qname + " (" + docType + ")" + } + // Fallback: just prefix with module + if moduleName != "" { + return moduleName + "." + documentName + } + return documentName +} + // formatCheckBadge returns a compact badge string for the status bar. func formatCheckBadge(errors []CheckError, running bool) string { if running { @@ -158,4 +236,3 @@ func formatCheckBadge(errors []CheckError, running bool) string { } return strings.Join(parts, " ") } - diff --git a/cmd/mxcli/tui/checker_test.go b/cmd/mxcli/tui/checker_test.go index 1dc66f7..10e057a 100644 --- a/cmd/mxcli/tui/checker_test.go +++ b/cmd/mxcli/tui/checker_test.go @@ -1,20 +1,65 @@ package tui -import "testing" - -func TestParseCheckOutput(t *testing.T) { - input := `Checking your app for issues... -Checking the version of the mpr file. -The mpr file version is '11.6.4'. -Loading the mpr file. -Checking app for errors... -[error] [CE1613] "The selected association 'MyModule.Priority' no longer exists." at Combo box 'cmbPriority' -[warning] [CW0001] "Unused variable '$var' in microflow" at Microflow 'MyModule.DoSomething' -[error] [CE0463] "Widget definition changed for DataGrid2" at Page 'MyModule.CustomerList' -The app contains: 2 errors. -` - - errors := parseCheckOutput(input) +import ( + "os" + "strings" + "testing" +) + +func TestParseCheckJSON(t *testing.T) { + jsonContent := `{ + "serialization_version": 1, + "errors": [ + { + "code": "CE1613", + "message": "The selected association 'MyModule.Priority' no longer exists.", + "locations": [ + { + "module-name": "MyModule", + "document-name": "Page 'P_ComboBox'", + "element-name": "Property 'Association' of combo box 'cmbPriority'" + } + ] + }, + { + "code": "CE0463", + "message": "Widget definition changed for DataGrid2", + "locations": [ + { + "module-name": "MyModule", + "document-name": "Page 'CustomerList'", + "element-name": "DataGrid2 widget" + } + ] + } + ], + "warnings": [ + { + "code": "CW0001", + "message": "Unused variable '$var' in microflow", + "locations": [ + { + "module-name": "MyModule", + "document-name": "Microflow 'DoSomething'", + "element-name": "Variable '$var'" + } + ] + } + ] + }` + + tmpFile, err := os.CreateTemp("", "mx-check-test-*.json") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString(jsonContent) + tmpFile.Close() + + errors, err := parseCheckJSON(tmpFile.Name()) + if err != nil { + t.Fatalf("parseCheckJSON: %v", err) + } if len(errors) != 3 { t.Fatalf("expected 3 errors, got %d", len(errors)) } @@ -26,39 +71,114 @@ The app contains: 2 errors. if errors[0].Code != "CE1613" { t.Errorf("expected CE1613, got %q", errors[0].Code) } - if errors[0].Location != "Combo box 'cmbPriority'" { - t.Errorf("unexpected location: %q", errors[0].Location) + if errors[0].DocumentName != "Page 'P_ComboBox'" { + t.Errorf("unexpected document: %q", errors[0].DocumentName) } - - // Second: warning - if errors[1].Severity != "WARNING" { - t.Errorf("expected WARNING, got %q", errors[1].Severity) + if errors[0].ElementName == "" { + t.Error("expected non-empty element name") } - if errors[1].Code != "CW0001" { - t.Errorf("expected CW0001, got %q", errors[1].Code) + if errors[0].ModuleName != "MyModule" { + t.Errorf("expected MyModule, got %q", errors[0].ModuleName) + } + + // Second error + if errors[1].Code != "CE0463" { + t.Errorf("expected CE0463, got %q", errors[1].Code) } - // Third: error - if errors[2].Code != "CE0463" { - t.Errorf("expected CE0463, got %q", errors[2].Code) + // Third: warning + if errors[2].Severity != "WARNING" { + t.Errorf("expected WARNING, got %q", errors[2].Severity) + } + if errors[2].Code != "CW0001" { + t.Errorf("expected CW0001, got %q", errors[2].Code) } } -func TestParseCheckOutputEmpty(t *testing.T) { - errors := parseCheckOutput("Project check passed.\n") +func TestParseCheckJSONEmpty(t *testing.T) { + tmpFile, err := os.CreateTemp("", "mx-check-test-*.json") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString(`{"serialization_version": 1}`) + tmpFile.Close() + + errors, err := parseCheckJSON(tmpFile.Name()) + if err != nil { + t.Fatalf("parseCheckJSON: %v", err) + } if len(errors) != 0 { t.Fatalf("expected 0 errors, got %d", len(errors)) } } -func TestParseCheckOutputIgnoresNonMatchingLines(t *testing.T) { - input := `Checking your app for issues... -Loading the mpr file. -The app contains: 0 errors. -` - errors := parseCheckOutput(input) - if len(errors) != 0 { - t.Fatalf("expected 0 errors from non-matching lines, got %d", len(errors)) +func TestParseCheckJSONNoLocations(t *testing.T) { + jsonContent := `{ + "serialization_version": 1, + "errors": [ + { + "code": "CE9999", + "message": "Some error without location", + "locations": [] + } + ] + }` + + tmpFile, err := os.CreateTemp("", "mx-check-test-*.json") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString(jsonContent) + tmpFile.Close() + + errors, err := parseCheckJSON(tmpFile.Name()) + if err != nil { + t.Fatalf("parseCheckJSON: %v", err) + } + if len(errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(errors)) + } + if errors[0].DocumentName != "" { + t.Errorf("expected empty document name, got %q", errors[0].DocumentName) + } +} + +func TestRenderCheckResultsNilVsEmpty(t *testing.T) { + // nil = no check has run yet + result := renderCheckResults(nil) + if result == "" { + t.Error("expected non-empty result for nil errors") + } + if strings.Contains(result, "passed") { + t.Error("nil errors should NOT show 'passed' — no check has run yet") + } + + // empty = check ran, no errors found + result = renderCheckResults([]CheckError{}) + if !strings.Contains(result, "passed") { + t.Error("empty errors should show 'passed'") + } +} + +func TestRenderCheckResultsWithDocLocation(t *testing.T) { + errors := []CheckError{ + { + Severity: "ERROR", + Code: "CE1613", + Message: "Association no longer exists", + DocumentName: "Page 'P_ComboBox'", + ElementName: "combo box 'cmbPriority'", + ModuleName: "MyModule", + }, + } + result := renderCheckResults(errors) + if !strings.Contains(result, "MyModule.P_ComboBox (Page)") { + t.Errorf("expected qualified doc location, got: %s", result) + } + if !strings.Contains(result, "combo box 'cmbPriority'") { + t.Error("expected element name in rendered output") } } diff --git a/cmd/mxcli/tui/execview.go b/cmd/mxcli/tui/execview.go index 204551c..9ef9bb5 100644 --- a/cmd/mxcli/tui/execview.go +++ b/cmd/mxcli/tui/execview.go @@ -134,6 +134,8 @@ func (ev ExecView) StatusInfo() StatusInfo { } // Render returns the ExecView rendered string. +// Value receiver is intentional: SetWidth/SetHeight mutate only this copy, +// keeping the authoritative dimensions in Update (same pattern as DiffView). func (ev ExecView) Render(width, height int) string { if ev.picking { return ev.renderPicker(width, height) diff --git a/cmd/mxcli/tui/help.go b/cmd/mxcli/tui/help.go index f9dd7f3..9267812 100644 --- a/cmd/mxcli/tui/help.go +++ b/cmd/mxcli/tui/help.go @@ -32,6 +32,7 @@ const helpText = ` / search in content y copy to clipboard Tab switch MDL / NDSL + r rerun (check overlay) q close COMPARE VIEW diff --git a/cmd/mxcli/tui/overlay.go b/cmd/mxcli/tui/overlay.go index d236a45..2995e14 100644 --- a/cmd/mxcli/tui/overlay.go +++ b/cmd/mxcli/tui/overlay.go @@ -20,6 +20,7 @@ type Overlay struct { visible bool copiedFlash bool switchable bool // Tab key switches between NDSL and MDL + refreshable bool // show "r rerun" hint width int height int } @@ -111,6 +112,9 @@ func (o Overlay) View() string { if o.switchable { hints = append(hints, keySt.Render("Tab")+" "+dimSt.Render("switch")) } + if o.refreshable { + hints = append(hints, keySt.Render("r")+" "+dimSt.Render("rerun")) + } if o.copiedFlash { hints = append(hints, successSt.Render("✓ Copied!")) } else { diff --git a/cmd/mxcli/tui/overlayview.go b/cmd/mxcli/tui/overlayview.go index eaf3a6b..e38b637 100644 --- a/cmd/mxcli/tui/overlayview.go +++ b/cmd/mxcli/tui/overlayview.go @@ -23,6 +23,8 @@ type OverlayViewOpts struct { MxcliPath string ProjectPath string HideLineNumbers bool + Refreshable bool // show "r" hint and allow re-triggering via RefreshMsg + RefreshMsg tea.Msg // message to send when "r" is pressed } // OverlayView wraps an Overlay to satisfy the View interface, @@ -35,6 +37,8 @@ type OverlayView struct { switchable bool mxcliPath string projectPath string + refreshable bool + refreshMsg tea.Msg } // NewOverlayView creates an OverlayView with the given title, content, dimensions, and options. @@ -46,9 +50,12 @@ func NewOverlayView(title, content string, width, height int, opts OverlayViewOp switchable: opts.Switchable, mxcliPath: opts.MxcliPath, projectPath: opts.ProjectPath, + refreshable: opts.Refreshable, + refreshMsg: opts.RefreshMsg, } ov.overlay = NewOverlay() ov.overlay.switchable = opts.Switchable + ov.overlay.refreshable = opts.Refreshable ov.overlay.Show(title, content, width, height) if opts.HideLineNumbers { ov.overlay.content.hideLineNumbers = true @@ -72,6 +79,11 @@ func (ov OverlayView) Update(msg tea.Msg) (View, tea.Cmd) { switch msg.String() { case "esc", "q": return ov, func() tea.Msg { return PopViewMsg{} } + case "r": + if ov.refreshable && ov.refreshMsg != nil { + refreshMsg := ov.refreshMsg + return ov, func() tea.Msg { return refreshMsg } + } case "tab": if ov.switchable && ov.qname != "" { ov.isNDSL = !ov.isNDSL @@ -116,6 +128,9 @@ func (ov OverlayView) Hints() []Hint { if ov.switchable { hints = append(hints, Hint{Key: "Tab", Label: "mdl/ndsl"}) } + if ov.refreshable { + hints = append(hints, Hint{Key: "r", Label: "rerun"}) + } hints = append(hints, Hint{Key: "q", Label: "close"}) return hints } diff --git a/cmd/mxcli/tui/watcher.go b/cmd/mxcli/tui/watcher.go index fbfd7cc..35e2de4 100644 --- a/cmd/mxcli/tui/watcher.go +++ b/cmd/mxcli/tui/watcher.go @@ -27,6 +27,7 @@ func (p programSender) Send(msg tea.Msg) { p.prog.Send(msg) } type Watcher struct { fsw *fsnotify.Watcher done chan struct{} + closeOnce sync.Once mu sync.Mutex suppressEnd time.Time } @@ -113,11 +114,11 @@ func (w *Watcher) run(sender MsgSender) { sender.Send(MprChangedMsg{}) }) - case _, ok := <-w.fsw.Errors: + case watchErr, ok := <-w.fsw.Errors: if !ok { return } - Trace("watcher: fsnotify error") + Trace("watcher: fsnotify error: %v", watchErr) } } } @@ -130,13 +131,10 @@ func (w *Watcher) Suppress(d time.Duration) { w.mu.Unlock() } -// Close stops the watcher and releases resources. +// Close stops the watcher and releases resources. Safe to call concurrently. func (w *Watcher) Close() { - select { - case <-w.done: - return - default: - } - close(w.done) - w.fsw.Close() + w.closeOnce.Do(func() { + close(w.done) + w.fsw.Close() + }) }