Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
748 changes: 368 additions & 380 deletions cmd/mxcli/tui/app.go

Large diffs are not rendered by default.

329 changes: 329 additions & 0 deletions cmd/mxcli/tui/browserview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
package tui

import (
"fmt"
"os"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

// BrowserView wraps MillerView and absorbs action keys from the normal browsing mode.
// It implements the View interface.
type BrowserView struct {
miller MillerView
tab *Tab
allNodes []*TreeNode
compareItems []PickerItem
mxcliPath string
projectPath string
previewEngine *PreviewEngine

// Overlay state for NDSL/MDL tab switching context
overlayQName string
overlayNodeType string
overlayIsNDSL bool
}

// NewBrowserView creates a BrowserView wrapping the Miller view from the given tab.
func NewBrowserView(tab *Tab, mxcliPath string, engine *PreviewEngine) BrowserView {
return BrowserView{
miller: tab.Miller,
tab: tab,
allNodes: tab.AllNodes,
mxcliPath: mxcliPath,
projectPath: tab.ProjectPath,
previewEngine: engine,
}
}

// Mode returns ModeBrowser.
func (bv BrowserView) Mode() ViewMode {
return ModeBrowser
}

// Hints returns context-sensitive hints for the browser view.
func (bv BrowserView) Hints() []Hint {
if bv.miller.focusedColumn().IsFilterActive() {
return FilterActiveHints
}
return ListBrowsingHints
}

// StatusInfo builds status bar data from the Miller view state.
func (bv BrowserView) StatusInfo() StatusInfo {
crumbs := bv.miller.Breadcrumb()

mode := "MDL"
if bv.miller.preview.mode == PreviewNDSL {
mode = "NDSL"
}

col := bv.miller.current
position := fmt.Sprintf("%d/%d", col.cursor+1, col.ItemCount())

return StatusInfo{
Breadcrumb: crumbs,
Position: position,
Mode: mode,
}
}

// Render sets the miller size and returns its rendered output with an LLM anchor prefix.
func (bv BrowserView) Render(width, height int) string {
bv.miller.SetSize(width, height)
rendered := bv.miller.View()

// Embed LLM anchor as muted prefix on the first line
info := bv.StatusInfo()
anchor := fmt.Sprintf("[mxcli:browse] %s %s %s",
strings.Join(info.Breadcrumb, " > "), info.Position, info.Mode)
anchorStr := lipgloss.NewStyle().Foreground(MutedColor).Faint(true).Render(anchor)

if idx := strings.IndexByte(rendered, '\n'); idx >= 0 {
rendered = anchorStr + rendered[idx:]
} else {
rendered = anchorStr
}
return rendered
}

// Update handles messages for the browser view.
// Keys not handled here (q, ?, t, T, W, 1-9, [, ], ctrl+c) return (bv, nil)
// so App can handle them.
func (bv BrowserView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case PreviewReadyMsg, PreviewLoadingMsg, CursorChangedMsg, animTickMsg, previewDebounceMsg:
var cmd tea.Cmd
bv.miller, cmd = bv.miller.Update(msg)
return bv, cmd

case tea.MouseMsg:
var cmd tea.Cmd
bv.miller, cmd = bv.miller.Update(msg)
return bv, cmd

case tea.KeyMsg:
return bv.handleKey(msg)
}

return bv, nil
}

func (bv BrowserView) handleKey(msg tea.KeyMsg) (View, tea.Cmd) {
// If filter is active, forward all keys to miller
if bv.miller.focusedColumn().IsFilterActive() {
var cmd tea.Cmd
bv.miller, cmd = bv.miller.Update(msg)
return bv, cmd
}

switch msg.String() {
case "b":
node := bv.miller.SelectedNode()
if node != nil && node.QualifiedName != "" {
if bsonType := inferBsonType(node.Type); bsonType != "" {
bv.overlayQName = node.QualifiedName
bv.overlayNodeType = node.Type
bv.overlayIsNDSL = true
return bv, bv.runBsonOverlay(bsonType, node.QualifiedName)
}
}
return bv, nil

case "m":
node := bv.miller.SelectedNode()
if node != nil && node.QualifiedName != "" {
bv.overlayQName = node.QualifiedName
bv.overlayNodeType = node.Type
bv.overlayIsNDSL = false
return bv, bv.runMDLOverlay(node.Type, node.QualifiedName)
}
return bv, nil

case "c":
node := bv.miller.SelectedNode()
var loadCmd tea.Cmd
if node != nil && node.QualifiedName != "" {
loadCmd = bv.loadBsonNDSL(node.QualifiedName, node.Type, CompareFocusLeft)
}
return bv, loadCmd

case "d":
node := bv.miller.SelectedNode()
if node != nil && node.QualifiedName != "" {
return bv, bv.openDiagram(node.Type, node.QualifiedName)
}
return bv, nil

case "y":
if bv.miller.preview.content != "" {
raw := stripAnsi(bv.miller.preview.content)
_ = writeClipboard(raw)
}
return bv, nil

case "r":
// Return nil — App handles refresh via Init()
return bv, nil

case "z":
bv.miller.zenMode = !bv.miller.zenMode
bv.miller.relayout()
return bv, nil
}

// Navigation keys: forward to miller
switch msg.String() {
case "j", "k", "g", "G", "h", "l", "left", "right", "up", "down",
"enter", "tab", "/", "n", "N":
var cmd tea.Cmd
bv.miller, cmd = bv.miller.Update(msg)
return bv, cmd
}

// Keys not handled: q, ?, t, T, W, 1-9, [, ], ctrl+c — let App handle
return bv, nil
}

// --- Load helpers (moved from app.go) ---

func (bv BrowserView) runBsonOverlay(bsonType, qname string) tea.Cmd {
mxcliPath := bv.mxcliPath
projectPath := bv.projectPath
return func() tea.Msg {
args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl",
"--type", bsonType, "--object", qname}
out, err := runMxcli(mxcliPath, args...)
out = StripBanner(out)
title := fmt.Sprintf("BSON: %s", qname)
if err != nil {
return OpenOverlayMsg{Title: title, Content: "Error: " + out}
}
return OpenOverlayMsg{Title: title, Content: HighlightNDSL(out)}
}
}

func (bv BrowserView) runMDLOverlay(nodeType, qname string) tea.Cmd {
mxcliPath := bv.mxcliPath
projectPath := bv.projectPath
return func() tea.Msg {
out, err := runMxcli(mxcliPath, "-p", projectPath, "-c", buildDescribeCmd(nodeType, qname))
out = StripBanner(out)
title := fmt.Sprintf("MDL: %s", qname)
if err != nil {
return OpenOverlayMsg{Title: title, Content: "Error: " + out}
}
return OpenOverlayMsg{Title: title, Content: DetectAndHighlight(out)}
}
}

func (bv BrowserView) loadBsonNDSL(qname, nodeType string, side CompareFocus) tea.Cmd {
mxcliPath := bv.mxcliPath
projectPath := bv.projectPath
return func() tea.Msg {
bsonType := inferBsonType(nodeType)
if bsonType == "" {
return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType,
Content: fmt.Sprintf("Error: type %q not supported for BSON dump", nodeType),
Err: fmt.Errorf("unsupported type")}
}
args := []string{"bson", "dump", "-p", projectPath, "--format", "ndsl",
"--type", bsonType, "--object", qname}
out, err := runMxcli(mxcliPath, args...)
out = StripBanner(out)
if err != nil {
return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: "Error: " + out, Err: err}
}
return CompareLoadMsg{Side: side, Title: qname, NodeType: nodeType, Content: HighlightNDSL(out)}
}
}

// navigateToNode resets the miller view to root and drills down to the node
// matching the given qualified name. Returns a preview request command.
func (bv *BrowserView) navigateToNode(qname string) tea.Cmd {
path := findNodePath(bv.allNodes, qname)
if len(path) == 0 {
return nil
}

// Reset to root
bv.miller.SetRootNodes(bv.allNodes)

// Drill in for each intermediate node in the path (all except the last)
for i := 0; i < len(path)-1; i++ {
node := path[i]
// Find this node's index in the current column
idx := -1
for j, item := range bv.miller.current.items {
if item.Node == node {
idx = j
break
}
}
if idx < 0 {
return nil
}
bv.miller.current.SetCursor(idx)
bv.miller, _ = bv.miller.drillIn()
}

// Select the final node
target := path[len(path)-1]
for j, item := range bv.miller.current.items {
if item.Node == target {
bv.miller.current.SetCursor(j)
break
}
}

// Request preview for the selected node
if target.QualifiedName != "" && target.Type != "" && len(target.Children) == 0 {
return bv.miller.previewEngine.RequestPreview(target.Type, target.QualifiedName, bv.miller.preview.mode)
}
return nil
}

// findNodePath walks the tree to find the chain of nodes from root to the node
// with the matching qualified name. Returns nil if not found.
func findNodePath(nodes []*TreeNode, qname string) []*TreeNode {
for _, n := range nodes {
if n.QualifiedName == qname {
return []*TreeNode{n}
}
if len(n.Children) > 0 {
if sub := findNodePath(n.Children, qname); sub != nil {
return append([]*TreeNode{n}, sub...)
}
}
}
return nil
}

func (bv BrowserView) openDiagram(nodeType, qualifiedName string) tea.Cmd {
mxcliPath := bv.mxcliPath
projectPath := bv.projectPath
return func() tea.Msg {
out, err := runMxcli(mxcliPath, "describe", "-p", projectPath,
"--format", "elk", nodeType, qualifiedName)
if err != nil {
return CmdResultMsg{Output: out, Err: err}
}
htmlContent := buildDiagramHTML(out, nodeType, qualifiedName)
tmpFile, err := os.CreateTemp("", "mxcli-diagram-*.html")
if err != nil {
return CmdResultMsg{Err: err}
}
if _, err := tmpFile.WriteString(htmlContent); err != nil {
tmpFile.Close()
return CmdResultMsg{Err: fmt.Errorf("writing diagram HTML: %w", err)}
}
tmpFile.Close()
tmpPath := tmpFile.Name()
openBrowser(tmpPath)
time.AfterFunc(30*time.Second, func() { os.Remove(tmpPath) })
return CmdResultMsg{Output: fmt.Sprintf("Opened diagram: %s", tmpPath)}
}
}
34 changes: 28 additions & 6 deletions cmd/mxcli/tui/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,12 @@ func (c *Column) handleMouse(msg tea.MouseMsg) {
func (c Column) View() string {
var sb strings.Builder

// Title
sb.WriteString(ColumnTitleStyle.Render(c.title))
// Title — use accent style when focused
titleStyle := ColumnTitleStyle
if c.focused {
titleStyle = FocusedTitleStyle
}
sb.WriteString(titleStyle.Render(c.title))
sb.WriteString("\n")

// Filter bar
Expand All @@ -243,6 +247,10 @@ func (c Column) View() string {
if showScrollbar {
contentWidth--
}
// Reserve 1 column for focus edge indicator
if c.focused {
contentWidth--
}
if contentWidth < 10 {
contentWidth = 10
}
Expand All @@ -264,6 +272,7 @@ func (c Column) View() string {

scrollThumbStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
scrollTrackStyle := lipgloss.NewStyle().Faint(true)
edgeChar := FocusedEdgeStyle.Render(FocusedEdgeChar)

for vi := range maxVis {
idx := c.scrollOffset + vi
Expand Down Expand Up @@ -297,10 +306,19 @@ func (c Column) View() string {
}
}

// Pad to contentWidth
// Focus edge indicator
if c.focused {
line = edgeChar + line
}

// Pad to contentWidth (plus edge char width)
targetWidth := contentWidth
if c.focused {
targetWidth++ // account for the 1-char edge prefix
}
lineWidth := lipgloss.Width(line)
if lineWidth < contentWidth {
line += strings.Repeat(" ", contentWidth-lineWidth)
if lineWidth < targetWidth {
line += strings.Repeat(" ", targetWidth-lineWidth)
}

// Scrollbar
Expand All @@ -318,7 +336,11 @@ func (c Column) View() string {
}
}

return sb.String()
result := sb.String()
if !c.focused {
result = lipgloss.NewStyle().Faint(true).Render(result)
}
return result
}

// --- Helpers ---
Expand Down
Loading
Loading