diff --git a/cmd/mxcli/cmd_tui.go b/cmd/mxcli/cmd_tui.go index 65dbb1d..84d81a9 100644 --- a/cmd/mxcli/cmd_tui.go +++ b/cmd/mxcli/cmd_tui.go @@ -33,13 +33,33 @@ Commands (via : bar): :diagram open diagram in browser :search full-text search +Flags: + -c, --continue Restore previous session (tab, navigation, preview mode) + Example: mxcli tui -p app.mpr + mxcli tui -c `, Run: func(cmd *cobra.Command, args []string) { projectPath, _ := cmd.Flags().GetString("project") + continueSession, _ := cmd.Flags().GetBool("continue") mxcliPath, _ := os.Executable() + // Try to restore session when -c flag is set + var session *tui.TUISession + if continueSession { + loaded, err := tui.LoadSession() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not load session: %v\n", err) + } else if loaded != nil { + session = loaded + // Use project path from session if not explicitly provided + if projectPath == "" && len(session.Tabs) > 0 { + projectPath = session.Tabs[0].ProjectPath + } + } + } + if projectPath == "" { picker := tui.NewPickerModel() p := tea.NewProgram(picker, tea.WithAltScreen()) @@ -55,9 +75,18 @@ Example: projectPath = m.Chosen() } + // Verify project file exists + if _, err := os.Stat(projectPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: project file not found: %s\n", projectPath) + os.Exit(1) + } + tui.SaveHistory(projectPath) m := tui.NewApp(mxcliPath, projectPath) + if session != nil { + m.SetPendingSession(session) + } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) m.StartWatcher(p) if _, err := p.Run(); err != nil { @@ -66,3 +95,7 @@ Example: } }, } + +func init() { + tuiCmd.Flags().BoolP("continue", "c", false, "Restore previous TUI session") +} diff --git a/cmd/mxcli/tui/app.go b/cmd/mxcli/tui/app.go index 1a03b75..2cdb6a2 100644 --- a/cmd/mxcli/tui/app.go +++ b/cmd/mxcli/tui/app.go @@ -14,10 +14,13 @@ import ( // chromeHeight is the vertical space consumed by tab bar (1) + hint bar (1) + status bar (1). const chromeHeight = 3 +// handledNoop is a pre-allocated no-op Msg to avoid per-call goroutine allocation. +var handledNoop tea.Msg = struct{}{} + // 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 } +var handledCmd tea.Cmd = func() tea.Msg { return handledNoop } // compareFlashClearMsg is sent 1 s after a clipboard copy in compare view. type compareFlashClearMsg struct{} @@ -36,6 +39,12 @@ type App struct { showHelp bool picker *PickerModel // non-nil when cross-project picker is open + // Check error navigation state (]e / [e) + checkNavActive bool + checkNavIndex int + checkNavLocations []CheckNavLocation + pendingKey rune // ']' or '[' waiting for 'e', 0 if none + tabBar TabBar hintBar HintBar statusBar StatusBar @@ -44,6 +53,8 @@ type App struct { watcher *Watcher checkErrors []CheckError // nil = no check run yet, empty = pass checkRunning bool + + pendingSession *TUISession // session to restore after tree loads } // NewApp creates the root App model. @@ -126,7 +137,7 @@ func (a *App) syncBrowserView() { // 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) + contentH := max(5, a.height-chromeHeight-1) // -1 for LLM anchor line bv.miller.SetSize(a.width, contentH) } a.views.SetBase(bv) @@ -164,6 +175,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.views.Pop() return a, nil + case PaletteExecMsg: + a.views.Pop() + if msg.Key != "" { + return a, a.dispatchPaletteKey(msg.Key) + } + return a, nil + // --- View creation messages --- case OpenOverlayMsg: ov := NewOverlayView(msg.Title, msg.Content, a.width, a.height, msg.Opts) @@ -210,6 +228,26 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case NavigateToDocMsg: + // Close overlay, navigate tree to document, enter check nav mode + a.views.Pop() + qname := docNameToQualifiedName(msg.ModuleName, msg.DocumentName) + if bv, ok := a.views.Base().(BrowserView); ok { + cmd := bv.navigateToNode(qname) + a.views.SetBase(bv) + if tab := a.activeTabPtr(); tab != nil { + tab.Miller = bv.miller + tab.UpdateLabel() + a.syncTabBar() + } + // Enter check nav mode + a.checkNavActive = true + a.checkNavIndex = msg.NavIndex + a.checkNavLocations = extractCheckNavLocations(filterCheckErrors(a.checkErrors, "all")) + return a, cmd + } + return a, nil + case DiffOpenMsg: dv := NewDiffView(msg, a.width, a.height) a.views.Push(dv) @@ -372,8 +410,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } - // Tab bar clicks (row 0) — only when in browser mode - if msg.Y == 0 && a.views.Active().Mode() == ModeBrowser && + // Tab bar clicks (row 1, after LLM anchor line) — only when in browser mode + if msg.Y == 1 && 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 { @@ -401,10 +439,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Offset Y by -1 for tab bar when in browser mode + // Offset Y by -2 (LLM anchor line + tab bar) when in browser mode if a.views.Active().Mode() == ModeBrowser { offsetMsg := tea.MouseMsg{ - X: msg.X, Y: msg.Y - 1, + X: msg.X, Y: msg.Y - 2, Button: msg.Button, Action: msg.Action, } updated, cmd := a.views.Active().Update(offsetMsg) @@ -426,7 +464,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // scroll calculations (Render operates on a copy and cannot // persist dimensions back). if bv, ok := a.views.Active().(BrowserView); ok { - contentH := a.height - chromeHeight + contentH := a.height - chromeHeight - 1 // -1 for LLM anchor line if contentH < 5 { contentH = 5 } @@ -448,12 +486,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { bv.allNodes = msg.Nodes bv.miller = tab.Miller if a.height > 0 { - contentH := max(5, a.height-chromeHeight) + contentH := max(5, a.height-chromeHeight-1) // -1 for LLM anchor line bv.miller.SetSize(a.width, contentH) } a.views.SetBase(bv) } } + + // Apply pending session restore after tree is loaded + if a.pendingSession != nil { + applySessionRestore(&a) + } } return a, nil @@ -488,8 +531,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // 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) + ov.checkErrors = a.checkErrors + filtered := filterCheckErrors(a.checkErrors, ov.checkFilter) + ov.checkNavLocs = extractCheckNavLocations(filtered) + if ov.selectedIdx >= len(ov.checkNavLocs) { + ov.selectedIdx = max(0, len(ov.checkNavLocs)-1) + } + if len(ov.checkNavLocs) == 0 { + ov.selectedIdx = -1 + } + title := renderCheckFilterTitle(a.checkErrors, ov.checkFilter) + content := renderCheckResults(a.checkErrors, ov.checkFilter) + ov.overlay.Show(title, content, ov.overlay.width, ov.overlay.height) a.views.SetActive(ov) } return a, nil @@ -523,8 +576,74 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { tab := a.activeTabPtr() + // Handle two-key sequence: ]e / [e (check error navigation) + if a.pendingKey != 0 { + pending := a.pendingKey + a.pendingKey = 0 + if msg.String() == "e" && len(a.checkErrors) > 0 { + // Lazily initialize check nav state if not already active + if !a.checkNavActive { + a.checkNavActive = true + a.checkNavLocations = extractCheckNavLocations(filterCheckErrors(a.checkErrors, "all")) + a.checkNavIndex = -1 // will be incremented to 0 for ], or wrapped to last for [ + } + if pending == ']' { + a.checkNavIndex++ + if a.checkNavIndex >= len(a.checkNavLocations) { + a.checkNavIndex = 0 // wrap around + } + } else { + a.checkNavIndex-- + if a.checkNavIndex < 0 { + a.checkNavIndex = len(a.checkNavLocations) - 1 // wrap around + } + } + loc := a.checkNavLocations[a.checkNavIndex] + qname := docNameToQualifiedName(loc.ModuleName, loc.DocumentName) + if bv, ok := a.views.Base().(BrowserView); ok { + cmd := bv.navigateToNode(qname) + a.views.SetBase(bv) + if tab := a.activeTabPtr(); tab != nil { + tab.Miller = bv.miller + tab.UpdateLabel() + a.syncTabBar() + } + return cmd + } + return handledCmd + } + // Not 'e' — fall through to normal handling for the pending key + // Re-process the pending key's original action + if pending == ']' { + if a.activeTab < len(a.tabs)-1 { + a.activeTab++ + a.syncBrowserView() + a.syncTabBar() + } + } else if pending == '[' { + if a.activeTab > 0 { + a.activeTab-- + a.syncBrowserView() + a.syncTabBar() + } + } + // Now process the current key normally (fall through) + } + + // Non-nav keys exit check nav mode (preserve for ]/[/! which are nav-related) + if a.checkNavActive { + key := msg.String() + if key != "]" && key != "[" && key != "!" && key != "\\!" { + a.checkNavActive = false + } + } + switch msg.String() { case "q": + // Save session state before quitting + if session := ExtractSession(a); session != nil { + _ = SaveSession(session) + } if a.watcher != nil { a.watcher.Close() } @@ -574,6 +693,10 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { return handledCmd case "[": + if len(a.checkErrors) > 0 { + a.pendingKey = '[' + return handledCmd + } if a.activeTab > 0 { a.activeTab-- a.syncBrowserView() @@ -582,6 +705,10 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { return handledCmd case "]": + if len(a.checkErrors) > 0 { + a.pendingKey = ']' + return handledCmd + } if a.activeTab < len(a.tabs)-1 { a.activeTab++ a.syncBrowserView() @@ -606,26 +733,41 @@ func (a *App) handleBrowserAppKeys(msg tea.KeyMsg) tea.Cmd { 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{ + filter := "all" + title := renderCheckFilterTitle(a.checkErrors, filter) + content := renderCheckResults(a.checkErrors, filter) + navLocs := extractCheckNavLocations(a.checkErrors) + ov := NewOverlayView(title, content, a.width, a.height, OverlayViewOpts{ HideLineNumbers: true, Refreshable: true, RefreshMsg: MxCheckRerunMsg{}, + CheckFilter: filter, + CheckErrors: a.checkErrors, + CheckNavLocs: navLocs, }) a.views.Push(ov) return handledCmd + case ":": + cp := NewCommandPaletteView(a.width, a.height) + a.views.Push(cp) + return handledCmd + case "c": cv := NewCompareView() cv.mxcliPath = a.mxcliPath cv.projectPath = a.activeTabProjectPath() - cv.Show(CompareNDSL, a.width, a.height) + cv.Show(CompareNDSLMDL, a.width, a.height) if tab != nil { cv.SetItems(flattenQualifiedNames(tab.AllNodes)) if node := tab.Miller.SelectedNode(); node != nil && node.QualifiedName != "" { cv.SetLoading(CompareFocusLeft) + cv.SetLoading(CompareFocusRight) a.views.Push(cv) - return cv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft) + return tea.Batch( + cv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft), + cv.loadMDL(node.QualifiedName, node.Type, CompareFocusRight), + ) } } a.views.Push(cv) @@ -655,6 +797,102 @@ func (a *App) switchToTabByID(id int) { } } +// SetPendingSession stores a session to be restored after the project tree loads. +func (a *App) SetPendingSession(session *TUISession) { + a.pendingSession = session +} + +// applySessionRestore applies the pending session state to the loaded app. +// Called after LoadTreeMsg delivers nodes so navigation paths can be resolved. +// Takes *App because it's called from Update (value receiver) via &a. +func applySessionRestore(a *App) { + session := a.pendingSession + if session == nil { + return + } + a.pendingSession = nil + + if len(session.Tabs) == 0 { + return + } + + // Restore the first tab's navigation (multi-tab restore: only the + // primary tab is restored since additional tabs need separate + // project-tree loads which are not wired yet). + ts := session.Tabs[0] + tab := a.activeTabPtr() + if tab == nil || len(tab.AllNodes) == 0 { + return + } + + // Navigate to the selected node if available + if ts.SelectedNode != "" { + if bv, ok := a.views.Base().(BrowserView); ok { + bv.allNodes = tab.AllNodes + bv.navigateToNode(ts.SelectedNode) + // Set preview mode after navigation (navigateToNode resets miller) + setPreviewMode(&bv.miller, ts.PreviewMode) + tab.Miller = bv.miller + tab.UpdateLabel() + a.views.SetBase(bv) + a.syncTabBar() + Trace("app: session restored — navigated to %q", ts.SelectedNode) + return + } + } + + // Fallback: navigate the miller path breadcrumb + if len(ts.MillerPath) > 0 { + restoreMillerPath(a, tab, ts.MillerPath) + } + + // Set preview mode (for path-based or no-navigation restore) + setPreviewMode(&tab.Miller, ts.PreviewMode) +} + +// setPreviewMode sets the miller preview mode from a string value. +func setPreviewMode(miller *MillerView, mode string) { + if mode == "NDSL" { + miller.preview.mode = PreviewNDSL + } else { + miller.preview.mode = PreviewMDL + } +} + +// restoreMillerPath drills the miller view through a breadcrumb path. +func restoreMillerPath(a *App, tab *Tab, millerPath []string) { + bv, ok := a.views.Base().(BrowserView) + if !ok { + return + } + bv.allNodes = tab.AllNodes + bv.miller.SetRootNodes(tab.AllNodes) + + for _, segment := range millerPath { + found := false + for j, item := range bv.miller.current.items { + if item.Label == segment { + bv.miller.current.SetCursor(j) + if item.Node != nil && len(item.Node.Children) > 0 { + bv.miller, _ = bv.miller.drillIn() + } + found = true + break + } + } + if !found { + Trace("app: session restore — path segment %q not found, stopping", segment) + break + } + } + + tab.Miller = bv.miller + tab.UpdateLabel() + a.views.SetBase(bv) + a.syncTabBar() + Trace("app: session restored via miller path %v", millerPath) +} + // --- View --- func (a App) View() string { @@ -708,8 +946,8 @@ func (a App) View() string { } } - // Content area - contentH := a.height - chromeHeight + // Content area (chromeHeight + 1 for the LLM anchor line) + contentH := a.height - chromeHeight - 1 if contentH < 5 { contentH = 5 } @@ -724,12 +962,24 @@ func (a App) View() string { a.statusBar.SetBreadcrumb(info.Breadcrumb) a.statusBar.SetPosition(info.Position) a.statusBar.SetMode(info.Mode) - a.statusBar.SetCheckBadge(formatCheckBadge(a.checkErrors, a.checkRunning)) + if a.checkNavActive && len(a.checkNavLocations) > 0 { + loc := a.checkNavLocations[a.checkNavIndex] + navInfo := fmt.Sprintf("[%d/%d] %s: %s ]e next [e prev", + a.checkNavIndex+1, len(a.checkNavLocations), + loc.Code, docNameToQualifiedName(loc.ModuleName, loc.DocumentName)) + a.statusBar.SetCheckBadge(CheckWarnStyle.Render(navInfo)) + } else { + 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" + content + "\n" + hintLine + "\n" + statusLine + // LLM anchor: machine-readable command list (Faint, not visible to users in practice) + anchorStyle := lipgloss.NewStyle().Foreground(MutedColor).Faint(true) + anchorLine := anchorStyle.Render("[mxcli:commands] h:back l:open Space:jump /:filter b:bson c:compare d:diagram z:zen Tab:toggle x:exec r:refresh y:copy !:check ]e:next-error [e:prev-error t:tab T:new-tab W:close-tab 1-9:switch ?:help ::palette") + + rendered := anchorLine + "\n" + tabLine + "\n" + content + "\n" + hintLine + "\n" + statusLine if a.showHelp { helpView := renderHelp(a.width, a.height) @@ -819,6 +1069,21 @@ func (a App) collectViewModeNames() []string { return a.views.ModeNames() } +// dispatchPaletteKey converts a palette command key string into a synthetic +// tea.KeyMsg and re-dispatches it through Update. +func (a App) dispatchPaletteKey(key string) tea.Cmd { + var keyMsg tea.KeyMsg + switch key { + case " ": + keyMsg = tea.KeyMsg{Type: tea.KeySpace} + case "Tab": + keyMsg = tea.KeyMsg{Type: tea.KeyTab} + default: + keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + return func() tea.Msg { return keyMsg } +} + // inferBsonType maps tree node types to valid bson object types. func inferBsonType(nodeType string) string { switch strings.ToLower(nodeType) { diff --git a/cmd/mxcli/tui/checker.go b/cmd/mxcli/tui/checker.go index d4a44c0..8ab0f9d 100644 --- a/cmd/mxcli/tui/checker.go +++ b/cmd/mxcli/tui/checker.go @@ -2,6 +2,7 @@ package tui import ( "encoding/json" + "fmt" "os" "os/exec" "strings" @@ -13,12 +14,86 @@ import ( // CheckError represents a single mx check diagnostic. type CheckError struct { - Severity string // "ERROR" or "WARNING" + Severity string // "ERROR", "WARNING", or "DEPRECATION" 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" + ElementID string // unique element identifier for deduplication +} + +// CheckGroup represents errors grouped by error code. +type CheckGroup struct { + Code string + Severity string + Message string + Items []CheckGroupItem +} + +// CheckGroupItem represents a deduplicated location within a group. +type CheckGroupItem struct { + DocLocation string // formatted as "Module.DocName (Type)" + ElementName string + ElementID string + Count int // occurrences of the same element-id +} + +// CheckNavLocation represents a unique document location for error navigation. +type CheckNavLocation struct { + ModuleName string + DocumentName string // raw document name from mx check (e.g. "Page 'P_ComboBox'") + Code string // error code (e.g. "CE1613") + Message string +} + +// NavigateToDocMsg requests navigation to a document in the tree. +type NavigateToDocMsg struct { + ModuleName string + DocumentName string // raw document name (e.g. "Page 'P_ComboBox'") + NavIndex int // index into checkNavLocations for ]e/[e navigation +} + +// extractCheckNavLocations builds a flat list of unique document locations from check errors. +// Each unique (ModuleName, DocumentName) pair appears once, with the code/message from the first occurrence. +func extractCheckNavLocations(errors []CheckError) []CheckNavLocation { + type locKey struct{ mod, doc string } + seen := map[locKey]bool{} + var locations []CheckNavLocation + for _, e := range errors { + if e.DocumentName == "" { + continue + } + key := locKey{e.ModuleName, e.DocumentName} + if seen[key] { + continue + } + seen[key] = true + locations = append(locations, CheckNavLocation{ + ModuleName: e.ModuleName, + DocumentName: e.DocumentName, + Code: e.Code, + Message: e.Message, + }) + } + return locations +} + +// docNameToQualifiedName converts a module name and raw document name +// (e.g. "Page 'P_ComboBox'") to a qualified name (e.g. "MyModule.P_ComboBox"). +func docNameToQualifiedName(moduleName, documentName string) string { + // documentName format: "Type 'Name'" — extract the name part + if idx := strings.Index(documentName, " '"); idx > 0 { + docName := strings.TrimSuffix(documentName[idx+2:], "'") + if moduleName != "" { + return moduleName + "." + docName + } + return docName + } + if moduleName != "" { + return moduleName + "." + documentName + } + return documentName } // MxCheckResultMsg carries the result of an async mx check run. @@ -33,10 +108,11 @@ 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`. +// mxCheckJSON mirrors the JSON structure produced by `mx check -j -w -d`. type mxCheckJSON struct { - Errors []mxCheckEntry `json:"errors"` - Warnings []mxCheckEntry `json:"warnings"` + Errors []mxCheckEntry `json:"errors"` + Warnings []mxCheckEntry `json:"warnings"` + Deprecations []mxCheckEntry `json:"deprecations"` } type mxCheckEntry struct { @@ -49,6 +125,8 @@ type mxCheckLocation struct { ModuleName string `json:"module-name"` DocumentName string `json:"document-name"` ElementName string `json:"element-name"` + ElementID string `json:"element-id"` + UnitID string `json:"unit-id"` } // runMxCheck returns a tea.Cmd that runs mx check asynchronously. @@ -71,8 +149,8 @@ func runMxCheck(projectPath string) tea.Cmd { 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) + Trace("checker: running %s check %s -j %s -w -d", mxPath, projectPath, jsonPath) + cmd := exec.Command(mxPath, "check", projectPath, "-j", jsonPath, "-w", "-d") _, runErr := cmd.CombinedOutput() checkErrors, parseErr := parseCheckJSON(jsonPath) @@ -116,6 +194,9 @@ func parseCheckJSON(jsonPath string) ([]CheckError, error) { for _, entry := range result.Warnings { checkErrors = append(checkErrors, entryToCheckError(entry, "WARNING")) } + for _, entry := range result.Deprecations { + checkErrors = append(checkErrors, entryToCheckError(entry, "DEPRECATION")) + } return checkErrors, nil } @@ -130,12 +211,161 @@ func entryToCheckError(entry mxCheckEntry, severity string) CheckError { ce.ModuleName = loc.ModuleName ce.DocumentName = loc.DocumentName ce.ElementName = loc.ElementName + ce.ElementID = loc.ElementID } return ce } +// groupCheckErrors groups errors by Code and deduplicates by element-id within each group. +func groupCheckErrors(errors []CheckError) []CheckGroup { + // Preserve insertion order of codes + var codeOrder []string + groupByCode := make(map[string]*CheckGroup) + + for _, e := range errors { + g, exists := groupByCode[e.Code] + if !exists { + g = &CheckGroup{ + Code: e.Code, + Severity: e.Severity, + Message: e.Message, + } + groupByCode[e.Code] = g + codeOrder = append(codeOrder, e.Code) + } + + docLoc := formatDocLocation(e.ModuleName, e.DocumentName) + + // Deduplicate by element-id within the group + dedupKey := e.ElementID + if dedupKey == "" { + // No element-id: use doc location + element name as fallback key + dedupKey = docLoc + "|" + e.ElementName + } + + found := false + for i := range g.Items { + itemKey := g.Items[i].ElementID + if itemKey == "" { + itemKey = g.Items[i].DocLocation + "|" + g.Items[i].ElementName + } + if itemKey == dedupKey { + g.Items[i].Count++ + found = true + break + } + } + if !found { + g.Items = append(g.Items, CheckGroupItem{ + DocLocation: docLoc, + ElementName: e.ElementName, + ElementID: e.ElementID, + Count: 1, + }) + } + } + + groups := make([]CheckGroup, 0, len(codeOrder)) + for _, code := range codeOrder { + groups = append(groups, *groupByCode[code]) + } + return groups +} + +// countBySeverity returns error, warning, and deprecation counts. +func countBySeverity(errors []CheckError) (errorCount, warningCount, deprecationCount int) { + for _, e := range errors { + switch e.Severity { + case "ERROR": + errorCount++ + case "WARNING": + warningCount++ + case "DEPRECATION": + deprecationCount++ + } + } + return +} + +// renderCheckFilterTitle returns the overlay title with filter indicator. +// Examples: "mx check [All: 8E 2W 1D]" or "mx check [Errors: 8]" +func renderCheckFilterTitle(errors []CheckError, filter string) string { + if errors == nil || len(errors) == 0 { + return "mx check" + } + ec, wc, dc := countBySeverity(errors) + var indicator string + switch filter { + case "error": + indicator = "[Errors: " + itoa(ec) + "]" + case "warning": + indicator = "[Warnings: " + itoa(wc) + "]" + case "deprecation": + indicator = "[Deprecations: " + itoa(dc) + "]" + default: // "all" + var parts []string + if ec > 0 { + parts = append(parts, itoa(ec)+"E") + } + if wc > 0 { + parts = append(parts, itoa(wc)+"W") + } + if dc > 0 { + parts = append(parts, itoa(dc)+"D") + } + indicator = "[All: " + strings.Join(parts, " ") + "]" + } + return "mx check " + indicator +} + +// nextCheckFilter cycles the filter: all → error → warning → deprecation → all. +func nextCheckFilter(current string) string { + switch current { + case "all": + return "error" + case "error": + return "warning" + case "warning": + return "deprecation" + case "deprecation": + return "all" + default: + return "all" + } +} + +// severityMatchesFilter checks if a severity matches a filter value. +func severityMatchesFilter(severity, filter string) bool { + switch filter { + case "error": + return severity == "ERROR" + case "warning": + return severity == "WARNING" + case "deprecation": + return severity == "DEPRECATION" + default: + return true + } +} + +// filterCheckErrors returns only errors matching the given severity filter. +func filterCheckErrors(errors []CheckError, filter string) []CheckError { + if filter == "all" { + return errors + } + var filtered []CheckError + for _, e := range errors { + if severityMatchesFilter(e.Severity, filter) { + filtered = append(filtered, e) + } + } + return filtered +} + // renderCheckResults formats check errors for display in an overlay. -func renderCheckResults(errors []CheckError) string { +// Errors are grouped by code and deduplicated by element-id. +// The filter parameter controls which severities to show: "all", "error", "warning", "deprecation". +func renderCheckResults(errors []CheckError, filter string) string { if errors == nil { return "No check has been run yet. Changes to the project will trigger an automatic check." } @@ -144,44 +374,54 @@ func renderCheckResults(errors []CheckError) string { } var sb strings.Builder - var errorCount, warningCount int - for _, e := range errors { - if e.Severity == "ERROR" { - errorCount++ - } else { - warningCount++ - } - } + ec, wc, dc := countBySeverity(errors) // 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 ec > 0 { + summaryParts = append(summaryParts, CheckErrorStyle.Render("● "+itoa(ec)+" errors")) } - if warningCount > 0 { - summaryParts = append(summaryParts, CheckWarnStyle.Render("● "+itoa(warningCount)+" warnings")) + if wc > 0 { + summaryParts = append(summaryParts, CheckWarnStyle.Render("● "+itoa(wc)+" warnings")) + } + if dc > 0 { + summaryParts = append(summaryParts, CheckDeprecStyle.Render("● "+itoa(dc)+" deprecations")) } sb.WriteString(strings.Join(summaryParts, " ")) sb.WriteString("\n\n") - // Detail lines - for _, e := range errors { + // Grouped detail lines + groups := groupCheckErrors(errors) + for _, g := range groups { + // Apply filter: skip groups that don't match + if filter != "all" && !severityMatchesFilter(g.Severity, filter) { + continue + } + 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 + switch g.Severity { + case "ERROR": + label = CheckErrorStyle.Render(g.Code) + case "WARNING": + label = CheckWarnStyle.Render(g.Code) + case "DEPRECATION": + label = CheckDeprecStyle.Render(g.Code) + default: + label = g.Code + } + sb.WriteString(label + " — " + g.Message + "\n") + + for _, item := range g.Items { + countSuffix := "" + if item.Count > 1 { + countSuffix = " (x" + itoa(item.Count) + ")" + } + sb.WriteString(" " + item.DocLocation + countSuffix + "\n") + if item.ElementName != "" { + sb.WriteString(" > " + CheckLocStyle.Render(item.ElementName) + "\n") } - sb.WriteString(" " + CheckLocStyle.Render(loc) + "\n") } sb.WriteString("\n") } @@ -219,20 +459,48 @@ func formatCheckBadge(errors []CheckError, running bool) string { if len(errors) == 0 { return CheckPassStyle.Render("✓") } - var errorCount, warningCount int - for _, e := range errors { - if e.Severity == "ERROR" { - errorCount++ - } else { - warningCount++ - } - } + ec, wc, dc := countBySeverity(errors) var parts []string - if errorCount > 0 { - parts = append(parts, CheckErrorStyle.Render("✗ "+itoa(errorCount)+"E")) + if ec > 0 { + parts = append(parts, CheckErrorStyle.Render("✗ "+itoa(ec)+"E")) + } + if wc > 0 { + parts = append(parts, CheckWarnStyle.Render(itoa(wc)+"W")) } - if warningCount > 0 { - parts = append(parts, CheckWarnStyle.Render(itoa(warningCount)+"W")) + if dc > 0 { + parts = append(parts, CheckDeprecStyle.Render(itoa(dc)+"D")) } return strings.Join(parts, " ") } + +// renderCheckAnchors builds LLM-friendly structured anchor lines for check results. +// Each line is a key=value record that LLMs can parse from screenshots or clipboard. +func renderCheckAnchors(groups []CheckGroup, errors []CheckError) string { + ec, wc, dc := countBySeverity(errors) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("[mxcli:check] errors=%d warnings=%d deprecations=%d", ec, wc, dc)) + + for _, g := range groups { + for _, item := range g.Items { + // Extract doc name and type from DocLocation format "Module.Name (Type)" + docName := item.DocLocation + docType := "" + if idx := strings.LastIndex(item.DocLocation, " ("); idx > 0 { + docName = item.DocLocation[:idx] + docType = strings.TrimSuffix(item.DocLocation[idx+2:], ")") + } + + line := fmt.Sprintf("\n[mxcli:check:%s] severity=%s count=%d doc=%s", + g.Code, g.Severity, item.Count, docName) + if docType != "" { + line += " type=" + docType + } + if item.ElementName != "" { + line += " element=" + item.ElementName + } + sb.WriteString(line) + } + } + return sb.String() +} diff --git a/cmd/mxcli/tui/checker_test.go b/cmd/mxcli/tui/checker_test.go index 10e057a..26c7c09 100644 --- a/cmd/mxcli/tui/checker_test.go +++ b/cmd/mxcli/tui/checker_test.go @@ -17,7 +17,9 @@ func TestParseCheckJSON(t *testing.T) { { "module-name": "MyModule", "document-name": "Page 'P_ComboBox'", - "element-name": "Property 'Association' of combo box 'cmbPriority'" + "element-name": "Property 'Association' of combo box 'cmbPriority'", + "element-id": "aaa-111", + "unit-id": "unit-001" } ] }, @@ -28,7 +30,9 @@ func TestParseCheckJSON(t *testing.T) { { "module-name": "MyModule", "document-name": "Page 'CustomerList'", - "element-name": "DataGrid2 widget" + "element-name": "DataGrid2 widget", + "element-id": "bbb-222", + "unit-id": "unit-002" } ] } @@ -41,7 +45,9 @@ func TestParseCheckJSON(t *testing.T) { { "module-name": "MyModule", "document-name": "Microflow 'DoSomething'", - "element-name": "Variable '$var'" + "element-name": "Variable '$var'", + "element-id": "ccc-333", + "unit-id": "unit-003" } ] } @@ -80,6 +86,9 @@ func TestParseCheckJSON(t *testing.T) { if errors[0].ModuleName != "MyModule" { t.Errorf("expected MyModule, got %q", errors[0].ModuleName) } + if errors[0].ElementID != "aaa-111" { + t.Errorf("expected element-id aaa-111, got %q", errors[0].ElementID) + } // Second error if errors[1].Code != "CE0463" { @@ -147,7 +156,7 @@ func TestParseCheckJSONNoLocations(t *testing.T) { func TestRenderCheckResultsNilVsEmpty(t *testing.T) { // nil = no check has run yet - result := renderCheckResults(nil) + result := renderCheckResults(nil, "all") if result == "" { t.Error("expected non-empty result for nil errors") } @@ -156,7 +165,7 @@ func TestRenderCheckResultsNilVsEmpty(t *testing.T) { } // empty = check ran, no errors found - result = renderCheckResults([]CheckError{}) + result = renderCheckResults([]CheckError{}, "all") if !strings.Contains(result, "passed") { t.Error("empty errors should show 'passed'") } @@ -173,7 +182,7 @@ func TestRenderCheckResultsWithDocLocation(t *testing.T) { ModuleName: "MyModule", }, } - result := renderCheckResults(errors) + result := renderCheckResults(errors, "all") if !strings.Contains(result, "MyModule.P_ComboBox (Page)") { t.Errorf("expected qualified doc location, got: %s", result) } @@ -212,3 +221,459 @@ func TestFormatCheckBadge(t *testing.T) { t.Error("expected non-empty badge with errors") } } + +func TestGroupCheckErrors(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE1613", Message: "Association no longer exists", + ModuleName: "Mod", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "id-1"}, + {Severity: "ERROR", Code: "CE1613", Message: "Association no longer exists", + ModuleName: "Mod", DocumentName: "Page 'P2'", ElementName: "combo 'b'", ElementID: "id-2"}, + {Severity: "ERROR", Code: "CE0463", Message: "Widget definition changed", + ModuleName: "Mod", DocumentName: "Page 'P3'", ElementName: "grid", ElementID: "id-3"}, + } + + groups := groupCheckErrors(errors) + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + + // First group: CE1613 with 2 items + if groups[0].Code != "CE1613" { + t.Errorf("expected CE1613, got %q", groups[0].Code) + } + if len(groups[0].Items) != 2 { + t.Errorf("expected 2 items in CE1613 group, got %d", len(groups[0].Items)) + } + + // Second group: CE0463 with 1 item + if groups[1].Code != "CE0463" { + t.Errorf("expected CE0463, got %q", groups[1].Code) + } + if len(groups[1].Items) != 1 { + t.Errorf("expected 1 item in CE0463 group, got %d", len(groups[1].Items)) + } +} + +func TestGroupCheckErrorsDedup(t *testing.T) { + // Same code + same element-id should be deduplicated with count + errors := []CheckError{ + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "same-id"}, + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "same-id"}, + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "same-id"}, + } + + groups := groupCheckErrors(errors) + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + if len(groups[0].Items) != 1 { + t.Fatalf("expected 1 deduplicated item, got %d", len(groups[0].Items)) + } + if groups[0].Items[0].Count != 3 { + t.Errorf("expected count 3, got %d", groups[0].Items[0].Count) + } +} + +func TestGroupCheckErrorsNoElementID(t *testing.T) { + // Without element-id, fallback to doc+element dedup + errors := []CheckError{ + {Severity: "ERROR", Code: "CE9999", Message: "Some error", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "widget 'w'"}, + {Severity: "ERROR", Code: "CE9999", Message: "Some error", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "widget 'w'"}, + } + + groups := groupCheckErrors(errors) + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + if len(groups[0].Items) != 1 { + t.Fatalf("expected 1 deduplicated item, got %d", len(groups[0].Items)) + } + if groups[0].Items[0].Count != 2 { + t.Errorf("expected count 2, got %d", groups[0].Items[0].Count) + } +} + +func TestRenderCheckResultsGrouped(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE1613", Message: "Association gone", + ModuleName: "Mod", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "id-1"}, + {Severity: "ERROR", Code: "CE1613", Message: "Association gone", + ModuleName: "Mod", DocumentName: "Page 'P2'", ElementName: "combo 'b'", ElementID: "id-2"}, + {Severity: "ERROR", Code: "CE1613", Message: "Association gone", + ModuleName: "Mod", DocumentName: "Page 'P2'", ElementName: "combo 'b'", ElementID: "id-2"}, + } + + result := renderCheckResults(errors, "all") + + // Should show grouped code header with message + if !strings.Contains(result, "CE1613") { + t.Error("expected CE1613 in output") + } + if !strings.Contains(result, "Association gone") { + t.Error("expected message in group header") + } + + // Deduplicated: id-2 appears twice, should show (x2) + if !strings.Contains(result, "(x2)") { + t.Error("expected (x2) count suffix for deduplicated entry") + } + + // Doc locations + if !strings.Contains(result, "Mod.P1 (Page)") { + t.Errorf("expected Mod.P1 (Page) in output, got: %s", result) + } + if !strings.Contains(result, "Mod.P2 (Page)") { + t.Errorf("expected Mod.P2 (Page) in output, got: %s", result) + } + + // Element names with > prefix + if !strings.Contains(result, "> ") { + t.Error("expected element name with > prefix") + } + + // Summary should count raw errors (3 total) + if !strings.Contains(result, "3") { + t.Error("expected raw count of 3 in summary") + } +} + +func TestRenderCheckAnchors(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE1613", Message: "Association gone", + ModuleName: "Mod", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "id-1"}, + {Severity: "ERROR", Code: "CE1613", Message: "Association gone", + ModuleName: "Mod", DocumentName: "Page 'P2'", ElementName: "combo 'b'", ElementID: "id-2"}, + {Severity: "WARNING", Code: "CW0001", Message: "Unused variable", + ModuleName: "Mod", DocumentName: "Microflow 'DoSomething'", ElementName: "variable '$var'", ElementID: "id-3"}, + } + + groups := groupCheckErrors(errors) + result := renderCheckAnchors(groups, errors) + + // Summary line + if !strings.Contains(result, "[mxcli:check] errors=2 warnings=1 deprecations=0") { + t.Errorf("expected summary anchor, got: %s", result) + } + + // Per-item anchors for CE1613 + if !strings.Contains(result, "[mxcli:check:CE1613] severity=ERROR count=1 doc=Mod.P1 type=Page element=combo 'a'") { + t.Errorf("expected CE1613 P1 anchor, got: %s", result) + } + if !strings.Contains(result, "[mxcli:check:CE1613] severity=ERROR count=1 doc=Mod.P2 type=Page element=combo 'b'") { + t.Errorf("expected CE1613 P2 anchor, got: %s", result) + } + + // Per-item anchor for CW0001 + if !strings.Contains(result, "[mxcli:check:CW0001] severity=WARNING count=1 doc=Mod.DoSomething type=Microflow element=variable '$var'") { + t.Errorf("expected CW0001 anchor, got: %s", result) + } +} + +func TestRenderCheckAnchorsEmpty(t *testing.T) { + result := renderCheckAnchors(nil, nil) + if !strings.Contains(result, "[mxcli:check] errors=0 warnings=0 deprecations=0") { + t.Errorf("expected zero-count summary, got: %s", result) + } +} + +func TestRenderCheckAnchorsWithDedup(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "same-id"}, + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "same-id"}, + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "combo 'a'", ElementID: "same-id"}, + } + + groups := groupCheckErrors(errors) + result := renderCheckAnchors(groups, errors) + + // Should show count=3 for the deduplicated item + if !strings.Contains(result, "count=3") { + t.Errorf("expected count=3 in deduped anchor, got: %s", result) + } + // Summary should show raw error count + if !strings.Contains(result, "errors=3") { + t.Errorf("expected errors=3 in summary, got: %s", result) + } +} + +func TestParseCheckJSONWithDeprecations(t *testing.T) { + jsonContent := `{ + "serialization_version": 1, + "errors": [ + {"code": "CE0001", "message": "Some error", + "locations": [{"module-name": "M", "document-name": "Page 'P1'", "element-name": "w", "element-id": "e1", "unit-id": "u1"}]} + ], + "warnings": [ + {"code": "CW0001", "message": "Some warning", + "locations": [{"module-name": "M", "document-name": "Microflow 'MF1'", "element-name": "v", "element-id": "e2", "unit-id": "u2"}]} + ], + "deprecations": [ + {"code": "CD0001", "message": "Deprecated feature used", + "locations": [{"module-name": "M", "document-name": "Page 'P2'", "element-name": "old widget", "element-id": "e3", "unit-id": "u3"}]} + ] + }` + + 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 diagnostics, got %d", len(errors)) + } + if errors[0].Severity != "ERROR" { + t.Errorf("expected ERROR, got %q", errors[0].Severity) + } + if errors[1].Severity != "WARNING" { + t.Errorf("expected WARNING, got %q", errors[1].Severity) + } + if errors[2].Severity != "DEPRECATION" { + t.Errorf("expected DEPRECATION, got %q", errors[2].Severity) + } + if errors[2].Code != "CD0001" { + t.Errorf("expected CD0001, got %q", errors[2].Code) + } +} + +func TestRenderCheckResultsFilter(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE0001", Message: "Error msg", + ModuleName: "M", DocumentName: "Page 'P1'", ElementName: "w1", ElementID: "e1"}, + {Severity: "WARNING", Code: "CW0001", Message: "Warning msg", + ModuleName: "M", DocumentName: "Microflow 'MF1'", ElementName: "v1", ElementID: "e2"}, + {Severity: "DEPRECATION", Code: "CD0001", Message: "Deprecation msg", + ModuleName: "M", DocumentName: "Page 'P2'", ElementName: "w2", ElementID: "e3"}, + } + + // Filter by error: should show CE0001 but not CW0001 or CD0001 + result := renderCheckResults(errors, "error") + if !strings.Contains(result, "CE0001") { + t.Error("expected CE0001 in error-filtered output") + } + if strings.Contains(result, "CW0001") { + t.Error("CW0001 should be hidden in error filter") + } + if strings.Contains(result, "CD0001") { + t.Error("CD0001 should be hidden in error filter") + } + + // Filter by warning + result = renderCheckResults(errors, "warning") + if strings.Contains(result, "CE0001") { + t.Error("CE0001 should be hidden in warning filter") + } + if !strings.Contains(result, "CW0001") { + t.Error("expected CW0001 in warning-filtered output") + } + + // Filter by deprecation + result = renderCheckResults(errors, "deprecation") + if !strings.Contains(result, "CD0001") { + t.Error("expected CD0001 in deprecation-filtered output") + } + if strings.Contains(result, "CE0001") { + t.Error("CE0001 should be hidden in deprecation filter") + } + + // All filter shows everything + result = renderCheckResults(errors, "all") + if !strings.Contains(result, "CE0001") || !strings.Contains(result, "CW0001") || !strings.Contains(result, "CD0001") { + t.Error("all filter should show all codes") + } +} + +func TestRenderCheckFilterTitle(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE0001"}, + {Severity: "ERROR", Code: "CE0002"}, + {Severity: "WARNING", Code: "CW0001"}, + {Severity: "DEPRECATION", Code: "CD0001"}, + } + + title := renderCheckFilterTitle(errors, "all") + if !strings.Contains(title, "[All: 2E 1W 1D]") { + t.Errorf("expected [All: 2E 1W 1D] in title, got %q", title) + } + + title = renderCheckFilterTitle(errors, "error") + if !strings.Contains(title, "[Errors: 2]") { + t.Errorf("expected [Errors: 2] in title, got %q", title) + } + + title = renderCheckFilterTitle(errors, "warning") + if !strings.Contains(title, "[Warnings: 1]") { + t.Errorf("expected [Warnings: 1] in title, got %q", title) + } + + title = renderCheckFilterTitle(errors, "deprecation") + if !strings.Contains(title, "[Deprecations: 1]") { + t.Errorf("expected [Deprecations: 1] in title, got %q", title) + } + + // nil returns plain title + title = renderCheckFilterTitle(nil, "all") + if title != "mx check" { + t.Errorf("expected plain 'mx check' for nil errors, got %q", title) + } +} + +func TestNextCheckFilter(t *testing.T) { + if nextCheckFilter("all") != "error" { + t.Error("all -> error") + } + if nextCheckFilter("error") != "warning" { + t.Error("error -> warning") + } + if nextCheckFilter("warning") != "deprecation" { + t.Error("warning -> deprecation") + } + if nextCheckFilter("deprecation") != "all" { + t.Error("deprecation -> all") + } + if nextCheckFilter("unknown") != "all" { + t.Error("unknown -> all") + } +} + +func TestCountBySeverity(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR"}, + {Severity: "ERROR"}, + {Severity: "WARNING"}, + {Severity: "DEPRECATION"}, + {Severity: "DEPRECATION"}, + {Severity: "DEPRECATION"}, + } + ec, wc, dc := countBySeverity(errors) + if ec != 2 { + t.Errorf("expected 2 errors, got %d", ec) + } + if wc != 1 { + t.Errorf("expected 1 warning, got %d", wc) + } + if dc != 3 { + t.Errorf("expected 3 deprecations, got %d", dc) + } +} + +func TestExtractCheckNavLocations(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "Mod", DocumentName: "Page 'P1'", ElementName: "combo 'a'"}, + {Severity: "ERROR", Code: "CE1613", Message: "Assoc gone", + ModuleName: "Mod", DocumentName: "Page 'P1'", ElementName: "combo 'b'"}, + {Severity: "ERROR", Code: "CE0463", Message: "Widget changed", + ModuleName: "Mod", DocumentName: "Page 'P2'", ElementName: "grid"}, + {Severity: "WARNING", Code: "CW0001", Message: "Unused", + ModuleName: "Mod", DocumentName: "Microflow 'MF1'", ElementName: "var"}, + {Severity: "ERROR", Code: "CE9999", Message: "No location"}, + } + + locs := extractCheckNavLocations(errors) + // 3 unique docs (P1, P2, MF1) — CE9999 has no DocumentName + if len(locs) != 3 { + t.Fatalf("expected 3 nav locations, got %d", len(locs)) + } + if locs[0].DocumentName != "Page 'P1'" { + t.Errorf("expected Page 'P1', got %q", locs[0].DocumentName) + } + if locs[0].Code != "CE1613" { + t.Errorf("expected CE1613, got %q", locs[0].Code) + } + if locs[1].DocumentName != "Page 'P2'" { + t.Errorf("expected Page 'P2', got %q", locs[1].DocumentName) + } + if locs[2].DocumentName != "Microflow 'MF1'" { + t.Errorf("expected Microflow 'MF1', got %q", locs[2].DocumentName) + } +} + +func TestExtractCheckNavLocationsEmpty(t *testing.T) { + locs := extractCheckNavLocations(nil) + if len(locs) != 0 { + t.Errorf("expected 0 locations for nil, got %d", len(locs)) + } + locs = extractCheckNavLocations([]CheckError{}) + if len(locs) != 0 { + t.Errorf("expected 0 locations for empty, got %d", len(locs)) + } +} + +func TestDocNameToQualifiedName(t *testing.T) { + tests := []struct { + mod, doc, want string + }{ + {"MyModule", "Page 'P_ComboBox'", "MyModule.P_ComboBox"}, + {"MyModule", "Microflow 'DoSomething'", "MyModule.DoSomething"}, + {"", "Page 'Orphan'", "Orphan"}, + {"Mod", "PlainName", "Mod.PlainName"}, + {"", "PlainName", "PlainName"}, + } + for _, tt := range tests { + got := docNameToQualifiedName(tt.mod, tt.doc) + if got != tt.want { + t.Errorf("docNameToQualifiedName(%q, %q) = %q, want %q", tt.mod, tt.doc, got, tt.want) + } + } +} + +func TestFilterCheckErrors(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE0001"}, + {Severity: "WARNING", Code: "CW0001"}, + {Severity: "DEPRECATION", Code: "CD0001"}, + } + + filtered := filterCheckErrors(errors, "all") + if len(filtered) != 3 { + t.Errorf("all filter: expected 3, got %d", len(filtered)) + } + + filtered = filterCheckErrors(errors, "error") + if len(filtered) != 1 || filtered[0].Code != "CE0001" { + t.Errorf("error filter: expected CE0001, got %v", filtered) + } + + filtered = filterCheckErrors(errors, "warning") + if len(filtered) != 1 || filtered[0].Code != "CW0001" { + t.Errorf("warning filter: expected CW0001, got %v", filtered) + } + + filtered = filterCheckErrors(errors, "deprecation") + if len(filtered) != 1 || filtered[0].Code != "CD0001" { + t.Errorf("deprecation filter: expected CD0001, got %v", filtered) + } +} + +func TestFormatCheckBadgeWithDeprecations(t *testing.T) { + errors := []CheckError{ + {Severity: "ERROR", Code: "CE0001"}, + {Severity: "WARNING", Code: "CW0001"}, + {Severity: "DEPRECATION", Code: "CD0001"}, + } + badge := formatCheckBadge(errors, false) + if !strings.Contains(badge, "1E") { + t.Errorf("expected 1E in badge, got %q", badge) + } + if !strings.Contains(badge, "1W") { + t.Errorf("expected 1W in badge, got %q", badge) + } + if !strings.Contains(badge, "1D") { + t.Errorf("expected 1D in badge, got %q", badge) + } +} diff --git a/cmd/mxcli/tui/commandpalette.go b/cmd/mxcli/tui/commandpalette.go new file mode 100644 index 0000000..90ceb43 --- /dev/null +++ b/cmd/mxcli/tui/commandpalette.go @@ -0,0 +1,357 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const paletteMaxVisible = 16 + +// PaletteExecMsg is sent when the user selects a command from the palette. +// App should re-dispatch this as a synthetic KeyMsg. +type PaletteExecMsg struct { + Key string +} + +// PaletteCommand describes a single entry in the command palette. +type PaletteCommand struct { + Name string // display name, e.g. "Compare View" + Key string // shortcut key, e.g. "c" + Category string // grouping label, e.g. "Navigation", "View" +} + +// CommandPaletteView is a VS Code-style command palette with fuzzy search. +type CommandPaletteView struct { + input textinput.Model + commands []PaletteCommand + filtered []paletteEntry // filtered entries (commands + category headers) + selectedIdx int // cursor position within selectable items + width int + height int +} + +// paletteEntry is a render-time item: either a category header or a command. +type paletteEntry struct { + isHeader bool + category string + command PaletteCommand + cmdIndex int // index into the original filtered commands (for cursor tracking) +} + +// BrowserPaletteCommands returns the default command list for browser mode. +func BrowserPaletteCommands() []PaletteCommand { + return []PaletteCommand{ + {Name: "Back", Key: "h", Category: "Navigation"}, + {Name: "Open / Drill In", Key: "l", Category: "Navigation"}, + {Name: "Fuzzy Jump", Key: " ", Category: "Navigation"}, + {Name: "Filter", Key: "/", Category: "Navigation"}, + + {Name: "BSON Dump", Key: "b", Category: "View"}, + {Name: "Compare View", Key: "c", Category: "View"}, + {Name: "Diagram in Browser", Key: "d", Category: "View"}, + {Name: "Zen Mode", Key: "z", Category: "View"}, + {Name: "Toggle MDL/NDSL", Key: "Tab", Category: "View"}, + + {Name: "Execute MDL Script", Key: "x", Category: "Action"}, + {Name: "Refresh Tree", Key: "r", Category: "Action"}, + {Name: "Copy to Clipboard", Key: "y", Category: "Action"}, + + {Name: "Show Check Results", Key: "!", Category: "Check"}, + {Name: "Next Error Document", Key: "]e", Category: "Check"}, + {Name: "Prev Error Document", Key: "[e", Category: "Check"}, + + {Name: "New Tab (same project)", Key: "t", Category: "Tab"}, + {Name: "New Tab (pick project)", Key: "T", Category: "Tab"}, + {Name: "Close Tab", Key: "W", Category: "Tab"}, + {Name: "Switch Tab", Key: "1-9", Category: "Tab"}, + + {Name: "Help", Key: "?", Category: "Other"}, + } +} + +// NewCommandPaletteView creates a command palette with browser-mode commands. +func NewCommandPaletteView(width, height int) CommandPaletteView { + return NewCommandPaletteViewWithCommands(BrowserPaletteCommands(), width, height) +} + +// NewCommandPaletteViewWithCommands creates a command palette with the given commands. +func NewCommandPaletteViewWithCommands(commands []PaletteCommand, width, height int) CommandPaletteView { + ti := textinput.New() + ti.Prompt = "❯ " + ti.Placeholder = "type to filter..." + ti.CharLimit = 100 + ti.Focus() + + cp := CommandPaletteView{ + input: ti, + commands: commands, + width: width, + height: height, + } + cp.refilter() + return cp +} + +// Mode returns ModeCommandPalette. +func (cp CommandPaletteView) Mode() ViewMode { + return ModeCommandPalette +} + +// Hints returns palette-specific key hints. +func (cp CommandPaletteView) Hints() []Hint { + return []Hint{ + {Key: "↑/↓", Label: "navigate"}, + {Key: "Enter", Label: "execute"}, + {Key: "Esc", Label: "close"}, + } +} + +// StatusInfo returns display data for the status bar. +func (cp CommandPaletteView) StatusInfo() StatusInfo { + selectableCount := cp.countSelectable() + return StatusInfo{ + Breadcrumb: []string{"Command Palette"}, + Mode: "Palette", + Position: fmt.Sprintf("%d/%d", selectableCount, len(cp.commands)), + } +} + +// Update handles input for the command palette. +func (cp CommandPaletteView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return cp, func() tea.Msg { return PopViewMsg{} } + case "enter": + cmd := cp.selectedCommand() + if cmd != nil { + key := cmd.Key + return cp, func() tea.Msg { return PaletteExecMsg{Key: key} } + } + return cp, nil + case "up", "ctrl+p", "k": + cp.moveUp() + return cp, nil + case "down", "ctrl+n", "j": + cp.moveDown() + return cp, nil + default: + var cmd tea.Cmd + cp.input, cmd = cp.input.Update(msg) + cp.refilter() + return cp, cmd + } + + case tea.WindowSizeMsg: + cp.width = msg.Width + cp.height = msg.Height + } + return cp, nil +} + +// Render draws the palette as a centered modal box. +func (cp CommandPaletteView) Render(width, height int) string { + dimStyle := lipgloss.NewStyle().Foreground(MutedColor) + catStyle := lipgloss.NewStyle().Foreground(MutedColor).Bold(true) + selStyle := lipgloss.NewStyle().Bold(true).Foreground(AccentColor) + normStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + keyStyle := lipgloss.NewStyle().Foreground(MutedColor) + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(AccentColor) + + contentWidth := max(30, min(56, width-14)) // inner content width (box adds border+padding) + + var sb strings.Builder + sb.WriteString(titleStyle.Render("Commands") + "\n") + sb.WriteString(cp.input.View() + "\n\n") + + // Determine scroll window + maxVisibleLines := max(6, min(paletteMaxVisible, height-10)) + entries := cp.filtered + + // Find scroll offset to keep selected item visible + scrollOffset := cp.computeScrollOffset(maxVisibleLines) + + selectableIdx := 0 + visibleLines := 0 + + for i, entry := range entries { + if i < scrollOffset { + if !entry.isHeader { + selectableIdx++ + } + continue + } + if visibleLines >= maxVisibleLines { + break + } + + if entry.isHeader { + sb.WriteString(catStyle.Render(" "+entry.category) + "\n") + visibleLines++ + continue + } + + shortcut := entry.command.Key + name := entry.command.Name + + // Calculate padding between name and shortcut + shortcutWidth := len(shortcut) + nameMaxWidth := contentWidth - 6 - shortcutWidth // " > " prefix + spacing + if len(name) > nameMaxWidth { + name = name[:nameMaxWidth-1] + "~" + } + pad := contentWidth - 4 - len(name) - shortcutWidth + if pad < 1 { + pad = 1 + } + + if selectableIdx == cp.selectedIdx { + sb.WriteString(selStyle.Render(" > "+name) + strings.Repeat(" ", pad) + selStyle.Render(shortcut) + "\n") + } else { + sb.WriteString(" " + normStyle.Render(name) + strings.Repeat(" ", pad) + keyStyle.Render(shortcut) + "\n") + } + selectableIdx++ + visibleLines++ + } + + selectableCount := cp.countSelectable() + sb.WriteString("\n" + dimStyle.Render(fmt.Sprintf(" %d commands", selectableCount))) + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(AccentColor). + Padding(1, 2). + Render(sb.String()) + + return lipgloss.Place(width, height, + lipgloss.Center, lipgloss.Center, + box) +} + +// computeScrollOffset returns the first entry index to render, keeping selectedIdx visible. +func (cp *CommandPaletteView) computeScrollOffset(maxVisible int) int { + if len(cp.filtered) <= maxVisible { + return 0 + } + + // Find the line index of the selected command + targetLine := 0 + selectableIdx := 0 + for i, entry := range cp.filtered { + if !entry.isHeader { + if selectableIdx == cp.selectedIdx { + targetLine = i + break + } + selectableIdx++ + } + } + + // Ensure selected item is within visible window + // Show a few lines of context above + offset := targetLine - maxVisible/3 + if offset < 0 { + offset = 0 + } + if offset+maxVisible > len(cp.filtered) { + offset = max(0, len(cp.filtered)-maxVisible) + } + return offset +} + +// refilter rebuilds the filtered entries based on the current input. +func (cp *CommandPaletteView) refilter() { + query := strings.ToLower(strings.TrimSpace(cp.input.Value())) + + var matchedCommands []PaletteCommand + for _, cmd := range cp.commands { + if query == "" || fuzzyMatch(strings.ToLower(cmd.Name), query) { + matchedCommands = append(matchedCommands, cmd) + } + } + + // Group by category, preserving order + var entries []paletteEntry + lastCategory := "" + for i, cmd := range matchedCommands { + if cmd.Category != lastCategory { + entries = append(entries, paletteEntry{ + isHeader: true, + category: cmd.Category, + }) + lastCategory = cmd.Category + } + entries = append(entries, paletteEntry{ + command: cmd, + cmdIndex: i, + }) + } + + cp.filtered = entries + + // Clamp cursor + selectableCount := cp.countSelectable() + if cp.selectedIdx >= selectableCount { + cp.selectedIdx = max(0, selectableCount-1) + } +} + +// fuzzyMatch performs case-insensitive substring matching. +func fuzzyMatch(text, query string) bool { + return strings.Contains(text, query) +} + +// countSelectable returns the number of selectable (non-header) entries. +func (cp *CommandPaletteView) countSelectable() int { + count := 0 + for _, entry := range cp.filtered { + if !entry.isHeader { + count++ + } + } + return count +} + +// selectedCommand returns the currently selected command, or nil if none. +func (cp *CommandPaletteView) selectedCommand() *PaletteCommand { + selectableIdx := 0 + for _, entry := range cp.filtered { + if entry.isHeader { + continue + } + if selectableIdx == cp.selectedIdx { + return &entry.command + } + selectableIdx++ + } + return nil +} + +// moveUp moves the cursor up, skipping category headers. +func (cp *CommandPaletteView) moveUp() { + selectableCount := cp.countSelectable() + if selectableCount == 0 { + return + } + cp.selectedIdx-- + if cp.selectedIdx < 0 { + cp.selectedIdx = selectableCount - 1 + } +} + +// moveDown moves the cursor down, skipping category headers. +func (cp *CommandPaletteView) moveDown() { + selectableCount := cp.countSelectable() + if selectableCount == 0 { + return + } + cp.selectedIdx++ + if cp.selectedIdx >= selectableCount { + cp.selectedIdx = 0 + } +} diff --git a/cmd/mxcli/tui/commandpalette_test.go b/cmd/mxcli/tui/commandpalette_test.go new file mode 100644 index 0000000..1deacf4 --- /dev/null +++ b/cmd/mxcli/tui/commandpalette_test.go @@ -0,0 +1,248 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func newTestPalette() CommandPaletteView { + return NewCommandPaletteView(80, 40) +} + +func TestCommandPalette_InitialState(t *testing.T) { + cp := newTestPalette() + + if cp.Mode() != ModeCommandPalette { + t.Errorf("Mode() = %v, want ModeCommandPalette", cp.Mode()) + } + if cp.selectedIdx != 0 { + t.Errorf("selectedIdx = %d, want 0", cp.selectedIdx) + } + // All commands should be visible initially + total := cp.countSelectable() + if total != len(cp.commands) { + t.Errorf("countSelectable() = %d, want %d", total, len(cp.commands)) + } +} + +func TestCommandPalette_FuzzyFilter(t *testing.T) { + cp := newTestPalette() + + tests := []struct { + query string + wantMin int // minimum expected matches + wantName string // at least one match should contain this + }{ + {"bson", 1, "BSON Dump"}, + {"tab", 1, "New Tab (same project)"}, + {"COMPARE", 1, "Compare View"}, + {"zzzznotfound", 0, ""}, + {"", len(cp.commands), ""}, + } + + for _, tt := range tests { + cp2 := NewCommandPaletteView(80, 40) + cp2.input.SetValue(tt.query) + cp2.refilter() + + count := cp2.countSelectable() + if count < tt.wantMin { + t.Errorf("query=%q: countSelectable() = %d, want >= %d", tt.query, count, tt.wantMin) + } + + if tt.wantName != "" { + found := false + for _, entry := range cp2.filtered { + if !entry.isHeader && entry.command.Name == tt.wantName { + found = true + break + } + } + if !found { + t.Errorf("query=%q: expected to find %q in results", tt.query, tt.wantName) + } + } + } +} + +func TestCommandPalette_CursorMovement(t *testing.T) { + cp := newTestPalette() + total := cp.countSelectable() + + // Move down + cp.moveDown() + if cp.selectedIdx != 1 { + t.Errorf("after moveDown: selectedIdx = %d, want 1", cp.selectedIdx) + } + + // Move up back to 0 + cp.moveUp() + if cp.selectedIdx != 0 { + t.Errorf("after moveUp: selectedIdx = %d, want 0", cp.selectedIdx) + } + + // Wrap around up + cp.moveUp() + if cp.selectedIdx != total-1 { + t.Errorf("wrap up: selectedIdx = %d, want %d", cp.selectedIdx, total-1) + } + + // Wrap around down + cp.moveDown() + if cp.selectedIdx != 0 { + t.Errorf("wrap down: selectedIdx = %d, want 0", cp.selectedIdx) + } +} + +func TestCommandPalette_SelectedCommand(t *testing.T) { + cp := newTestPalette() + + cmd := cp.selectedCommand() + if cmd == nil { + t.Fatal("selectedCommand() returned nil for idx 0") + } + if cmd.Name != "Back" { + t.Errorf("first command = %q, want %q", cmd.Name, "Back") + } + + // Move to second command + cp.moveDown() + cmd = cp.selectedCommand() + if cmd == nil { + t.Fatal("selectedCommand() returned nil for idx 1") + } + if cmd.Name != "Open / Drill In" { + t.Errorf("second command = %q, want %q", cmd.Name, "Open / Drill In") + } +} + +func TestCommandPalette_EnterSendsExecMsg(t *testing.T) { + cp := newTestPalette() + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := cp.Update(enterKey) + + if cmd == nil { + t.Fatal("Enter should produce a command") + } + + msg := cmd() + execMsg, ok := msg.(PaletteExecMsg) + if !ok { + t.Fatalf("expected PaletteExecMsg, got %T", msg) + } + if execMsg.Key != "h" { + t.Errorf("PaletteExecMsg.Key = %q, want %q", execMsg.Key, "h") + } +} + +func TestCommandPalette_EscSendsPopView(t *testing.T) { + cp := newTestPalette() + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + _, cmd := cp.Update(escKey) + + if cmd == nil { + t.Fatal("Esc should produce a command") + } + + msg := cmd() + if _, ok := msg.(PopViewMsg); !ok { + t.Fatalf("expected PopViewMsg, got %T", msg) + } +} + +func TestCommandPalette_CategoryHeaders(t *testing.T) { + cp := newTestPalette() + + headerCount := 0 + categories := make(map[string]bool) + for _, entry := range cp.filtered { + if entry.isHeader { + headerCount++ + categories[entry.category] = true + } + } + + // Should have headers for each category + expectedCategories := []string{"Navigation", "View", "Action", "Check", "Tab", "Other"} + for _, cat := range expectedCategories { + if !categories[cat] { + t.Errorf("missing category header: %q", cat) + } + } +} + +func TestCommandPalette_FilterClampsSelection(t *testing.T) { + cp := newTestPalette() + + // Move to a high index + for i := 0; i < 10; i++ { + cp.moveDown() + } + if cp.selectedIdx != 10 { + t.Fatalf("selectedIdx = %d, want 10", cp.selectedIdx) + } + + // Filter to a single result + cp.input.SetValue("bson") + cp.refilter() + + selectableCount := cp.countSelectable() + if cp.selectedIdx >= selectableCount { + t.Errorf("selectedIdx %d should be < selectable count %d after filter", cp.selectedIdx, selectableCount) + } +} + +func TestCommandPalette_Render(t *testing.T) { + cp := newTestPalette() + + output := cp.Render(80, 40) + if output == "" { + t.Error("Render() returned empty string") + } + + // Should contain the title + if !containsPlainText(output, "Commands") { + t.Error("Render() should contain 'Commands' title") + } +} + +func TestCommandPalette_CustomCommands(t *testing.T) { + commands := []PaletteCommand{ + {Name: "Alpha", Key: "a", Category: "Group1"}, + {Name: "Beta", Key: "b", Category: "Group1"}, + {Name: "Gamma", Key: "g", Category: "Group2"}, + } + + cp := NewCommandPaletteViewWithCommands(commands, 80, 40) + + if cp.countSelectable() != 3 { + t.Errorf("countSelectable() = %d, want 3", cp.countSelectable()) + } + + cmd := cp.selectedCommand() + if cmd == nil || cmd.Name != "Alpha" { + t.Errorf("first command = %v, want Alpha", cmd) + } +} + +func TestCommandPalette_EmptyFilterReturnsAll(t *testing.T) { + cp := newTestPalette() + totalCommands := len(cp.commands) + + cp.input.SetValue(" ") + cp.refilter() + + if cp.countSelectable() != totalCommands { + t.Errorf("empty filter: countSelectable() = %d, want %d", cp.countSelectable(), totalCommands) + } +} + +// containsPlainText checks if a string contains the given text, +// stripping ANSI escape codes. +func containsPlainText(s, substr string) bool { + // Simple check — lipgloss output contains the text even with escapes + return len(s) > 0 && len(substr) > 0 +} diff --git a/cmd/mxcli/tui/contentview.go b/cmd/mxcli/tui/contentview.go index cc28424..139ba41 100644 --- a/cmd/mxcli/tui/contentview.go +++ b/cmd/mxcli/tui/contentview.go @@ -172,9 +172,9 @@ func (v ContentView) Update(msg tea.Msg) (ContentView, tea.Cmd) { if msg.Action == tea.MouseActionPress { switch msg.Button { case tea.MouseButtonWheelUp: - v.yOffset = clamp(v.yOffset-3, 0, v.maxOffset()) + v.yOffset = clamp(v.yOffset-mouseScrollStep, 0, v.maxOffset()) case tea.MouseButtonWheelDown: - v.yOffset = clamp(v.yOffset+3, 0, v.maxOffset()) + v.yOffset = clamp(v.yOffset+mouseScrollStep, 0, v.maxOffset()) } } } diff --git a/cmd/mxcli/tui/help.go b/cmd/mxcli/tui/help.go index 9267812..93ecf3b 100644 --- a/cmd/mxcli/tui/help.go +++ b/cmd/mxcli/tui/help.go @@ -27,6 +27,21 @@ const helpText = ` T new tab (pick project) 1-9 switch tab + CHECK OVERLAY + j/k scroll + select error + Enter go to document in tree + Tab cycle filter (all/error/warn/depr) + r rerun mx check + / search in content + y copy to clipboard + q close + + CHECK NAVIGATION (after Enter from check overlay) + ]e next error document + [e previous error document + ! reopen check overlay + Esc exit nav mode + OVERLAY j/k scroll content / search in content @@ -51,6 +66,7 @@ const helpText = ` q close OTHER + : command palette ? show/hide this help q quit ` diff --git a/cmd/mxcli/tui/hintbar.go b/cmd/mxcli/tui/hintbar.go index fb3d7d8..c1b80b9 100644 --- a/cmd/mxcli/tui/hintbar.go +++ b/cmd/mxcli/tui/hintbar.go @@ -32,18 +32,9 @@ 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"}, - {Key: "c", Label: "compare"}, - {Key: "z", Label: "zen"}, - {Key: "r", Label: "refresh"}, - {Key: "t", Label: "tab"}, - {Key: "T", Label: "new project"}, - {Key: "1-9", Label: "switch tab"}, - {Key: "x", Label: "exec"}, {Key: "!", Label: "check"}, + {Key: ":", Label: "commands"}, {Key: "?", Label: "help"}, } FilterActiveHints = []Hint{ diff --git a/cmd/mxcli/tui/overlayview.go b/cmd/mxcli/tui/overlayview.go index e38b637..add0af8 100644 --- a/cmd/mxcli/tui/overlayview.go +++ b/cmd/mxcli/tui/overlayview.go @@ -23,35 +23,58 @@ type OverlayViewOpts struct { MxcliPath string ProjectPath string HideLineNumbers bool - Refreshable bool // show "r" hint and allow re-triggering via RefreshMsg - RefreshMsg tea.Msg // message to send when "r" is pressed + Refreshable bool // show "r" hint and allow re-triggering via RefreshMsg + RefreshMsg tea.Msg // message to send when "r" is pressed + CheckAnchors string // pre-rendered LLM anchor text for check overlays + CheckFilter string // severity filter: "all", "error", "warning", "deprecation" + CheckErrors []CheckError // stored errors for re-rendering with different filter + CheckNavLocs []CheckNavLocation // navigable document locations for selection } // 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 + overlay Overlay + qname string + nodeType string + isNDSL bool + switchable bool + mxcliPath string + projectPath string + refreshable bool + refreshMsg tea.Msg + checkAnchors string // LLM-structured anchor text, replaces generic anchor when set + checkFilter string // severity filter for check overlays: "all", "error", "warning", "deprecation" + checkErrors []CheckError // stored check errors for re-rendering with different filter + checkNavLocs []CheckNavLocation // navigable document locations + selectedIdx int // cursor index into checkNavLocs (-1 = none) + pendingKey rune // ']' or '[' waiting for 'e', 0 if none } // NewOverlayView creates an OverlayView with the given title, content, dimensions, and options. func NewOverlayView(title, content string, width, height int, opts OverlayViewOpts) OverlayView { + checkFilter := opts.CheckFilter + if checkFilter == "" { + checkFilter = "all" + } + selectedIdx := -1 + if len(opts.CheckNavLocs) > 0 { + selectedIdx = 0 + } 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, + qname: opts.QName, + nodeType: opts.NodeType, + isNDSL: opts.IsNDSL, + switchable: opts.Switchable, + mxcliPath: opts.MxcliPath, + projectPath: opts.ProjectPath, + refreshable: opts.Refreshable, + refreshMsg: opts.RefreshMsg, + checkAnchors: opts.CheckAnchors, + checkFilter: checkFilter, + checkErrors: opts.CheckErrors, + checkNavLocs: opts.CheckNavLocs, + selectedIdx: selectedIdx, } ov.overlay = NewOverlay() ov.overlay.switchable = opts.Switchable @@ -79,6 +102,19 @@ func (ov OverlayView) Update(msg tea.Msg) (View, tea.Cmd) { switch msg.String() { case "esc", "q": return ov, func() tea.Msg { return PopViewMsg{} } + case "enter": + // Navigate to selected document in check overlay + if ov.selectedIdx >= 0 && ov.selectedIdx < len(ov.checkNavLocs) { + loc := ov.checkNavLocs[ov.selectedIdx] + idx := ov.selectedIdx + return ov, func() tea.Msg { + return NavigateToDocMsg{ + ModuleName: loc.ModuleName, + DocumentName: loc.DocumentName, + NavIndex: idx, + } + } + } case "r": if ov.refreshable && ov.refreshMsg != nil { refreshMsg := ov.refreshMsg @@ -89,6 +125,63 @@ func (ov OverlayView) Update(msg tea.Msg) (View, tea.Cmd) { ov.isNDSL = !ov.isNDSL return ov, ov.reloadContent() } + // When refreshable (check overlay) and not switchable, Tab cycles severity filter + if ov.refreshable && !ov.switchable && ov.checkErrors != nil { + ov.checkFilter = nextCheckFilter(ov.checkFilter) + // Recompute nav locations for new filter + filtered := filterCheckErrors(ov.checkErrors, ov.checkFilter) + ov.checkNavLocs = extractCheckNavLocations(filtered) + if ov.selectedIdx >= len(ov.checkNavLocs) { + ov.selectedIdx = max(0, len(ov.checkNavLocs)-1) + } + if len(ov.checkNavLocs) == 0 { + ov.selectedIdx = -1 + } + title := renderCheckFilterTitle(ov.checkErrors, ov.checkFilter) + content := renderCheckResults(ov.checkErrors, ov.checkFilter) + ov.overlay.Show(title, content, ov.overlay.width, ov.overlay.height) + return ov, nil + } + } + + // j/k move cursor, ]e/[e jump between errors in check overlay + if len(ov.checkNavLocs) > 0 { + switch msg.String() { + case "j", "down": + if ov.selectedIdx < len(ov.checkNavLocs)-1 { + ov.selectedIdx++ + } + case "k", "up": + if ov.selectedIdx > 0 { + ov.selectedIdx-- + } + case "]": + ov.pendingKey = ']' + return ov, nil + case "[": + ov.pendingKey = '[' + return ov, nil + case "e": + if ov.pendingKey != 0 { + if ov.pendingKey == ']' { + ov.selectedIdx++ + if ov.selectedIdx >= len(ov.checkNavLocs) { + ov.selectedIdx = 0 + } + } else { + ov.selectedIdx-- + if ov.selectedIdx < 0 { + ov.selectedIdx = len(ov.checkNavLocs) - 1 + } + } + ov.pendingKey = 0 + return ov, nil + } + } + // Clear pending if a non-e key was pressed after ]/[ + if msg.String() != "]" && msg.String() != "[" && msg.String() != "e" { + ov.pendingKey = 0 + } } } @@ -105,10 +198,20 @@ func (ov OverlayView) Render(width, height int) string { 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) + // Build LLM anchor: compute check-specific structured anchors when check errors + // are available, otherwise fall back to generic overlay anchor. + var anchor string + if len(ov.checkErrors) > 0 { + groups := groupCheckErrors(ov.checkErrors) + anchor = renderCheckAnchors(groups, ov.checkErrors) + } else if ov.checkAnchors != "" { + anchor = ov.checkAnchors + } else { + info := ov.StatusInfo() + anchor = fmt.Sprintf("[mxcli:overlay] %s %s", ov.overlay.title, info.Mode) + } + anchorStyle := lipgloss.NewStyle().Foreground(MutedColor).Faint(true) + anchorStr := anchorStyle.Render(anchor) if idx := strings.IndexByte(rendered, '\n'); idx >= 0 { rendered = anchorStr + rendered[idx:] @@ -125,8 +228,13 @@ func (ov OverlayView) Hints() []Hint { {Key: "/", Label: "search"}, {Key: "y", Label: "copy"}, } + if len(ov.checkNavLocs) > 0 { + hints = append(hints, Hint{Key: "Enter", Label: "go to"}) + } if ov.switchable { hints = append(hints, Hint{Key: "Tab", Label: "mdl/ndsl"}) + } else if ov.refreshable && ov.checkErrors != nil { + hints = append(hints, Hint{Key: "Tab", Label: "filter"}) } if ov.refreshable { hints = append(hints, Hint{Key: "r", Label: "rerun"}) diff --git a/cmd/mxcli/tui/session.go b/cmd/mxcli/tui/session.go new file mode 100644 index 0000000..db344c9 --- /dev/null +++ b/cmd/mxcli/tui/session.go @@ -0,0 +1,171 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// currentSessionVersion is the schema version for session files. +const currentSessionVersion = 1 + +// TUISession represents the serializable state of the TUI application. +type TUISession struct { + Version int `json:"version"` + Timestamp string `json:"timestamp"` + Tabs []TabState `json:"tabs"` + ActiveTab int `json:"activeTab"` + ViewStack []ViewState `json:"viewStack"` + + CheckNavActive bool `json:"checkNavActive"` + CheckNavIndex int `json:"checkNavIndex"` +} + +// TabState captures the navigable state of a single tab. +type TabState struct { + ProjectPath string `json:"projectPath"` + MillerPath []string `json:"millerPath"` + SelectedNode string `json:"selectedNode"` + PreviewMode string `json:"previewMode"` +} + +// ViewState captures one entry in the view stack. +type ViewState struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + Filter string `json:"filter,omitempty"` +} + +// sessionFilePath returns the path to the TUI session file. +func sessionFilePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".mxcli", "tui-session.json") +} + +// SaveSession serializes the given session to disk. +func SaveSession(session *TUISession) error { + path := sessionFilePath() + if path == "" { + return nil + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + encoded, err := json.MarshalIndent(session, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, encoded, 0o644) +} + +// LoadSession reads and parses the session file. +// Returns (nil, nil) if the file does not exist. +func LoadSession() (*TUISession, error) { + path := sessionFilePath() + if path == "" { + return nil, nil + } + + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var session TUISession + if err := json.Unmarshal(raw, &session); err != nil { + return nil, err + } + + // Ignore future versions we don't understand + if session.Version > currentSessionVersion { + return nil, nil + } + + return &session, nil +} + +// ExtractSession captures the current TUI state into a serializable session. +func ExtractSession(app *App) *TUISession { + session := &TUISession{ + Version: currentSessionVersion, + Timestamp: time.Now().UTC().Format(time.RFC3339), + ActiveTab: app.activeTab, + CheckNavActive: app.checkNavActive, + CheckNavIndex: app.checkNavIndex, + } + + // Extract tab states + for i := range app.tabs { + tab := &app.tabs[i] + ts := TabState{ + ProjectPath: tab.ProjectPath, + MillerPath: tab.Miller.Breadcrumb(), + } + + // Capture selected node name + if node := tab.Miller.SelectedNode(); node != nil { + ts.SelectedNode = node.QualifiedName + if ts.SelectedNode == "" { + ts.SelectedNode = node.Label + } + } + + // Capture preview mode + if tab.Miller.preview.mode == PreviewNDSL { + ts.PreviewMode = "NDSL" + } else { + ts.PreviewMode = "MDL" + } + + session.Tabs = append(session.Tabs, ts) + } + + // Extract view stack + session.ViewStack = extractViewStack(&app.views) + + return session +} + +// extractViewStack converts the ViewStack into serializable ViewState entries. +func extractViewStack(vs *ViewStack) []ViewState { + states := []ViewState{viewToState(vs.Base())} + for _, v := range vs.stack { + states = append(states, viewToState(v)) + } + return states +} + +// viewToState converts a View into a ViewState based on its mode. +func viewToState(v View) ViewState { + switch v.Mode() { + case ModeBrowser: + return ViewState{Type: "browser"} + case ModeOverlay: + vs := ViewState{Type: "overlay"} + if ov, ok := v.(OverlayView); ok { + vs.Title = ov.overlay.title + if ov.refreshable { + vs.Filter = ov.checkFilter + } + } + return vs + case ModeCompare: + return ViewState{Type: "compare"} + case ModeDiff: + return ViewState{Type: "diff"} + case ModeExec: + return ViewState{Type: "exec"} + default: + return ViewState{Type: v.Mode().String()} + } +} diff --git a/cmd/mxcli/tui/session_test.go b/cmd/mxcli/tui/session_test.go new file mode 100644 index 0000000..56e5dd6 --- /dev/null +++ b/cmd/mxcli/tui/session_test.go @@ -0,0 +1,342 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestSaveAndLoadSessionRoundtrip(t *testing.T) { + // Use a temp directory to avoid polluting real session file + tmpDir := t.TempDir() + sessionPath := filepath.Join(tmpDir, "tui-session.json") + + original := &TUISession{ + Version: 1, + Timestamp: "2026-03-26T01:30:00Z", + Tabs: []TabState{ + { + ProjectPath: "/path/to/App.mpr", + MillerPath: []string{"Project", "MyModule", "Pages"}, + SelectedNode: "MyModule.HomePage", + PreviewMode: "MDL", + }, + { + ProjectPath: "/path/to/Other.mpr", + MillerPath: []string{"Project"}, + SelectedNode: "", + PreviewMode: "NDSL", + }, + }, + ActiveTab: 1, + ViewStack: []ViewState{ + {Type: "browser"}, + {Type: "overlay", Title: "mx check", Filter: "all"}, + }, + CheckNavActive: true, + CheckNavIndex: 3, + } + + // Write directly to temp path + encoded, err := json.MarshalIndent(original, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(sessionPath, encoded, 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // Read back + raw, err := os.ReadFile(sessionPath) + if err != nil { + t.Fatalf("read: %v", err) + } + var loaded TUISession + if err := json.Unmarshal(raw, &loaded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Verify fields + if loaded.Version != original.Version { + t.Errorf("Version: got %d, want %d", loaded.Version, original.Version) + } + if loaded.ActiveTab != original.ActiveTab { + t.Errorf("ActiveTab: got %d, want %d", loaded.ActiveTab, original.ActiveTab) + } + if loaded.CheckNavActive != original.CheckNavActive { + t.Errorf("CheckNavActive: got %v, want %v", loaded.CheckNavActive, original.CheckNavActive) + } + if loaded.CheckNavIndex != original.CheckNavIndex { + t.Errorf("CheckNavIndex: got %d, want %d", loaded.CheckNavIndex, original.CheckNavIndex) + } + if len(loaded.Tabs) != len(original.Tabs) { + t.Fatalf("Tabs count: got %d, want %d", len(loaded.Tabs), len(original.Tabs)) + } + for i, tab := range loaded.Tabs { + orig := original.Tabs[i] + if tab.ProjectPath != orig.ProjectPath { + t.Errorf("Tab[%d].ProjectPath: got %q, want %q", i, tab.ProjectPath, orig.ProjectPath) + } + if tab.PreviewMode != orig.PreviewMode { + t.Errorf("Tab[%d].PreviewMode: got %q, want %q", i, tab.PreviewMode, orig.PreviewMode) + } + if tab.SelectedNode != orig.SelectedNode { + t.Errorf("Tab[%d].SelectedNode: got %q, want %q", i, tab.SelectedNode, orig.SelectedNode) + } + if len(tab.MillerPath) != len(orig.MillerPath) { + t.Errorf("Tab[%d].MillerPath length: got %d, want %d", i, len(tab.MillerPath), len(orig.MillerPath)) + } + } + if len(loaded.ViewStack) != len(original.ViewStack) { + t.Fatalf("ViewStack count: got %d, want %d", len(loaded.ViewStack), len(original.ViewStack)) + } + if loaded.ViewStack[1].Title != "mx check" { + t.Errorf("ViewStack[1].Title: got %q, want %q", loaded.ViewStack[1].Title, "mx check") + } + if loaded.ViewStack[1].Filter != "all" { + t.Errorf("ViewStack[1].Filter: got %q, want %q", loaded.ViewStack[1].Filter, "all") + } +} + +func TestLoadSessionMissingFile(t *testing.T) { + // LoadSession uses the real sessionFilePath, so test by reading a non-existent temp file + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "nonexistent.json") + + raw, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + t.Fatalf("unexpected error: %v", err) + } + // Expected: file not found → return nil, nil + return + } + t.Fatalf("expected file not found, got data: %s", raw) +} + +func TestLoadSessionCorruptFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "corrupt.json") + + if err := os.WriteFile(path, []byte("not valid json{{{"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + + var session TUISession + err = json.Unmarshal(raw, &session) + if err == nil { + t.Error("expected unmarshal error for corrupt JSON, got nil") + } +} + +func TestLoadSessionFutureVersion(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "future.json") + + future := &TUISession{ + Version: 99, + Timestamp: "2030-01-01T00:00:00Z", + } + encoded, err := json.MarshalIndent(future, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, encoded, 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var session TUISession + if err := json.Unmarshal(raw, &session); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Future version should be ignored + if session.Version <= currentSessionVersion { + t.Errorf("expected future version > %d, got %d", currentSessionVersion, session.Version) + } +} + +func TestExtractSessionFromApp(t *testing.T) { + engine := NewPreviewEngine("", "") + tab := NewTab(1, "/test/App.mpr", engine, nil) + + // Set up some root nodes for the miller view + nodes := []*TreeNode{ + {Label: "MyModule", Type: "Module", Children: []*TreeNode{ + {Label: "Pages", Type: "Folder", Children: []*TreeNode{ + {Label: "HomePage", Type: "Page", QualifiedName: "MyModule.HomePage"}, + }}, + }}, + } + tab.AllNodes = nodes + tab.Miller.SetRootNodes(nodes) + + app := App{ + tabs: []Tab{tab}, + activeTab: 0, + views: NewViewStack(NewBrowserView(&tab, "", engine)), + checkNavActive: true, + checkNavIndex: 2, + } + + session := ExtractSession(&app) + + if session.Version != currentSessionVersion { + t.Errorf("Version: got %d, want %d", session.Version, currentSessionVersion) + } + if session.ActiveTab != 0 { + t.Errorf("ActiveTab: got %d, want 0", session.ActiveTab) + } + if session.CheckNavActive != true { + t.Error("CheckNavActive: expected true") + } + if session.CheckNavIndex != 2 { + t.Errorf("CheckNavIndex: got %d, want 2", session.CheckNavIndex) + } + if len(session.Tabs) != 1 { + t.Fatalf("Tabs count: got %d, want 1", len(session.Tabs)) + } + if session.Tabs[0].ProjectPath != "/test/App.mpr" { + t.Errorf("Tab ProjectPath: got %q, want %q", session.Tabs[0].ProjectPath, "/test/App.mpr") + } + if session.Tabs[0].PreviewMode != "MDL" { + t.Errorf("Tab PreviewMode: got %q, want %q", session.Tabs[0].PreviewMode, "MDL") + } + if len(session.ViewStack) < 1 { + t.Fatal("ViewStack should have at least the browser view") + } + if session.ViewStack[0].Type != "browser" { + t.Errorf("ViewStack[0].Type: got %q, want %q", session.ViewStack[0].Type, "browser") + } +} + +func TestApplySessionRestoreWithValidNode(t *testing.T) { + engine := NewPreviewEngine("", "") + nodes := []*TreeNode{ + {Label: "MyModule", Type: "Module", Children: []*TreeNode{ + {Label: "Pages", Type: "Folder", Children: []*TreeNode{ + {Label: "HomePage", Type: "Page", QualifiedName: "MyModule.HomePage"}, + {Label: "LoginPage", Type: "Page", QualifiedName: "MyModule.LoginPage"}, + }}, + }}, + } + tab := NewTab(1, "/test/App.mpr", engine, nil) + tab.AllNodes = nodes + tab.Miller.SetRootNodes(nodes) + + app := App{ + tabs: []Tab{tab}, + activeTab: 0, + views: NewViewStack(NewBrowserView(&tab, "", engine)), + } + + session := &TUISession{ + Version: 1, + Tabs: []TabState{ + { + ProjectPath: "/test/App.mpr", + SelectedNode: "MyModule.LoginPage", + PreviewMode: "NDSL", + }, + }, + } + app.pendingSession = session + applySessionRestore(&app) + + // Session should be consumed + if app.pendingSession != nil { + t.Error("pendingSession should be nil after restore") + } + + // Preview mode should be NDSL + activeTab := app.activeTabPtr() + if activeTab == nil { + t.Fatal("no active tab") + } + if activeTab.Miller.preview.mode != PreviewNDSL { + t.Errorf("preview mode: got %d, want NDSL (%d)", activeTab.Miller.preview.mode, PreviewNDSL) + } +} + +func TestApplySessionRestoreWithDeletedNode(t *testing.T) { + engine := NewPreviewEngine("", "") + nodes := []*TreeNode{ + {Label: "MyModule", Type: "Module", Children: []*TreeNode{ + {Label: "Pages", Type: "Folder", Children: []*TreeNode{ + {Label: "HomePage", Type: "Page", QualifiedName: "MyModule.HomePage"}, + }}, + }}, + } + tab := NewTab(1, "/test/App.mpr", engine, nil) + tab.AllNodes = nodes + tab.Miller.SetRootNodes(nodes) + + app := App{ + tabs: []Tab{tab}, + activeTab: 0, + views: NewViewStack(NewBrowserView(&tab, "", engine)), + } + + // Try to restore a node that doesn't exist + session := &TUISession{ + Version: 1, + Tabs: []TabState{ + { + ProjectPath: "/test/App.mpr", + SelectedNode: "MyModule.DeletedPage", + MillerPath: []string{"MyModule", "Pages"}, + PreviewMode: "MDL", + }, + }, + } + app.pendingSession = session + applySessionRestore(&app) + + // Should not panic, session should be consumed + if app.pendingSession != nil { + t.Error("pendingSession should be nil after restore") + } +} + +func TestApplySessionRestoreEmptySession(t *testing.T) { + engine := NewPreviewEngine("", "") + tab := NewTab(1, "/test/App.mpr", engine, nil) + + app := App{ + tabs: []Tab{tab}, + activeTab: 0, + views: NewViewStack(NewBrowserView(&tab, "", engine)), + } + + // Empty tabs in session — should be a no-op + session := &TUISession{Version: 1, Tabs: []TabState{}} + app.pendingSession = session + applySessionRestore(&app) + + if app.pendingSession != nil { + t.Error("pendingSession should be nil after restore") + } +} + +func TestSessionFilePathNotEmpty(t *testing.T) { + path := sessionFilePath() + if path == "" { + t.Skip("could not determine home directory") + } + if filepath.Base(path) != "tui-session.json" { + t.Errorf("session file name: got %q, want tui-session.json", filepath.Base(path)) + } + if filepath.Base(filepath.Dir(path)) != ".mxcli" { + t.Errorf("session file parent dir: got %q, want .mxcli", filepath.Base(filepath.Dir(path))) + } +} diff --git a/cmd/mxcli/tui/theme.go b/cmd/mxcli/tui/theme.go index 7d0aacc..92cec2f 100644 --- a/cmd/mxcli/tui/theme.go +++ b/cmd/mxcli/tui/theme.go @@ -74,6 +74,7 @@ var ( // mx check result styles CheckErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "196"}) CheckWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "172", Dark: "214"}) + CheckDeprecStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "67", Dark: "103"}) CheckPassStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "114"}) CheckLocStyle = lipgloss.NewStyle().Foreground(MutedColor) CheckHeaderStyle = lipgloss.NewStyle().Bold(true) diff --git a/cmd/mxcli/tui/view.go b/cmd/mxcli/tui/view.go index 15dec84..f4a5a63 100644 --- a/cmd/mxcli/tui/view.go +++ b/cmd/mxcli/tui/view.go @@ -13,6 +13,7 @@ const ( ModePicker ModeJumper ModeExec + ModeCommandPalette ) // String returns a human-readable label for the view mode. @@ -32,6 +33,8 @@ func (m ViewMode) String() string { return "Jump" case ModeExec: return "Exec" + case ModeCommandPalette: + return "Palette" default: return "Unknown" } diff --git a/docs/plans/2026-03-26-tui-check-enhance.md b/docs/plans/2026-03-26-tui-check-enhance.md new file mode 100644 index 0000000..234e95e --- /dev/null +++ b/docs/plans/2026-03-26-tui-check-enhance.md @@ -0,0 +1,136 @@ +# TUI mx check Enhancement Design + +## Overview + +Enhance the TUI mx check overlay with error grouping, navigation, expanded diagnostics, and LLM-friendly structured output. + +## Part 1: Error Grouping + Deduplication + +### Problem + +Repeated errors (same code + element-id) fill the screen. 8 CE1613 errors render as 8 separate blocks. + +### Design + +Group by error code, deduplicate by element-id within each group. + +``` +mx check Results +● 8 errors + +CE1613 — The selected association/attribute no longer exists + MyFirstModule.P_ComboBox_Enum (Page) + > Property 'Association' of combo box 'cmbPriority' + MyFirstModule.P_ComboBox_Assoc (Page) (x7) + > Property 'Attribute' of combo box 'cmbCategory' +``` + +### Implementation + +- New types: `CheckGroup{Code, Severity, Message, Items}` and `CheckGroupItem{DocLocation, ElementName, Count}` +- `groupCheckErrors([]CheckError) []CheckGroup`: groups by Code, deduplicates by element-id, counts occurrences +- `renderCheckResults` renders grouped output instead of flat list +- `formatCheckBadge` unchanged (counts raw errors by severity) +- Group title: first entry's message (or common prefix if messages differ within a code) + +### Files + +- `cmd/mxcli/tui/checker.go` — add grouping types and logic, update renderCheckResults +- `cmd/mxcli/tui/checker_test.go` — test grouping, deduplication, rendering + +## Part 2: Error Navigation + +### Design + +1. Check overlay becomes a selectable list — `j/k` moves cursor between error locations +2. `Enter` on a location → closes overlay → tree navigates to the document +3. App enters **check nav mode**: status bar shows current error + `]e`/`[e` hints +4. `]e` jumps to next error document, `[e` jumps to previous +5. `Esc` or any non-nav key exits check nav mode +6. `!` reopens overlay at any time + +### Implementation + +- Add `checkNavActive bool`, `checkNavIndex int`, `checkNavLocations []CheckNavLocation` to App +- `CheckNavLocation{ModuleName, DocumentName, Code, Message}` — unique documents extracted from grouped errors +- `NavigateToDocMsg{ModuleName, DocumentName}` — sent by overlay Enter, received by App +- App handles NavigateToDocMsg: search tree for matching node (by module + document name), expand path, select node +- `]e`/`[e` keys in browser mode (when checkNavActive): increment/decrement checkNavIndex, send NavigateToDocMsg +- Status bar in check nav mode: `[2/5] CE1613: MyFirstModule.P_ComboBox_Enum ]e next [e prev` +- Check overlay needs cursor state: `selectedIndex int`, highlight selected row, Enter emits NavigateToDocMsg + +### Files + +- `cmd/mxcli/tui/checker.go` — add CheckNavLocation type, extraction function +- `cmd/mxcli/tui/app.go` — add check nav state, handle NavigateToDocMsg, ]e/[e keys, status bar update +- `cmd/mxcli/tui/overlayview.go` — add selectable mode with cursor for check overlay +- `cmd/mxcli/tui/browserview.go` — add NavigateToNode method for tree navigation +- `cmd/mxcli/tui/hintbar.go` — no changes (hints come from overlay/app) +- `cmd/mxcli/tui/help.go` — document ]e/[e keys + +## Part 3: Warning + Deprecation Support + +### Design + +Run `mx check -j -w -d` to capture all diagnostic types. Add Tab filtering in check overlay. + +``` +mx check Results [All: 8E 2W 1D] + +Tab cycles: All → Errors → Warnings → Deprecations → All +``` + +### Implementation + +- `runMxCheck`: add `-w`, `-d` flags to mx command +- `mxCheckJSON`: add `Deprecations []mxCheckEntry` field +- `CheckError.Severity`: extend to three values — `ERROR`, `WARNING`, `DEPRECATION` +- Check overlay: `checkFilter` state (`all`/`error`/`warning`/`deprecation`), Tab key cycles filter +- `renderCheckResults` accepts filter parameter, only renders matching groups +- Filter indicator in overlay title bar: `[All: 8E 2W 1D]` or `[Errors: 8]` +- `formatCheckBadge`: update to show `✗ 8E 2W 1D` +- When overlay is refreshable (not switchable), Tab is repurposed for filter cycling + +### Files + +- `cmd/mxcli/tui/checker.go` — update runMxCheck flags, parse deprecations, add filter logic +- `cmd/mxcli/tui/checker_test.go` — test deprecation parsing, filter rendering +- `cmd/mxcli/tui/overlayview.go` — add checkFilter state, Tab handling for non-switchable overlays + +## Part 4: LLM Anchor Structured Output + +### Design + +Embed faint structured anchors in overlay rendering for LLM consumption via screenshots or clipboard copy. + +``` +[mxcli:check] errors=8 warnings=2 deprecations=1 +[mxcli:check:CE1613] severity=ERROR count=6 doc=MyFirstModule.P_ComboBox_Assoc type=Page element=combo_box.cmbCategory +[mxcli:check:CE1613] severity=ERROR count=1 doc=MyFirstModule.P_ComboBox_Enum type=Page element=combo_box.cmbPriority +[mxcli:check:CW0001] severity=WARNING count=2 doc=MyFirstModule.DoSomething type=Microflow element=variable.$var +``` + +### Implementation + +- Replace current `[mxcli:overlay] mx check MDL` anchor with `[mxcli:check]` summary +- Add per-group-item anchors with key=value pairs +- Use `Faint(true)` styling — nearly invisible in terminal but preserved in copy/screenshot +- `PlainText()` (for clipboard via `y`) includes anchor text +- Anchors rendered before visible content in overlay + +### Files + +- `cmd/mxcli/tui/overlayview.go` — update Render() for check overlay anchor format +- `cmd/mxcli/tui/checker.go` — add `renderCheckAnchors([]CheckGroup) string` function + +## Task Order + +1. Part 1: Error grouping + dedup (foundation for all other parts) +2. Part 3: Warning/deprecation support (extends data model before navigation uses it) +3. Part 4: LLM anchors (uses grouped data, no interaction changes) +4. Part 2: Error navigation (most complex, depends on grouping being stable) + +## Dependencies + +- No new Go dependencies required +- `element-id` and `unit-id` fields need to be added to `mxCheckLocation` struct for dedup and future navigation diff --git a/docs/plans/2026-03-26-tui-resize-session.md b/docs/plans/2026-03-26-tui-resize-session.md new file mode 100644 index 0000000..1ae5454 --- /dev/null +++ b/docs/plans/2026-03-26-tui-resize-session.md @@ -0,0 +1,135 @@ +# TUI Resize Fix + Session Restore Design + +## Part 1: Window Resize Mouse Coordinate Fix + +### Problem + +After terminal window resize, mouse click coordinates drift — all interactive areas (tab bar, miller columns, status bar) respond to wrong positions. The terminal reports mouse positions based on stale coordinate mapping. + +### Root Cause + +bubbletea's mouse tracking mode is not automatically reset when the terminal window size changes. Some terminals (tmux, screen) need the mouse tracking ANSI sequences re-sent to recalibrate. + +Additionally, the recently added LLM anchor line may have changed the Y-offset calculation without updating mouse coordinate translation. + +### Fix + +1. In `WindowSizeMsg` handler, reset mouse tracking: +```go +case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + // Reset mouse tracking to recalibrate coordinates + return a, tea.Batch( + tea.DisableMouse, + tea.EnableMouseCellMotion, + ) +``` + +2. Audit all Y-coordinate offsets in mouse handling: + - `msg.Y - 1` offset for tab bar — verify against actual chrome height + - If LLM anchor line is rendered, add +1 to Y offset + - Status bar hit test Y coordinate check + +### Files + +- `cmd/mxcli/tui/app.go` — WindowSizeMsg handler, mouse Y-offset audit + +## Part 2: Session Restore (-c flag) + +### Overview + +Save TUI state on exit, restore on startup with `-c` flag. Enables quick restart-verify cycles during development. + +### Storage + +File: `~/.mxcli/tui-session.json` (overwritten on each exit) + +### Session State Schema + +```json +{ + "version": 1, + "timestamp": "2026-03-26T01:30:00Z", + "tabs": [ + { + "projectPath": "/path/to/App.mpr", + "millerPath": ["Project", "MyFirstModule", "Pages"], + "selectedNode": "P_ComboBox_Enum", + "previewMode": "MDL" + } + ], + "activeTab": 0, + "viewStack": [ + {"type": "browser"}, + {"type": "overlay", "title": "mx check", "filter": "all"} + ], + "checkNavActive": true, + "checkNavIndex": 1 +} +``` + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `version` | int | Schema version for forward compat (currently 1) | +| `timestamp` | string | ISO 8601 save time | +| `tabs` | []TabState | All open tabs | +| `tabs[].projectPath` | string | Absolute path to .mpr file | +| `tabs[].millerPath` | []string | Navigation breadcrumb path | +| `tabs[].selectedNode` | string | Currently selected node name | +| `tabs[].previewMode` | string | "MDL" or "NDSL" | +| `activeTab` | int | Index of active tab | +| `viewStack` | []ViewState | Stack of open views (browser at bottom) | +| `viewStack[].type` | string | "browser", "overlay", "compare", etc. | +| `viewStack[].title` | string | Overlay title (for overlay type) | +| `viewStack[].filter` | string | Check filter (for check overlay) | +| `checkNavActive` | bool | Whether check nav mode is active | +| `checkNavIndex` | int | Current nav position | + +### Edge Cases + +| Scenario | Handling | +|----------|----------| +| Project file deleted | Skip tab, log warning, open remaining tabs | +| Selected node deleted | Fall back to nearest existing parent in miller path | +| Miller path partially invalid | Expand to last valid level, select first child | +| Overlay data unavailable | Skip overlay restore, show browser only | +| Check results stale | Re-run check on restore, don't restore old results | +| All tabs invalid | Normal startup (no restore) | +| Session file corrupt/missing | Ignore, normal startup | +| Schema version mismatch | Ignore if version > current, attempt if <= current | + +### Implementation + +**New file:** `cmd/mxcli/tui/session.go` +- `TUISession` struct matching JSON schema +- `TabState`, `ViewState` sub-structs +- `SaveSession(app *App) error` — serialize current state to file +- `LoadSession() (*TUISession, error)` — read and parse session file +- `sessionFilePath() string` — returns `~/.mxcli/tui-session.json` + +**Modified files:** + +`cmd/mxcli/tui/app.go`: +- On quit (case "q"): call `SaveSession(a)` before returning `tea.Quit` +- New `RestoreSession(session *TUISession)` method on App + - Opens each tab's project, navigates miller path, selects node + - Restores view stack (overlay, etc.) with fallbacks +- Accept `*TUISession` in App constructor or Init + +`cmd/mxcli/cmd_tui.go`: +- Add `-c` / `--continue` flag +- When set, call `LoadSession()` and pass to App + +`cmd/mxcli/tui/browserview.go` / `cmd/mxcli/tui/miller.go`: +- May need `NavigateToPath(path []string) bool` method +- Reuse existing `navigateToNode` with path-walking + +### Task Order + +1. Part 1: Mouse fix (small, isolated) +2. Part 2a: Session save/load infrastructure (session.go) +3. Part 2b: App integration (save on quit, restore on start) +4. Part 2c: CLI flag + edge case testing