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..6763088 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,16 @@ func resolveMx(mxbuildPath string) (string, error) { return p, nil } + // 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 { + 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 6563c77..1a03b75 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "os" + "path/filepath" "strings" "time" @@ -13,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{} @@ -26,20 +32,18 @@ type App struct { height int mxcliPath string - overlay Overlay - compare CompareView + 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 + + watcher *Watcher + checkErrors []CheckError // nil = no check run yet, empty = pass + checkRunning bool } // NewApp creates the root App model. @@ -50,11 +54,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), @@ -65,6 +70,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] @@ -72,6 +100,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 { @@ -80,38 +116,20 @@ 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.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 + // 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.SetBase(bv) } // --- Init --- @@ -138,39 +156,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 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, msg.Opts) + a.views.Push(ov) return a, nil case OpenImageOverlayMsg: @@ -197,41 +194,137 @@ 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.SetBase(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 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, 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 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" { + if a.watcher != nil { + a.watcher.Close() + } 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) @@ -239,45 +332,49 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } - // Fullscreen modes - if a.compare.IsVisible() { - var cmd tea.Cmd - a.compare, cmd = a.compare.Update(msg) - if !a.compare.IsVisible() { - a.syncHintBar() - } - 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) - } - 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 - } + // Help toggle (global, only in Browser mode) if a.showHelp { a.showHelp = false return a, nil } + if msg.String() == "?" && a.views.Active().Mode() == ModeBrowser { + a.showHelp = !a.showHelp + return a, nil + } - return a.updateNormalMode(msg) + // 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 + } + } + + // 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, 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.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) @@ -285,24 +382,57 @@ 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 - a.resizeAll() + // 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: @@ -312,74 +442,116 @@ 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.miller = tab.Miller + if a.height > 0 { + contentH := max(5, a.height-chromeHeight) + bv.miller.SetSize(a.width, contentH) + } + a.views.SetBase(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() - return a, cmd + 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 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 CmdResultMsg: - content := msg.Output + case MxCheckResultMsg: + a.checkRunning = false if msg.Err != nil { - content = "-- Error:\n" + msg.Output + 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)) } - a.overlayQName = "" - a.overlay.switchable = false - a.overlay.Show("Result", DetectAndHighlight(content), a.width, a.height) - a.syncHintBar() - } - return a, nil -} + // 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 -func (a App) updateNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - tab := a.activeTabPtr() + case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg, previewDebounceMsg: + 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 - // 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() + default: + // Forward everything else to active view + updated, cmd := a.views.Active().Update(msg) + a.views.SetActive(updated) return a, 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() switch msg.String() { case "q": + if a.watcher != nil { + a.watcher.Close() + } 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 handledCmd + case "T": p := NewEmbeddedPicker() p.width = a.width p.height = a.height a.picker = &p - return a, nil + return handledCmd + case "W": if len(a.tabs) > 1 { a.tabs[a.activeTab].Miller.previewEngine.Cancel() @@ -387,100 +559,80 @@ 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 handledCmd + 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 handledCmd + case "[": if a.activeTab > 0 { a.activeTab-- - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return handledCmd + case "]": if a.activeTab < len(a.tabs)-1 { a.activeTab++ - a.resizeAll() + a.syncBrowserView() a.syncTabBar() - a.syncStatusBar() } - return a, nil + return handledCmd - // 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 handledCmd + + case "x": + ev := NewExecView(a.mxcliPath, a.activeTabProjectPath(), a.width, a.height) + a.views.Push(ev) + return handledCmd + + 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, + Refreshable: true, + RefreshMsg: MxCheckRerunMsg{}, + }) + a.views.Push(ov) + return handledCmd + 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 handledCmd } - // 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 { @@ -496,28 +648,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 { @@ -528,37 +665,71 @@ func (a App) View() string { if a.picker != nil { return a.picker.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) + 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)) - 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) @@ -569,37 +740,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(` @@ -616,105 +757,66 @@ 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)} - } -} - -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)} - } +// CmdResultMsg carries output from any mxcli command. +type CmdResultMsg struct { + Output string + Err error } -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) - } - return nil +// 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", } -func (a App) runBsonOverlay(bsonType, qname string) tea.Cmd { - tab := a.activeTabPtr() - if tab == nil { - return nil +// renderContextSummary counts top-level node types and returns a compact summary. +func renderContextSummary(nodes []*TreeNode) string { + if len(nodes) == 0 { + return "" } - 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} + counts := map[string]int{} + for _, n := range nodes { + counts[n.Type]++ + } + // Display in a predictable order + order := []struct { + key string + plural string + }{ + {"Module", "modules"}, + {"Entity", "entities"}, + {"Microflow", "microflows"}, + {"Page", "pages"}, + {"Nanoflow", "nanoflows"}, + {"Enumeration", "enumerations"}, + } + 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 + } + } + // Add remaining types not in the predefined order + for k, c := range counts { + if !used[k] { + plural, ok := irregularPlurals[k] + if !ok { + plural = strings.ToLower(k) + "s" + } + parts = append(parts, fmt.Sprintf("%d %s", c, plural)) } - 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} - } - 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 { + return a.views.ModeNames() } // inferBsonType maps tree node types to valid bson object types. @@ -728,3 +830,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 new file mode 100644 index 0000000..7de7be6 --- /dev/null +++ b/cmd/mxcli/tui/browserview.go @@ -0,0 +1,302 @@ +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 + mxcliPath string + projectPath string + previewEngine *PreviewEngine + +} + +// 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, previewDebounceMsg: + 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 != "" { + return bv, bv.runBsonOverlay(bsonType, node.QualifiedName, node.Type) + } + } + return bv, nil + + case "m": + node := bv.miller.SelectedNode() + if node != nil && node.QualifiedName != "" { + return bv, bv.runMDLOverlay(node.Type, node.QualifiedName) + } + return bv, nil + + 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 "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) 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} + out, err := runMxcli(mxcliPath, args...) + out = StripBanner(out) + title := fmt.Sprintf("BSON: %s", qname) + if err != nil { + return OpenOverlayMsg{Title: title, Content: "Error: " + out, Opts: opts} + } + 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, Opts: opts} + } + return OpenOverlayMsg{Title: title, Content: DetectAndHighlight(out), Opts: opts} + } +} + +func (bv BrowserView) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd { + return loadBsonNDSL(bv.mxcliPath, bv.projectPath, qname, nodeType, side) +} + +// 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/checker.go b/cmd/mxcli/tui/checker.go new file mode 100644 index 0000000..d4a44c0 --- /dev/null +++ b/cmd/mxcli/tui/checker.go @@ -0,0 +1,238 @@ +package tui + +import ( + "encoding/json" + "os" + "os/exec" + "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 + 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. +type MxCheckResultMsg struct { + Errors []CheckError + Err error +} + +// MxCheckStartMsg signals that a check run has started. +type MxCheckStartMsg struct{} + +// 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{} }, + func() tea.Msg { + mxPath, err := docker.ResolveMx("") + if err != nil { + Trace("checker: mx not found: %v", err) + return MxCheckResultMsg{Err: err} + } + + 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} + } + + 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. + if runErr != nil && len(checkErrors) == 0 { + return MxCheckResultMsg{Err: runErr} + } + return MxCheckResultMsg{Errors: checkErrors} + }, + ) +} + +// 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") + } + + 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 + 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.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 { + 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..10e057a --- /dev/null +++ b/cmd/mxcli/tui/checker_test.go @@ -0,0 +1,214 @@ +package tui + +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)) + } + + // 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].DocumentName != "Page 'P_ComboBox'" { + t.Errorf("unexpected document: %q", errors[0].DocumentName) + } + if errors[0].ElementName == "" { + t.Error("expected non-empty element name") + } + 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: 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 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 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") + } +} + +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/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 47d37bb..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 @@ -83,23 +82,19 @@ 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 + projectPath string width int height int } -type pickerMatch struct { - item PickerItem - score int -} - const pickerMaxShow = 12 func NewCompareView() CompareView { @@ -111,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 @@ -135,9 +129,7 @@ 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) SetItems(items []PickerItem) { c.pickerList = NewFuzzyList(items, pickerMaxShow) } func (c *CompareView) SetContent(side CompareFocus, title, nodeType, content string) { p := c.pane(side) @@ -178,77 +170,16 @@ 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) Update(msg tea.Msg) (CompareView, tea.Cmd) { - if !c.visible { - return c, nil - } - +func (c CompareView) updateInternal(msg tea.Msg) (CompareView, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if c.picker { @@ -278,7 +209,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 { @@ -287,13 +218,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 @@ -310,8 +241,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": @@ -338,6 +268,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() @@ -351,10 +299,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: @@ -405,10 +350,6 @@ func (c *CompareView) syncOtherPane() { // --- View --- func (c CompareView) View() string { - if !c.visible { - return "" - } - pw, _ := c.paneDimensions() // Pane rendering @@ -499,6 +440,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 { @@ -540,27 +482,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()). @@ -570,46 +514,110 @@ func (c CompareView) renderPicker() string { Render(sb.String()) } -// --- Utilities --- +// --- View interface --- -// 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 +// Update satisfies the View interface. +func (c CompareView) Update(msg tea.Msg) (View, tea.Cmd) { + updated, cmd := c.updateInternal(msg) + return updated, cmd +} + +// 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] } - 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 + leftTitle := c.left.title + if leftTitle == "" { + leftTitle = "—" + } + rightTitle := c.right.title + if rightTitle == "" { + rightTitle = "—" + } + 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 { + return loadBsonNDSL(c.mxcliPath, c.projectPath, qname, nodeType, side) +} + +// 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/contentview.go b/cmd/mxcli/tui/contentview.go index 8358dad..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 @@ -53,7 +54,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 +124,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 +158,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) { @@ -269,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-- } @@ -308,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] @@ -346,7 +334,7 @@ func (v ContentView) View() string { line = gutter + content } else { - line = strings.Repeat(" ", v.gutterW+contentW) + line = strings.Repeat(" ", effectiveGutterW+contentW) } // Scrollbar @@ -379,7 +367,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/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/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.go b/cmd/mxcli/tui/diffrender.go new file mode 100644 index 0000000..f27c7d0 --- /dev/null +++ b/cmd/mxcli/tui/diffrender.go @@ -0,0 +1,348 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + + +// 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) + 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.TerminalColor + 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. +// 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) + } + + 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 + } + rw := runewidth.RuneWidth(r) + visW += rw + 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 + } + rw := runewidth.RuneWidth(r) + if visW+rw > maxW { + break + } + visW += rw + result.WriteRune(r) + } + return result.String() +} diff --git a/cmd/mxcli/tui/diffrender_test.go b/cmd/mxcli/tui/diffrender_test.go new file mode 100644 index 0000000..6ae669b --- /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 new file mode 100644 index 0000000..e757978 --- /dev/null +++ b/cmd/mxcli/tui/diffview.go @@ -0,0 +1,694 @@ +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 + 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 + NewText string + Language string // "sql", "go", "ndsl", "" (auto-detect) + Title string +} + +// 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 + plainLines []string // standard unified diff text lines (LLM-friendly) + 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 + + // Search + searching bool + searchInput textinput.Model + 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. +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, + 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) + 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() { + 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 + } + if i == 0 || dv.result.Lines[i-1].Type == DiffEqual { + 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. +func (dv *DiffView) SetSize(w, h int) { + dv.width = w + dv.height = h +} + +func (dv DiffView) totalLines() int { + switch dv.viewMode { + case DiffViewSideBySide: + return len(dv.sideLeft) + case DiffViewPlainDiff: + return len(dv.plainLines) + default: + 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) updateInternal(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-horizontalScrollStep) + case tea.MouseButtonWheelRight: + dv.xOffset += horizontalScrollStep + } + } + + 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) { + 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{} } + + // 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 + case "G", "end": + dv.yOffset = dv.maxOffset() + + // Horizontal scroll + case "h", "left": + dv.xOffset = max(0, dv.xOffset-horizontalScrollStep) + case "l", "right": + dv.xOffset += horizontalScrollStep + + // View mode toggle: Unified → Side-by-Side → Plain Diff → Unified + case "tab": + switch dv.viewMode { + case DiffViewUnified: + dv.viewMode = DiffViewSideBySide + case DiffViewSideBySide: + dv.viewMode = DiffViewPlainDiff + case DiffViewPlainDiff: + dv.viewMode = DiffViewUnified + } + 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": + plain := RenderPlainUnifiedDiff(dv.result, "old", "new") + _ = writeClipboard(plain) + + // Search + case "/": + dv.searching = true + dv.searchInput.SetValue(dv.searchQuery) + dv.searchInput.Focus() + case "n": + dv.nextMatch() + case "N": + dv.prevMatch() + + // Hunk navigation: first key of ]c / [c sequence + case "]": + dv.pendingKey = ']' + case "[": + dv.pendingKey = '[' + } + + return dv, nil +} + +func (dv *DiffView) scroll(delta int) { + dv.yOffset = clamp(dv.yOffset+delta, 0, dv.maxOffset()) +} + +// --- 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() { + starts := dv.activeHunkStarts() + if len(starts) == 0 { + return + } + for _, hs := range starts { + if hs > dv.yOffset { + dv.yOffset = clamp(hs, 0, dv.maxOffset()) + return + } + } + dv.yOffset = clamp(starts[0], 0, dv.maxOffset()) +} + +func (dv *DiffView) prevHunk() { + starts := dv.activeHunkStarts() + if len(starts) == 0 { + return + } + 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(starts[len(starts)-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) + 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) + } + } + } +} + +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 + 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 { + 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 + switch dv.viewMode { + case DiffViewSideBySide: + content = dv.renderSideBySide(viewH) + case DiffViewPlainDiff: + content = dv.renderPlainDiff(viewH) + default: + 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("]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)) + } + 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) + thumbStart, thumbEnd, scrollW := scrollbarGeometry(total, viewH, dv.yOffset) + + // Prefix is fixed (sticky line numbers); content gets 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] + content := hslice(rl.Content, dv.xOffset, contentW) + if pad := contentW - lipgloss.Width(content); pad > 0 { + content += strings.Repeat(" ", pad) + } + line = rl.Prefix + content + } else { + line = strings.Repeat(" ", dv.width-scrollW) + } + if scrollW > 0 { + line += scrollbarChar(vi, thumbStart, thumbEnd) + } + 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) + thumbStart, thumbEnd, scrollW := scrollbarGeometry(total, viewH, dv.yOffset) + + const dividerW = 3 // " │ " + paneTotal := (dv.width - dividerW - scrollW) / 2 + + 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 scrollW > 0 { + line += scrollbarChar(vi, thumbStart, thumbEnd) + } + sb.WriteString(line) + if vi < viewH-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func (dv DiffView) renderPlainDiff(viewH int) string { + lines := dv.plainLines + total := len(lines) + thumbStart, thumbEnd, scrollW := scrollbarGeometry(total, viewH, dv.yOffset) + contentW := dv.width - scrollW + + var sb strings.Builder + for vi := range viewH { + lineIdx := dv.yOffset + vi + var line 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 scrollW > 0 { + line += scrollbarChar(vi, thumbStart, thumbEnd) + } + sb.WriteString(line) + if vi < viewH-1 { + sb.WriteString("\n") + } + } + 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 { + return 100 + } + 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. +// 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 + 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/execview.go b/cmd/mxcli/tui/execview.go new file mode 100644 index 0000000..9ef9bb5 --- /dev/null +++ b/cmd/mxcli/tui/execview.go @@ -0,0 +1,487 @@ +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. +// 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) + } + + 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..bb31e49 --- /dev/null +++ b/cmd/mxcli/tui/execview_test.go @@ -0,0 +1,215 @@ +package tui + +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) + 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) + } +} + +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/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/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/help.go b/cmd/mxcli/tui/help.go index c91d72a..9267812 100644 --- a/cmd/mxcli/tui/help.go +++ b/cmd/mxcli/tui/help.go @@ -6,30 +6,49 @@ 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) + x execute MDL script + 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 + r rerun (check overlay) + 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 4c9dccc..fb3d7d8 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"}, @@ -41,6 +42,8 @@ var ( {Key: "t", Label: "tab"}, {Key: "T", Label: "new project"}, {Key: "1-9", Label: "switch tab"}, + {Key: "x", Label: "exec"}, + {Key: "!", Label: "check"}, {Key: "?", Label: "help"}, } FilterActiveHints = []Hint{ @@ -59,7 +62,20 @@ 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{ + {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"}, + {Key: "]c/[c", Label: "hunk"}, + {Key: "/", Label: "search"}, + {Key: "y", Label: "yank"}, {Key: "q", Label: "close"}, } ) @@ -81,7 +97,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..d7a6b90 --- /dev/null +++ b/cmd/mxcli/tui/jumper.go @@ -0,0 +1,153 @@ +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 + 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... (mf: nf: wf: pg: en:)" + ti.CharLimit = 200 + ti.Focus() + + jv := JumperView{ + input: ti, + list: NewFuzzyList(items, jumperMaxShow), + width: width, + height: height, + } + return jv +} + +// --- 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.list.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.list.MoveUp() + case "down", "ctrl+n": + jv.list.MoveDown() + default: + var cmd tea.Cmd + jv.input, cmd = jv.input.Update(msg) + jv.list.Filter(jv.input.Value()) + 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)) + + 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(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 := fl.VisibleEnd() + if fl.Offset > 0 { + sb.WriteString(dimSt.Render(" ↑ more") + "\n") + } + 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 == fl.Cursor { + sb.WriteString(selSt.Render(" "+label) + " " + typeSt.Render(typeLabel) + "\n") + } else { + sb.WriteString(normSt.Render(" "+label) + " " + dimSt.Render(typeLabel) + "\n") + } + } + if end < len(fl.Matches) { + sb.WriteString(dimSt.Render(" ↓ more") + "\n") + } + sb.WriteString("\n" + dimSt.Render(fmt.Sprintf(" %d/%d", len(fl.Matches), len(fl.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.list.Matches), len(jv.list.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..5b56d4f 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" @@ -19,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 @@ -51,6 +56,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 +78,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 +148,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 +212,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 +226,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(previewDebounceDelay, 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 +348,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 +442,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 +512,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 { @@ -840,7 +889,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/overlay.go b/cmd/mxcli/tui/overlay.go index e4a2401..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 } @@ -47,9 +48,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 @@ -114,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 new file mode 100644 index 0000000..e38b637 --- /dev/null +++ b/cmd/mxcli/tui/overlayview.go @@ -0,0 +1,201 @@ +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 + 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, +// 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 + refreshable bool + refreshMsg tea.Msg +} + +// 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, + 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 + } + 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 "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 + 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. +// 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 { + 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"}) + } + if ov.refreshable { + hints = append(hints, Hint{Key: "r", Label: "rerun"}) + } + 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..a99fab4 100644 --- a/cmd/mxcli/tui/statusbar.go +++ b/cmd/mxcli/tui/statusbar.go @@ -6,11 +6,22 @@ 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" + checkBadge string // e.g. "✗ 3E 2W" or "✓" (pre-styled) + viewDepth int + viewModes []string + zones []breadcrumbZone // clickable breadcrumb zones } // NewStatusBar creates a status bar. @@ -33,27 +44,79 @@ func (s *StatusBar) SetMode(mode string) { s.mode = mode } -// View renders the status bar to fit the given width. +// 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 + 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) - // 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)) } 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 +127,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/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. diff --git a/cmd/mxcli/tui/theme.go b/cmd/mxcli/tui/theme.go new file mode 100644 index 0000000..7d0aacc --- /dev/null +++ b/cmd/mxcli/tui/theme.go @@ -0,0 +1,81 @@ +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"} +) + +// 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 = "│" + 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) + + // 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/view.go b/cmd/mxcli/tui/view.go new file mode 100644 index 0000000..15dec84 --- /dev/null +++ b/cmd/mxcli/tui/view.go @@ -0,0 +1,61 @@ +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 + ModeExec +) + +// 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" + case ModeExec: + return "Exec" + 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..29377da --- /dev/null +++ b/cmd/mxcli/tui/viewstack.go @@ -0,0 +1,70 @@ +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 + } +} + +// 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 new file mode 100644 index 0000000..e81d9f5 --- /dev/null +++ b/cmd/mxcli/tui/viewstack_test.go @@ -0,0 +1,162 @@ +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) + } +} + +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 new file mode 100644 index 0000000..35e2de4 --- /dev/null +++ b/cmd/mxcli/tui/watcher.go @@ -0,0 +1,140 @@ +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{} + closeOnce sync.Once + 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) + // Allow .mpr, .mxunit, and extensionless files (MPR v2 mprcontents/ hash files). + 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 watchErr, ok := <-w.fsw.Errors: + if !ok { + return + } + Trace("watcher: fsnotify error: %v", watchErr) + } + } +} + +// 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. Safe to call concurrently. +func (w *Watcher) Close() { + w.closeOnce.Do(func() { + 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/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/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 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" +``` diff --git a/go.mod b/go.mod index 1b810b5..1ce5f90 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,18 @@ 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/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 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 +28,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 +51,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,7 +58,6 @@ 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/shopspring/decimal v1.4.0 // indirect diff --git a/go.sum b/go.sum index 57f2be8..584716d 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,14 @@ 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= 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= @@ -34,8 +40,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= @@ -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= @@ -74,6 +80,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,12 +132,12 @@ 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= 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 +148,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 +227,14 @@ 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.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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=