diff --git a/cmd/dive/cli/internal/ui/v2/app/layer_detail_modal.go b/cmd/dive/cli/internal/ui/v2/app/layer_detail_modal.go new file mode 100644 index 0000000..0a01085 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/app/layer_detail_modal.go @@ -0,0 +1,200 @@ +package app + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/dustin/go-humanize" + + v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils" + "github.com/wagoodman/dive/dive/image" +) + +// LayerDetailMsg is sent to show layer details +type LayerDetailMsg struct { + Layer *image.Layer +} + +// LayerDetailModal manages the layer detail modal +type LayerDetailModal struct { + visible bool + layer *image.Layer + width int + height int +} + +// NewLayerDetailModal creates a new layer detail modal +func NewLayerDetailModal() LayerDetailModal { + return LayerDetailModal{ + visible: false, + layer: nil, + } +} + +// Show makes the modal visible with the given layer +func (m *LayerDetailModal) Show(layer *image.Layer) { + m.visible = true + m.layer = layer +} + +// Hide hides the modal +func (m *LayerDetailModal) Hide() { + m.visible = false + m.layer = nil +} + +// IsVisible returns whether the modal is visible +func (m *LayerDetailModal) IsVisible() bool { + return m.visible +} + +// Update handles messages for the modal +func (m LayerDetailModal) Update(msg tea.Msg) (LayerDetailModal, tea.Cmd) { + if !m.visible { + return m, nil + } + + switch msg := msg.(type) { + case LayerDetailMsg: + m.Show(msg.Layer) + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", " ", "enter": + m.Hide() + return m, nil + } + } + + return m, nil +} + +// View renders the modal +func (m LayerDetailModal) View(screenWidth, screenHeight int) string { + if !m.visible || m.layer == nil { + return "" + } + + // Calculate modal dimensions + modalWidth := min(screenWidth-10, 80) + modalHeight := min(screenHeight-10, 25) + + // Create modal style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(v2styles.PrimaryColor). + Background(lipgloss.Color("#1C1C1E")). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight) + + // Build content + title := v2styles.LayerHeaderStyle.Render("📦 Layer Details") + content := m.buildContent(modalWidth - 4) // -4 for padding + help := v2styles.LayerValueStyle.Render("ESC/SPACE/ENTER: close") + + fullContent := lipgloss.JoinVertical(lipgloss.Left, + title, + "", + content, + "", + help, + ) + + // Render modal + modal := modalStyle.Render(fullContent) + + // Place modal in center + return lipgloss.Place(screenWidth, screenHeight, lipgloss.Center, lipgloss.Center, modal) +} + +// buildContent creates the modal content +func (m LayerDetailModal) buildContent(width int) string { + if m.layer == nil { + return "No layer data available" + } + + var lines []string + + // Layer ID + lines = append(lines, m.renderField("ID", m.layer.Id, width)) + + // Digest + lines = append(lines, m.renderField("Digest", m.layer.Digest, width)) + + // Index + lines = append(lines, m.renderField("Index", fmt.Sprintf("%d", m.layer.Index), width)) + + // Size + size := utils.FormatSize(m.layer.Size) + lines = append(lines, m.renderField("Size", fmt.Sprintf("%s (%d bytes)", size, m.layer.Size), width)) + + // Human-readable size + humanSize := humanize.Bytes(m.layer.Size) + lines = append(lines, m.renderField("Human Size", humanSize, width)) + + // Names + if len(m.layer.Names) > 0 { + names := strings.Join(m.layer.Names, ", ") + lines = append(lines, m.renderField("Names", names, width)) + } + + // Command (multiline) + lines = append(lines, "") + lines = append(lines, v2styles.LayerHeaderStyle.Render("Command:")) + cmdLines := m.wrapText(m.layer.Command, width) + for _, line := range cmdLines { + lines = append(lines, v2styles.LayerValueStyle.Render(line)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// renderField renders a key-value pair +func (m LayerDetailModal) renderField(key, value string, width int) string { + label := v2styles.LayerHeaderStyle.Render(key + ":") + return lipgloss.JoinHorizontal(lipgloss.Top, label, " ", v2styles.LayerValueStyle.Render(value)) +} + +// wrapText wraps text to fit width +func (m LayerDetailModal) wrapText(text string, width int) []string { + if text == "" { + return []string{"(none)"} + } + + words := strings.Fields(text) + if len(words) == 0 { + return []string{"(none)"} + } + + var lines []string + currentLine := "" + + for _, word := range words { + testLine := currentLine + if testLine == "" { + testLine = word + } else { + testLine += " " + word + } + + if len(testLine) <= width { + currentLine = testLine + } else { + if currentLine != "" { + lines = append(lines, currentLine) + } + currentLine = word + } + } + + if currentLine != "" { + lines = append(lines, currentLine) + } + + return lines +} diff --git a/cmd/dive/cli/internal/ui/v2/app/model.go b/cmd/dive/cli/internal/ui/v2/app/model.go index c3ed604..c6a526f 100644 --- a/cmd/dive/cli/internal/ui/v2/app/model.go +++ b/cmd/dive/cli/internal/ui/v2/app/model.go @@ -12,11 +12,12 @@ import ( "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/keys" - filetree "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/filetree" + filetreepane "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/filetree" imagepane "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/image" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/details" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/panes/layers" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" + filetree "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/dive/image" ) @@ -85,7 +86,7 @@ type Model struct { layersPane layers.Pane detailsPane details.Pane imagePane imagepane.Pane - treePane filetree.Pane + treePane filetreepane.Pane // Active pane state activePane Pane @@ -93,6 +94,9 @@ type Model struct { // Filter state filter FilterModel + // Layer detail modal + layerDetailModal LayerDetailModal + // Help and key bindings keys keys.KeyMap help help.Model @@ -111,12 +115,15 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre // Initialize filetree viewmodel var treeVM *viewmodel.FileTreeViewModel + var comparer filetree.Comparer if len(analysis.RefTrees) > 0 { v1cfg := v1.Config{ Analysis: analysis, Content: content, Preferences: prefs, } + // Get comparer for layer tree comparison + comparer, _ = v1cfg.TreeComparer() // Note: we ignore the error here since treeVM.Update() will be called in Init() treeVM, _ = viewmodel.NewFileTreeViewModel(v1cfg, 0) } @@ -128,35 +135,37 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre h.Styles.Ellipsis = styles.StatusStyle f := NewFilterModel() + layerDetailModal := NewLayerDetailModal() // Create pane components - layersPane := layers.New(layerVM) + layersPane := layers.New(layerVM, comparer) detailsPane := details.New() imagePane := imagepane.New(&analysis) - treePane := filetree.New(treeVM) + treePane := filetreepane.New(treeVM) // Set initial focus layersPane.Focus() // Create model with initial dimensions model := Model{ - analysis: analysis, - content: content, - prefs: prefs, - ctx: ctx, - layerVM: layerVM, - treeVM: treeVM, - layersPane: layersPane, - detailsPane: detailsPane, - imagePane: imagePane, - treePane: treePane, - width: 80, - height: 24, - quitting: false, - activePane: PaneLayer, - keys: keys.Keys, - help: h, - filter: f, + analysis: analysis, + content: content, + prefs: prefs, + ctx: ctx, + layerVM: layerVM, + treeVM: treeVM, + layersPane: layersPane, + detailsPane: detailsPane, + imagePane: imagePane, + treePane: treePane, + width: 80, + height: 24, + quitting: false, + activePane: PaneLayer, + keys: keys.Keys, + help: h, + filter: f, + layerDetailModal: layerDetailModal, } // CRITICAL: Calculate initial layout and set pane sizes immediately @@ -213,6 +222,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + // If layer detail modal is visible, let it handle keys first + if m.layerDetailModal.IsVisible() { + var cmd tea.Cmd + m.layerDetailModal, cmd = m.layerDetailModal.Update(msg) + cmds = append(cmds, cmd) + break + } + // If filter is visible, let filter handle keys if m.filter.IsVisible() { var cmd tea.Cmd @@ -238,7 +255,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case PaneTree: newPane, cmd := m.treePane.Update(msg) - m.treePane = newPane.(filetree.Pane) + m.treePane = newPane.(filetreepane.Pane) cmds = append(cmds, cmd) } @@ -268,14 +285,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.imagePane.Blur() m.treePane.Blur() - case filetree.NodeToggledMsg: + case filetreepane.NodeToggledMsg: // Tree node was toggled - tree pane already updated its content // Nothing to do here - case filetree.RefreshTreeContentMsg: + case filetreepane.RefreshTreeContentMsg: // Request to refresh tree content m.treePane.SetTreeVM(m.treeVM) + case layers.ShowLayerDetailMsg: + // Show layer detail modal + m.layerDetailModal.Show(msg.Layer) + case tea.MouseMsg: // Route mouse events to appropriate pane x, y := msg.X, msg.Y @@ -316,8 +337,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } else if inRightCol { // Tree pane - newPane, cmd := m.treePane.Update(msg) - m.treePane = newPane.(filetree.Pane) + // CRITICAL FIX: Adjust X coordinate to be relative to tree pane + // Mouse events come in absolute coordinates (from window start) + // Tree pane expects local coordinates (from pane start, i.e., LeftWidth) + localMsg := msg + localMsg.X -= l.LeftWidth + + newPane, cmd := m.treePane.Update(localMsg) + m.treePane = newPane.(filetreepane.Pane) cmds = append(cmds, cmd) if m.activePane != PaneTree { m.activePane = PaneTree @@ -426,6 +453,12 @@ func (m Model) View() string { statusBar, ) + // Overlay layer detail modal if visible + if m.layerDetailModal.IsVisible() { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + m.layerDetailModal.View(m.width, m.height)) + } + // Overlay filter modal if visible if m.filter.IsVisible() { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, diff --git a/cmd/dive/cli/internal/ui/v2/components/file_stats.go b/cmd/dive/cli/internal/ui/v2/components/file_stats.go new file mode 100644 index 0000000..d3b401b --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/components/file_stats.go @@ -0,0 +1,240 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils" +) + +// Re-export FileStats from utils package +type FileStats = utils.FileStats + +// StatsPartType represents which part of the stats this is +type StatsPartType int + +const ( + StatsPartAdded StatsPartType = iota + StatsPartModified + StatsPartRemoved +) + +// StatsPartRenderer handles rendering a single part of file statistics with interactive state +type StatsPartRenderer struct { + partType StatsPartType + value int + active bool +} + +// NewStatsPartRenderer creates a new stats part renderer +func NewStatsPartRenderer(partType StatsPartType, value int) StatsPartRenderer { + return StatsPartRenderer{ + partType: partType, + value: value, + active: false, + } +} + +// SetValue updates the value +func (r *StatsPartRenderer) SetValue(value int) { + r.value = value +} + +// GetValue returns the current value +func (r *StatsPartRenderer) GetValue() int { + return r.value +} + +// SetActive sets the active state +func (r *StatsPartRenderer) SetActive(active bool) { + r.active = active +} + +// IsActive returns whether the component is active +func (r *StatsPartRenderer) IsActive() bool { + return r.active +} + +// ToggleActive toggles the active state +func (r *StatsPartRenderer) ToggleActive() { + r.active = !r.active +} + +// GetType returns the type of this stats part +func (r *StatsPartRenderer) GetType() StatsPartType { + return r.partType +} + +// Render renders the stats part as a string +func (r *StatsPartRenderer) Render() string { + var prefix string + switch r.partType { + case StatsPartAdded: + prefix = "+" + case StatsPartModified: + prefix = "~" + case StatsPartRemoved: + prefix = "-" + } + + text := fmt.Sprintf("%s%d", prefix, r.value) + + if r.active { + return r.activeStyle().Render(text) + } + + return r.defaultStyle().Render(text) +} + +// defaultStyle returns the style for inactive state +func (r *StatsPartRenderer) defaultStyle() lipgloss.Style { + switch r.partType { + case StatsPartAdded: + return styles.FileStatsAddedStyle + case StatsPartModified: + return styles.FileStatsModifiedStyle + case StatsPartRemoved: + return styles.FileStatsRemovedStyle + default: + return lipgloss.NewStyle() + } +} + +// activeStyle returns the style for active state +func (r *StatsPartRenderer) activeStyle() lipgloss.Style { + var bgColor lipgloss.Color + switch r.partType { + case StatsPartAdded: + bgColor = styles.SuccessColor + case StatsPartModified: + bgColor = styles.WarningColor + case StatsPartRemoved: + bgColor = styles.ErrorColor + default: + bgColor = styles.PrimaryColor + } + + // FIX: Removed Padding(0, 1) to prevent text shifting + // The background color is enough indication of state + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(bgColor). + Bold(true) +} + +// GetVisualWidth returns the visual width of the rendered part (without ANSI codes) +func (r *StatsPartRenderer) GetVisualWidth() int { + // Format: "+N" or "~N" or "-N" where N is the value + return 1 + len(fmt.Sprintf("%d", r.value)) +} + +// FileStatsRow manages a row with three stats parts +type FileStatsRow struct { + added StatsPartRenderer + modified StatsPartRenderer + removed StatsPartRenderer +} + +// NewFileStatsRow creates a new file stats row +func NewFileStatsRow() FileStatsRow { + return FileStatsRow{ + added: NewStatsPartRenderer(StatsPartAdded, 0), + modified: NewStatsPartRenderer(StatsPartModified, 0), + removed: NewStatsPartRenderer(StatsPartRemoved, 0), + } +} + +// SetStats updates all statistics +func (r *FileStatsRow) SetStats(stats FileStats) { + r.added.SetValue(stats.Added) + r.modified.SetValue(stats.Modified) + r.removed.SetValue(stats.Removed) +} + +// GetStats returns the current statistics +func (r *FileStatsRow) GetStats() FileStats { + return FileStats{ + Added: r.added.GetValue(), + Modified: r.modified.GetValue(), + Removed: r.removed.GetValue(), + } +} + +// GetPart returns the specific part renderer +func (r *FileStatsRow) GetPart(partType StatsPartType) *StatsPartRenderer { + switch partType { + case StatsPartAdded: + return &r.added + case StatsPartModified: + return &r.modified + case StatsPartRemoved: + return &r.removed + default: + return nil + } +} + +// GetAdded returns the added part renderer +func (r *FileStatsRow) GetAdded() *StatsPartRenderer { + return &r.added +} + +// GetModified returns the modified part renderer +func (r *FileStatsRow) GetModified() *StatsPartRenderer { + return &r.modified +} + +// GetRemoved returns the removed part renderer +func (r *FileStatsRow) GetRemoved() *StatsPartRenderer { + return &r.removed +} + +// DeactivateAll deactivates all parts +func (r *FileStatsRow) DeactivateAll() { + r.added.SetActive(false) + r.modified.SetActive(false) + r.removed.SetActive(false) +} + +// Render renders the complete stats row as a string +func (r *FileStatsRow) Render() string { + // Join with spaces + addedStr := r.added.Render() + modifiedStr := r.modified.Render() + removedStr := r.removed.Render() + + return fmt.Sprintf("%s %s %s", addedStr, modifiedStr, removedStr) +} + +// GetPartPositions returns the X positions and widths of each part +// Returns: (addedX, addedWidth, modifiedX, modifiedWidth, removedX, removedWidth) +func (r *FileStatsRow) GetPartPositions(startX int) (int, int, int, int, int, int) { + addedWidth := r.added.GetVisualWidth() + modifiedWidth := r.modified.GetVisualWidth() + removedWidth := r.removed.GetVisualWidth() + + addedX := startX + modifiedX := addedX + addedWidth + 1 // +1 for space + removedX := modifiedX + modifiedWidth + 1 // +1 for space + + return addedX, addedWidth, modifiedX, modifiedWidth, removedX, removedWidth +} + +// GetPartAtPosition returns which part is at the given X position +// Returns (partType, found) +func (r *FileStatsRow) GetPartAtPosition(x int, startX int) (StatsPartType, bool) { + addedX, addedWidth, modifiedX, modifiedWidth, removedX, removedWidth := r.GetPartPositions(startX) + + if x >= addedX && x < addedX+addedWidth { + return StatsPartAdded, true + } + if x >= modifiedX && x < modifiedX+modifiedWidth { + return StatsPartModified, true + } + if x >= removedX && x < removedX+removedWidth { + return StatsPartRemoved, true + } + + return -1, false +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/doc.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/doc.go new file mode 100644 index 0000000..79fb8b4 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/doc.go @@ -0,0 +1,46 @@ +// Package filetree provides a tree view component for displaying Docker image file hierarchies. +// +// The package is organized into focused components that work together: +// +// Core Components: +// - Pane: Main coordinator implementing tea.Model interface +// - Selection: Manages tree index selection state +// - ViewportManager: Wrapper around bubbletea viewport +// +// Rendering Components: +// - HeaderRenderer: Renders table header row +// - NodeRenderer: Renders individual tree nodes +// - MetadataFormatter: Formats file metadata (permissions, uid:gid, size) +// - TreeTraversal: Collects visible nodes and handles tree traversal +// +// Logic Components: +// - Navigation: Handles navigation movements (up/down/pageup/pagedown) +// - InputHandler: Processes keyboard and mouse input +// +// Dependency Graph: +// +// pane.go (coordinator) +// ├── input_handler.go +// │ ├── navigation.go +// │ │ ├── selection.go +// │ │ └── viewport_manager.go +// │ └── selection.go +// ├── node_renderer.go +// │ ├── metadata_formatter.go +// │ └── styles (external) +// ├── header_renderer.go +// │ └── styles (external) +// └── tree_traversal.go (pure functions) +// +// Usage: +// +// treeVM := // ... obtain FileTreeViewModel +// pane := filetree.New(treeVM) +// pane.SetSize(width, height) +// pane.Focus() +// +// Messages: +// - NodeToggledMsg: Sent when a tree node is collapsed/expanded +// - TreeSelectionChangedMsg: Sent when a tree node is selected +// - RefreshTreeContentMsg: Requests tree content to be refreshed +package filetree diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/header_renderer.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/header_renderer.go new file mode 100644 index 0000000..b29d7df --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/header_renderer.go @@ -0,0 +1,71 @@ +package filetree + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" +) + +// RenderHeader creates a column header row with FIXED WIDTH columns +func RenderHeader(width int) string { + // Header style (muted to not distract) + headerColor := styles.DarkGrayColor + + // Create cell styles with FIXED WIDTH (same as renderer.go!) + sizeHeaderCell := lipgloss.NewStyle(). + Width(SizeWidth). + Align(lipgloss.Right). + Foreground(headerColor) + + uidGidHeaderCell := lipgloss.NewStyle(). + Width(UidGidWidth). + Align(lipgloss.Right). + Foreground(headerColor) + + permHeaderCell := lipgloss.NewStyle(). + Width(PermWidth). + Align(lipgloss.Right). + Foreground(headerColor) + + // Render each header cell with fixed width + styledSizeHeader := sizeHeaderCell.Render("Size") + styledUidGidHeader := uidGidHeaderCell.Render("UID:GID") + styledPermHeader := permHeaderCell.Render("Permissions") + + // Join cells horizontally with gap (same as renderer.go!) + metaBlock := lipgloss.JoinHorizontal( + lipgloss.Top, + styledSizeHeader, + lipgloss.NewStyle().Width(len(MetaGap)).Render(MetaGap), + styledUidGidHeader, + lipgloss.NewStyle().Width(len(MetaGap)).Render(MetaGap), + styledPermHeader, + ) + + // CRITICAL: Must match viewport width (width - 2)! + // Viewport is created with width-2, so header must use the same width + availableWidth := width - 2 + if availableWidth < 10 { + availableWidth = 10 + } + + // Left part: "Name" label (with muted color like other headers) + nameHeaderStyle := lipgloss.NewStyle().Foreground(headerColor) + styledNameHeader := nameHeaderStyle.Render("Name") + + // Get actual metadata block width + metaBlockWidth := lipgloss.Width(metaBlock) + + // Calculate padding to push metadata to the right + padding := availableWidth - runewidth.StringWidth("Name") - metaBlockWidth + if padding < 1 { + padding = 1 + } + + // Assemble: Name + padding + right-aligned metadata + fullText := styledNameHeader + strings.Repeat(" ", padding) + metaBlock + + return fullText +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go new file mode 100644 index 0000000..5d0a144 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go @@ -0,0 +1,165 @@ +package filetree + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout" +) + +// InputHandler handles keyboard and mouse input events +type InputHandler struct { + navigation *Navigation + selection *Selection + viewportMgr *ViewportManager + treeVM *viewmodel.FileTreeViewModel + focused bool + width int + height int +} + +// NewInputHandler creates a new input handler +func NewInputHandler(nav *Navigation, sel *Selection, vp *ViewportManager, treeVM *viewmodel.FileTreeViewModel) *InputHandler { + return &InputHandler{ + navigation: nav, + selection: sel, + viewportMgr: vp, + treeVM: treeVM, + focused: false, + width: 80, + height: 20, + } +} + +// SetFocused updates the focused state +func (h *InputHandler) SetFocused(focused bool) { + h.focused = focused +} + +// SetSize updates the dimensions +func (h *InputHandler) SetSize(width, height int) { + h.width = width + h.height = height +} + +// HandleKeyPress processes keyboard input +// Returns (commands, consumed) - if consumed is true, event should not propagate to viewport +func (h *InputHandler) HandleKeyPress(msg tea.KeyMsg) (cmds []tea.Cmd, consumed bool) { + if !h.focused { + return nil, false + } + + switch msg.String() { + case "up", "k": + return []tea.Cmd{h.navigation.MoveUp()}, true + case "down", "j": + return []tea.Cmd{h.navigation.MoveDown()}, true + case "pgup": + return []tea.Cmd{h.navigation.MovePageUp()}, true + case "pgdown": + return []tea.Cmd{h.navigation.MovePageDown()}, true + case "home": + return []tea.Cmd{h.navigation.MoveToTop()}, true + case "end": + return []tea.Cmd{h.navigation.MoveToBottom()}, true + case "left", "h": + cmd := h.navigation.MoveLeft() + if cmd != nil { + return []tea.Cmd{cmd}, true + } + return nil, true + case "right", "l": + cmd := h.navigation.MoveRight() + if cmd != nil { + return []tea.Cmd{cmd}, true + } + return nil, true + case "enter", " ": + cmd := h.toggleCollapse() + if cmd != nil { + return []tea.Cmd{cmd}, true + } + return nil, true + } + + return nil, false +} + +// HandleMouseClick processes mouse click events +func (h *InputHandler) HandleMouseClick(msg tea.MouseMsg) tea.Cmd { + x, y := msg.X, msg.Y + + // Bounds check + if x < 0 || x >= h.width || y < 0 { + return nil + } + + // CRITICAL: Account for the table header row + const tableHeaderHeight = 1 + relativeY := y - layout.ContentVisualOffset - tableHeaderHeight + + if relativeY < 0 || relativeY >= h.viewportMgr.GetHeight() { + return nil + } + + if h.treeVM == nil || h.treeVM.ViewTree == nil { + return nil + } + + visibleNodes := CollectVisibleNodes(h.treeVM.ViewTree.Root) + targetIndex := relativeY + h.viewportMgr.GetYOffset() + + if targetIndex >= 0 && targetIndex < len(visibleNodes) { + // First click: just focus (move cursor) + // Second click on same row: toggle collapse + if h.selection.GetTreeIndex() == targetIndex { + return h.toggleCollapse() + } + + h.selection.MoveToIndex(targetIndex) + h.navigation.SyncScroll() + h.navigation.Refresh() + + return func() tea.Msg { + return TreeSelectionChangedMsg{NodeIndex: h.selection.GetTreeIndex()} + } + } + + return nil +} + +// toggleCollapse toggles the current node's collapse state +func (h *InputHandler) toggleCollapse() tea.Cmd { + if h.treeVM == nil || h.treeVM.ViewTree == nil { + return nil + } + + visibleNodes := CollectVisibleNodes(h.treeVM.ViewTree.Root) + + treeIndex := h.selection.GetTreeIndex() + if treeIndex >= len(visibleNodes) { + h.selection.MoveToIndex(len(visibleNodes) - 1) + treeIndex = h.selection.GetTreeIndex() + } + if treeIndex < 0 { + h.selection.SetTreeIndex(0) + treeIndex = h.selection.GetTreeIndex() + } + + if treeIndex < len(visibleNodes) { + selectedNode := visibleNodes[treeIndex].Node + + if selectedNode.Data.FileInfo.IsDir() { + // Toggle the collapsed flag directly on the node + selectedNode.Data.ViewInfo.Collapsed = !selectedNode.Data.ViewInfo.Collapsed + + // Just refresh the UI - don't call treeVM.Update() as it rebuilds the tree + h.navigation.Refresh() + + return func() tea.Msg { + return NodeToggledMsg{NodeIndex: treeIndex} + } + } + } + + return nil +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go new file mode 100644 index 0000000..165de23 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/metadata_formatter.go @@ -0,0 +1,99 @@ +package filetree + +// Column width constants for metadata display +const ( + PermWidth = 11 // "-rwxr-xr-x" + UidGidWidth = 9 // "0:0" or "1000:1000" + SizeWidth = 8 // right-aligned size + MetaGap = " " +) + +// FormatPermissions converts os.FileMode to Unix permission string (e.g. "-rwxr-xr-x") +func FormatPermissions(mode interface{}) string { + var m uint32 + switch v := mode.(type) { + case uint32: + m = v + case int: + m = uint32(v) + default: + return "----------" + } + + // Convert to string representation + perms := []rune("----------") + + // File type + if m&(1<<15) != 0 { // regular file + perms[0] = '-' + } else if m&(1<<14) != 0 { // directory + perms[0] = 'd' + } else if m&(1<<12) != 0 { // symbolic link + perms[0] = 'l' + } + + // Owner permissions + if m&(1<<8) != 0 { + perms[1] = 'r' + } + if m&(1<<7) != 0 { + perms[2] = 'w' + } + if m&(1<<6) != 0 { + perms[3] = 'x' + } else if m&(1<<11) != 0 { // setuid + perms[3] = 'S' + } + + // Group permissions + if m&(1<<5) != 0 { + perms[4] = 'r' + } + if m&(1<<4) != 0 { + perms[5] = 'w' + } + if m&(1<<3) != 0 { + perms[6] = 'x' + } else if m&(1<<10) != 0 { // setgid + perms[6] = 'S' + } + + // Other permissions + if m&(1<<2) != 0 { + perms[7] = 'r' + } + if m&(1<<1) != 0 { + perms[8] = 'w' + } + if m&(1<<0) != 0 { + perms[9] = 'x' + } else if m&(1<<9) != 0 { // sticky bit + perms[9] = 'T' + } + + return string(perms) +} + +// FormatUidGid formats UID:GID for display, showing "-" for default root:root (0:0) +func FormatUidGid(uid, gid uint32) string { + if uid != 0 || gid != 0 { + return formatUint32(uid) + ":" + formatUint32(gid) + } + return "-" +} + +// formatUint32 formats a uint32 to string +func formatUint32(v uint32) string { + var buf [20]byte + i := len(buf) + n := int64(v) + for n > 0 { + i-- + buf[i] = '0' + byte(n%10) + n /= 10 + } + if i == len(buf) { + return "0" + } + return string(buf[i:]) +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go new file mode 100644 index 0000000..19288bd --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go @@ -0,0 +1,246 @@ +package filetree + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Navigation handles tree navigation movements +type Navigation struct { + selection *Selection + viewportMgr *ViewportManager + visibleNodesFn func() []VisibleNode + refreshFn func() + toggleCollapseFn func() tea.Cmd +} + +// NewNavigation creates a new navigation handler +func NewNavigation(selection *Selection, viewportMgr *ViewportManager) *Navigation { + return &Navigation{ + selection: selection, + viewportMgr: viewportMgr, + } +} + +// SetVisibleNodesFunc sets the callback to get visible nodes +func (n *Navigation) SetVisibleNodesFunc(fn func() []VisibleNode) { + n.visibleNodesFn = fn +} + +// SetRefreshFunc sets the callback to refresh content +func (n *Navigation) SetRefreshFunc(fn func()) { + n.refreshFn = fn +} + +// SetToggleCollapseFunc sets the callback to toggle directory collapse state +func (n *Navigation) SetToggleCollapseFunc(fn func() tea.Cmd) { + n.toggleCollapseFn = fn +} + +// MoveUp moves selection up +func (n *Navigation) MoveUp() tea.Cmd { + if n.selection.GetTreeIndex() > 0 { + n.selection.SetTreeIndex(n.selection.GetTreeIndex() - 1) + n.SyncScroll() + n.doRefresh() + } + return nil +} + +// MoveDown moves selection down +func (n *Navigation) MoveDown() tea.Cmd { + if n.visibleNodesFn == nil { + return nil + } + + visibleNodes := n.visibleNodesFn() + if n.selection.GetTreeIndex() < len(visibleNodes)-1 { + n.selection.SetTreeIndex(n.selection.GetTreeIndex() + 1) + n.SyncScroll() + n.doRefresh() + } + return nil +} + +// MovePageUp moves selection up by one page +func (n *Navigation) MovePageUp() tea.Cmd { + if n.visibleNodesFn == nil { + return nil + } + + // Move up by viewport height + pageSize := n.viewportMgr.GetHeight() + if pageSize < 1 { + pageSize = 10 + } + + newIndex := n.selection.GetTreeIndex() - pageSize + if newIndex < 0 { + newIndex = 0 + } + n.selection.MoveToIndex(newIndex) + n.SyncScroll() + n.doRefresh() + return nil +} + +// MovePageDown moves selection down by one page +func (n *Navigation) MovePageDown() tea.Cmd { + if n.visibleNodesFn == nil { + return nil + } + + visibleNodes := n.visibleNodesFn() + if len(visibleNodes) == 0 { + return nil + } + + // Move down by viewport height + pageSize := n.viewportMgr.GetHeight() + if pageSize < 1 { + pageSize = 10 + } + + newIndex := n.selection.GetTreeIndex() + pageSize + if newIndex >= len(visibleNodes) { + newIndex = len(visibleNodes) - 1 + } + n.selection.MoveToIndex(newIndex) + n.SyncScroll() + n.doRefresh() + return nil +} + +// MoveToTop moves selection to the first item +func (n *Navigation) MoveToTop() tea.Cmd { + n.selection.SetTreeIndex(0) + n.viewportMgr.GotoTop() + n.SyncScroll() + n.doRefresh() + return nil +} + +// MoveToBottom moves selection to the last item +func (n *Navigation) MoveToBottom() tea.Cmd { + if n.visibleNodesFn == nil { + return nil + } + + visibleNodes := n.visibleNodesFn() + if len(visibleNodes) == 0 { + return nil + } + + n.selection.MoveToIndex(len(visibleNodes) - 1) + n.viewportMgr.GotoBottom() + n.SyncScroll() + n.doRefresh() + return nil +} + +// SyncScroll ensures the cursor is always visible +func (n *Navigation) SyncScroll() { + if n.visibleNodesFn == nil { + return + } + + visibleNodes := n.visibleNodesFn() + if len(visibleNodes) == 0 { + return + } + + n.selection.SetMaxIndex(len(visibleNodes)) + n.selection.ValidateBounds() + + visibleHeight := n.viewportMgr.GetHeight() + if visibleHeight <= 0 { + return + } + + treeIndex := n.selection.GetTreeIndex() + yOffset := n.viewportMgr.GetYOffset() + + if treeIndex < yOffset { + n.viewportMgr.SetYOffset(treeIndex) + } + + if treeIndex >= yOffset+visibleHeight { + n.viewportMgr.SetYOffset(treeIndex - visibleHeight + 1) + } +} + +// doRefresh calls the refresh callback if set +func (n *Navigation) doRefresh() { + if n.refreshFn != nil { + n.refreshFn() + } +} + +// Refresh is a public method to trigger content refresh +func (n *Navigation) Refresh() { + n.doRefresh() +} + +// MoveLeft navigates to parent directory or collapses current directory +func (n *Navigation) MoveLeft() tea.Cmd { + if n.visibleNodesFn == nil { + return nil + } + + visibleNodes := n.visibleNodesFn() + if len(visibleNodes) == 0 { + return nil + } + + currentIndex := n.selection.GetTreeIndex() + if currentIndex < 0 || currentIndex >= len(visibleNodes) { + return nil + } + + currentNode := visibleNodes[currentIndex].Node + + // If current node is an expanded directory, collapse it + if currentNode.Data.FileInfo.IsDir() && !currentNode.Data.ViewInfo.Collapsed { + if n.toggleCollapseFn != nil { + return n.toggleCollapseFn() + } + return nil + } + + // Otherwise, move to parent directory (for files or collapsed dirs) + parentIndex := FindParentIndex(visibleNodes, currentIndex) + if parentIndex >= 0 { + n.selection.MoveToIndex(parentIndex) + n.SyncScroll() + n.doRefresh() + } + + return nil +} + +// MoveRight expands collapsed directory +func (n *Navigation) MoveRight() tea.Cmd { + if n.visibleNodesFn == nil { + return nil + } + + visibleNodes := n.visibleNodesFn() + if len(visibleNodes) == 0 { + return nil + } + + currentIndex := n.selection.GetTreeIndex() + if currentIndex < 0 || currentIndex >= len(visibleNodes) { + return nil + } + + currentNode := visibleNodes[currentIndex].Node + + // If current node is a collapsed directory, expand it + if currentNode.Data.FileInfo.IsDir() && currentNode.Data.ViewInfo.Collapsed { + if n.toggleCollapseFn != nil { + return n.toggleCollapseFn() + } + } + + return nil +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go new file mode 100644 index 0000000..66d4ecb --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go @@ -0,0 +1,211 @@ +package filetree + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils" + "github.com/wagoodman/dive/dive/filetree" +) + +// RenderNodeWithCursor renders a node with tree guides and improved visual design +func RenderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, prefix string, isSelected bool, width int) { + if node == nil { + return + } + + // 1. Icon and base color + icon := styles.IconFile + diffIcon := "" // 1 space (compact, like nvim-tree) + color := styles.DiffNormalColor + + if node.Data.FileInfo.IsDir() { + if node.Data.ViewInfo.Collapsed { + icon = styles.IconDirClosed + } else { + icon = styles.IconDirOpen + } + } else if node.Data.FileInfo.TypeFlag == 16 { // Symlink + icon = styles.IconSymlink + } + + // 2. Diff status (color only, no icons) + switch node.Data.DiffType { + case filetree.Added: + color = styles.DiffAddedColor + case filetree.Removed: + color = styles.DiffRemovedColor + case filetree.Modified: + color = styles.DiffModifiedColor + } + + // 3. Format metadata (right-aligned) + perm := FormatPermissions(node.Data.FileInfo.Mode) + + // Show UID:GID only if not the default root:root (0:0) + var uidGid string + if node.Data.FileInfo.Uid != 0 || node.Data.FileInfo.Gid != 0 { + uidGid = FormatUidGid(node.Data.FileInfo.Uid, node.Data.FileInfo.Gid) + } else { + uidGid = "-" + } + + // Size (empty for folders) + var sizeStr string + if !node.Data.FileInfo.IsDir() { + sizeStr = utils.FormatSize(uint64(node.Data.FileInfo.Size)) + } + + // Format name + name := node.Name + if name == "" { + name = "/" + } + if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" { + name += " → " + node.Data.FileInfo.Linkname + } + + // 4. Build line with new order: cursor | tree-guides diff icon icon filename [metadata...] + + // Cursor mark (same as Layers) + cursorMark := "" + if isSelected { + cursorMark = "" + } + + // Render tree guides (lines should be gray) + // If selected, apply background to tree guides too + styledPrefix := styles.TreeGuideStyle.Render(prefix) + if isSelected { + styledPrefix = lipgloss.NewStyle(). + Foreground(styles.DarkGrayColor). + Background(lipgloss.Color("#1C1C1E")). + Render(prefix) + } + + // Render filename with diff color + nameStyle := lipgloss.NewStyle().Foreground(color) + if isSelected { + // Use SelectedLayerStyle (with background and primary color) + nameStyle = nameStyle.Bold(true).Foreground(styles.PrimaryColor).Background(lipgloss.Color("#1C1C1E")) + } + + // Render metadata with FIXED WIDTH columns for strict grid layout + // Each column gets exact width to ensure headers align with data + + // Base metadata color + metaColor := lipgloss.Color("#6e6e73") + + // If selected, use background color for metadata too + metaBg := lipgloss.Color("") + if isSelected { + metaBg = lipgloss.Color("#1C1C1E") + } + + // Create cell styles with fixed width and right alignment + sizeCell := lipgloss.NewStyle(). + Width(SizeWidth). + Align(lipgloss.Right). + Foreground(metaColor). + Background(metaBg) + + uidGidCell := lipgloss.NewStyle(). + Width(UidGidWidth). + Align(lipgloss.Right). + Foreground(metaColor). + Background(metaBg) + + permCell := lipgloss.NewStyle(). + Width(PermWidth). + Align(lipgloss.Right). + Foreground(metaColor). + Background(metaBg) + + // Render each cell with fixed width + styledSize := sizeCell.Render(sizeStr) + styledUidGid := uidGidCell.Render(uidGid) + styledPerm := permCell.Render(perm) + + // Gap style (must have background if selected) + gapStyle := lipgloss.NewStyle().Width(len(MetaGap)) + if isSelected { + gapStyle = gapStyle.Background(lipgloss.Color("#1C1C1E")) + } + styledGap := gapStyle.Render(MetaGap) + + // Join cells horizontally with gap + // This creates a rigid block where each column has exact width + metaBlock := lipgloss.JoinHorizontal( + lipgloss.Top, + styledSize, + styledGap, + styledUidGid, + styledGap, + styledPerm, + ) + + // Calculate widths for truncation + // Fixed part: cursor + prefix + diffIcon + icon + fixedPartWidth := runewidth.StringWidth(cursorMark) + + runewidth.StringWidth(prefix) + + runewidth.StringWidth(diffIcon) + + runewidth.StringWidth(icon) + + // Get actual metadata block width (should be: sizeWidth + gap + uidGidWidth + gap + permWidth) + metaBlockWidth := lipgloss.Width(metaBlock) + + // Available width for filename (between file and right-aligned metadata) + availableForName := width - fixedPartWidth - metaBlockWidth - 2 // -2 for gaps + if availableForName < 5 { + availableForName = 5 + } + + // Truncate name if needed + displayName := name + if runewidth.StringWidth(name) > availableForName { + displayName = runewidth.Truncate(name, availableForName, "…") + } + + styledName := nameStyle.Render(displayName) + + // Apply background to diffIcon and icon if selected + if isSelected { + bg := lipgloss.Color("#1C1C1E") + if diffIcon != "" { + diffIcon = lipgloss.NewStyle().Background(bg).Render(diffIcon) + } + icon = lipgloss.NewStyle().Background(bg).Render(icon) + } + + // Calculate EXACT padding to push metadata to the right edge + // Current content width (without spacer) + currentContentWidth := fixedPartWidth + runewidth.StringWidth(displayName) + metaBlockWidth + + // How many spaces needed to fill to width? + paddingNeeded := width - currentContentWidth + if paddingNeeded < 1 { + paddingNeeded = 1 // At least 1 space gap + } + + // If selected, padding should also have background + paddingStyle := lipgloss.NewStyle() + if isSelected { + paddingStyle = paddingStyle.Background(lipgloss.Color("#1C1C1E")) + } + padding := paddingStyle.Render(strings.Repeat(" ", paddingNeeded)) + + // Assemble final line: tree-guides cursor diff icon filename [SPACER] metadata + sb.WriteString(styledPrefix) + sb.WriteString(cursorMark) + sb.WriteString(diffIcon) + sb.WriteString(icon) + sb.WriteString(styledName) + sb.WriteString(padding) // <--- THIS PUSHES METADATA TO THE RIGHT EDGE + sb.WriteString(metaBlock) + sb.WriteString("\n") + + // Note: Filename comes first, metadata is right-aligned at the end + // Order: filename → size → uid:gid → permissions +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go index 2a3732b..be0111f 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/pane.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" @@ -31,20 +32,44 @@ type Pane struct { width int height int treeVM *viewmodel.FileTreeViewModel - viewport viewport.Model - treeIndex int + + // Components + selection *Selection + viewportMgr *ViewportManager + navigation *Navigation + inputHandler *InputHandler } // New creates a new tree pane func New(treeVM *viewmodel.FileTreeViewModel) Pane { - vp := viewport.New(80, 20) + // Initialize components + selection := NewSelection() + viewportMgr := NewViewportManager(80, 20) + navigation := NewNavigation(selection, viewportMgr) + inputHandler := NewInputHandler(navigation, selection, viewportMgr, treeVM) + p := Pane{ - treeVM: treeVM, - viewport: vp, - treeIndex: 0, - width: 80, - height: 20, + treeVM: treeVM, + selection: selection, + viewportMgr: viewportMgr, + navigation: navigation, + inputHandler: inputHandler, + focused: false, + width: 80, + height: 20, } + + // Set up callbacks + p.navigation.SetVisibleNodesFunc(func() []VisibleNode { + if p.treeVM == nil || p.treeVM.ViewTree == nil { + return nil + } + return CollectVisibleNodes(p.treeVM.ViewTree.Root) + }) + + p.navigation.SetRefreshFunc(p.updateContent) + p.navigation.SetToggleCollapseFunc(p.toggleCollapse) + // IMPORTANT: Generate content immediately so viewport is not empty on startup p.updateContent() return p @@ -54,6 +79,7 @@ func New(treeVM *viewmodel.FileTreeViewModel) Pane { func (m *Pane) SetSize(width, height int) { m.width = width m.height = height + m.inputHandler.SetSize(width, height) viewportWidth := width - 2 viewportHeight := height - layout.BoxContentPadding @@ -61,8 +87,7 @@ func (m *Pane) SetSize(width, height int) { viewportHeight = 0 } - m.viewport.Width = viewportWidth - m.viewport.Height = viewportHeight + m.viewportMgr.SetSize(viewportWidth, viewportHeight) // CRITICAL: Regenerate content with new width to prevent soft wrap // Without this, long paths will wrap when window is resized @@ -72,30 +97,32 @@ func (m *Pane) SetSize(width, height int) { // SetTreeVM updates the tree viewmodel func (m *Pane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) { m.treeVM = treeVM - m.treeIndex = 0 - m.viewport.GotoTop() + m.selection.SetTreeIndex(0) + m.viewportMgr.GotoTop() m.updateContent() } // SetTreeIndex sets the current tree index func (m *Pane) SetTreeIndex(index int) { - m.treeIndex = index - m.syncScroll() + m.selection.SetTreeIndex(index) + m.navigation.SyncScroll() } // GetTreeIndex returns the current tree index func (m *Pane) GetTreeIndex() int { - return m.treeIndex + return m.selection.GetTreeIndex() } // Focus sets the pane as active func (m *Pane) Focus() { m.focused = true + m.inputHandler.SetFocused(true) } // Blur sets the pane as inactive func (m *Pane) Blur() { m.focused = false + m.inputHandler.SetFocused(false) } // IsFocused returns true if the pane is focused @@ -115,40 +142,39 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - if !m.focused { - return m, nil - } - - switch msg.String() { - case "up", "k": - cmds = append(cmds, m.moveUp()) - case "down", "j": - cmds = append(cmds, m.moveDown()) - case "enter", " ": - cmds = append(cmds, m.toggleCollapse()) + cmds, consumed := m.inputHandler.HandleKeyPress(msg) + if consumed { + // Don't pass to viewport + return m, tea.Batch(cmds...) } case tea.MouseMsg: if msg.Action == tea.MouseActionPress { + var keyCmds []tea.Cmd + if msg.Button == tea.MouseButtonWheelUp { - cmds = append(cmds, m.moveUp()) + keyCmds = append(keyCmds, m.navigation.MoveUp()) } else if msg.Button == tea.MouseButtonWheelDown { - cmds = append(cmds, m.moveDown()) + keyCmds = append(keyCmds, m.navigation.MoveDown()) } if msg.Button == tea.MouseButtonLeft { - if cmd := m.handleClick(msg.X, msg.Y); cmd != nil { - cmds = append(cmds, cmd) + if cmd := m.inputHandler.HandleMouseClick(msg); cmd != nil { + keyCmds = append(keyCmds, cmd) } } + + if len(keyCmds) > 0 { + return m, tea.Batch(keyCmds...) + } } case RefreshTreeContentMsg: m.updateContent() } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + // Always update viewport + _, cmd := m.viewportMgr.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) @@ -156,134 +182,22 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the pane func (m Pane) View() string { - content := m.viewport.View() - return styles.RenderBox("Current Layer Contents", m.width, m.height, content, m.focused) -} + // 1. Generate static header + header := RenderHeader(m.width) -// moveUp moves selection up -func (m *Pane) moveUp() tea.Cmd { - if m.treeIndex > 0 { - m.treeIndex-- - m.syncScroll() - } - return nil -} + // 2. Get viewport content + content := m.viewportMgr.GetViewport().View() -// moveDown moves selection down -func (m *Pane) moveDown() tea.Cmd { - if m.treeVM == nil || m.treeVM.ViewTree == nil { - return nil - } + // 3. Combine: Header + Content + fullContent := lipgloss.JoinVertical(lipgloss.Left, header, content) - visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root) - if m.treeIndex < len(visibleNodes)-1 { - m.treeIndex++ - m.syncScroll() - } - return nil -} - -// toggleCollapse toggles the current node's collapse state -func (m *Pane) toggleCollapse() tea.Cmd { - if m.treeVM == nil || m.treeVM.ViewTree == nil { - return nil - } - - visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root) - - if m.treeIndex >= len(visibleNodes) { - m.treeIndex = len(visibleNodes) - 1 - } - if m.treeIndex < 0 { - m.treeIndex = 0 - } - - if m.treeIndex < len(visibleNodes) { - selectedNode := visibleNodes[m.treeIndex].Node - - if selectedNode.Data.FileInfo.IsDir() { - selectedNode.Data.ViewInfo.Collapsed = !selectedNode.Data.ViewInfo.Collapsed - _ = m.treeVM.Update(nil, m.width, m.height) - m.updateContent() - - return func() tea.Msg { - return NodeToggledMsg{NodeIndex: m.treeIndex} - } - } - } - - return nil -} - -// handleClick processes a mouse click -func (m *Pane) handleClick(x, y int) tea.Cmd { - if x < 0 || x >= m.width || y < 0 { - return nil - } - - relativeY := y - layout.ContentVisualOffset - if relativeY < 0 || relativeY >= m.viewport.Height { - return nil - } - - if m.treeVM == nil || m.treeVM.ViewTree == nil { - return nil - } - - visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root) - targetIndex := relativeY + m.viewport.YOffset - - if targetIndex >= 0 && targetIndex < len(visibleNodes) { - if m.treeIndex == targetIndex { - return m.toggleCollapse() - } else { - m.treeIndex = targetIndex - m.syncScroll() - return func() tea.Msg { - return TreeSelectionChangedMsg{NodeIndex: m.treeIndex} - } - } - } - - return nil -} - -// syncScroll ensures the cursor is always visible -func (m *Pane) syncScroll() { - if m.treeVM == nil || m.treeVM.ViewTree == nil { - return - } - - visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root) - if len(visibleNodes) == 0 { - return - } - - if m.treeIndex >= len(visibleNodes) { - m.treeIndex = len(visibleNodes) - 1 - } - if m.treeIndex < 0 { - m.treeIndex = 0 - } - - visibleHeight := m.viewport.Height - if visibleHeight <= 0 { - return - } - - if m.treeIndex < m.viewport.YOffset { - m.viewport.SetYOffset(m.treeIndex) - } - - if m.treeIndex >= m.viewport.YOffset+visibleHeight { - m.viewport.SetYOffset(m.treeIndex - visibleHeight + 1) - } + return styles.RenderBox("Current Layer Contents", m.width, m.height, fullContent, m.focused) } // updateContent regenerates the viewport content func (m *Pane) updateContent() { if m.treeVM == nil { - m.viewport.SetContent("No tree data") + m.viewportMgr.SetContent("No tree data") return } @@ -291,7 +205,7 @@ func (m *Pane) updateContent() { if content == "" { content = "(File tree rendering in progress...)" } - m.viewport.SetContent(content) + m.viewportMgr.SetContent(content) } // renderTreeContent generates the tree content @@ -301,17 +215,55 @@ func (m *Pane) renderTreeContent() string { } var sb strings.Builder - visibleNodes := collectVisibleNodes(m.treeVM.ViewTree.Root) + visibleNodes := CollectVisibleNodes(m.treeVM.ViewTree.Root) + viewportWidth := m.viewportMgr.GetViewport().Width for i, vn := range visibleNodes { - isSelected := (i == m.treeIndex) - renderNodeWithCursor(&sb, vn.Node, vn.Depth, isSelected, m.viewport.Width) + isSelected := (i == m.selection.GetTreeIndex()) + RenderNodeWithCursor(&sb, vn.Node, vn.Prefix, isSelected, viewportWidth) } return sb.String() } +// toggleCollapse toggles the current node's collapse state +func (m *Pane) toggleCollapse() tea.Cmd { + if m.treeVM == nil || m.treeVM.ViewTree == nil { + return nil + } + + visibleNodes := CollectVisibleNodes(m.treeVM.ViewTree.Root) + + treeIndex := m.selection.GetTreeIndex() + if treeIndex >= len(visibleNodes) { + m.selection.MoveToIndex(len(visibleNodes) - 1) + treeIndex = m.selection.GetTreeIndex() + } + if treeIndex < 0 { + m.selection.SetTreeIndex(0) + treeIndex = m.selection.GetTreeIndex() + } + + if treeIndex < len(visibleNodes) { + selectedNode := visibleNodes[treeIndex].Node + + if selectedNode.Data.FileInfo.IsDir() { + // Toggle the collapsed flag directly on the node + selectedNode.Data.ViewInfo.Collapsed = !selectedNode.Data.ViewInfo.Collapsed + + // Just refresh the UI - don't call treeVM.Update() as it rebuilds the tree + m.updateContent() + + return func() tea.Msg { + return NodeToggledMsg{NodeIndex: treeIndex} + } + } + } + + return nil +} + // GetViewport returns the underlying viewport func (m *Pane) GetViewport() *viewport.Model { - return &m.viewport + return m.viewportMgr.GetViewport() } diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/renderer.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/renderer.go deleted file mode 100644 index ca849a1..0000000 --- a/cmd/dive/cli/internal/ui/v2/panes/filetree/renderer.go +++ /dev/null @@ -1,257 +0,0 @@ -package filetree - -import ( - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" - - "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" - "github.com/wagoodman/dive/dive/filetree" -) - -// VisibleNode represents a node with its depth for rendering -type VisibleNode struct { - Node *filetree.FileNode - Depth int -} - -// collectVisibleNodes collects all visible nodes in a flat list -func collectVisibleNodes(root *filetree.FileNode) []VisibleNode { - var nodes []VisibleNode - - var traverse func(*filetree.FileNode, int) - traverse = func(node *filetree.FileNode, depth int) { - if node == nil { - return - } - - // Skip root node itself, start from children - if node.Parent != nil { - nodes = append(nodes, VisibleNode{Node: node, Depth: depth}) - } - - // Recurse into children if directory and not collapsed - if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed { - sortedChildren := sortChildren(node.Children) - for _, child := range sortedChildren { - traverse(child, depth+1) - } - } - } - - // Start from root's children if root is not collapsed - if !root.Data.ViewInfo.Collapsed { - sortedChildren := sortChildren(root.Children) - for _, child := range sortedChildren { - traverse(child, 0) - } - } - - return nodes -} - -// renderNodeWithCursor renders a node with optional cursor indicator -func renderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, depth int, isSelected bool, width int) { - if node == nil { - return - } - - // 1. Cursor indicator - var cursor string - if isSelected { - cursor = "▸ " - } else { - cursor = " " - } - - // 2. Icon and color - icon := styles.IconFile - diffIcon := "" - color := styles.DiffNormalColor - - if node.Data.FileInfo.IsDir() { - if node.Data.ViewInfo.Collapsed { - icon = styles.IconDirClosed - } else { - icon = styles.IconDirOpen - } - } else if node.Data.FileInfo.TypeFlag == 16 { // Symlink - icon = styles.IconSymlink - } - - // 3. Diff status - switch node.Data.DiffType { - case filetree.Added: - color = styles.DiffAddedColor - diffIcon = styles.IconAdded - case filetree.Removed: - color = styles.DiffRemovedColor - diffIcon = styles.IconRemoved - case filetree.Modified: - color = styles.DiffModifiedColor - diffIcon = styles.IconModified - } - - // 4. Format name - name := node.Name - if name == "" { - name = "/" - } - // Symlinks - if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" { - name += " → " + node.Data.FileInfo.Linkname - } - - // 5. Indent - indent := strings.Repeat(" ", depth) - - // 6. Build line (without styles yet) - // Add space after diffIcon if present - if diffIcon != "" { - diffIcon += " " - } - - rawText := fmt.Sprintf("%s%s%s%s", indent+cursor, diffIcon, icon, name) - - // ВАЖНО: Truncate to prevent line wrapping which breaks scroll alignment - // CRITICAL: Leave 1 char margin for terminal cursor to prevent auto-scroll - maxTextWidth := width - 1 - if maxTextWidth < 10 { - maxTextWidth = 10 // Protection - } - - truncatedText := runewidth.Truncate(rawText, maxTextWidth, "…") - - // 7. Apply style - style := lipgloss.NewStyle().Foreground(color) - if isSelected { - // For selected items, fill background to full width BUT don't add padding - // Using MaxWidth instead of Width to prevent adding extra whitespace - style = style. - Background(styles.PrimaryColor). - Foreground(lipgloss.Color("#000000")). - Bold(true). - MaxWidth(width) // Prevent exceeding width, but don't add padding - } - - sb.WriteString(style.Render(truncatedText)) - sb.WriteString("\n") - - // Note: no recursion here since we're using collectVisibleNodes instead -} - -// renderNode recursively renders a tree node with icons and colors -func renderNode(sb *strings.Builder, node *filetree.FileNode, depth int, prefix string) { - if node == nil { - return - } - - // Don't render root element (it's usually empty) - if node.Parent == nil { - // Render root's children - if !node.Data.ViewInfo.Collapsed { - sortedChildren := sortChildren(node.Children) - for _, child := range sortedChildren { - renderNode(sb, child, depth, "") - } - } - return - } - - // 1. Determine icon - icon := styles.IconFile - diffIcon := "" - - // Determine file type - if node.Data.FileInfo.IsDir() { - if node.Data.ViewInfo.Collapsed { - icon = styles.IconDirClosed - } else { - icon = styles.IconDirOpen - } - } else if node.Data.FileInfo.TypeFlag == 16 { // tar.TypeSymlink - icon = styles.IconSymlink - } - - // Determine Diff (Added/Removed/Modified) - color := styles.DiffNormalColor - - switch node.Data.DiffType { - case filetree.Added: - color = styles.DiffAddedColor - diffIcon = styles.IconAdded - case filetree.Removed: - color = styles.DiffRemovedColor - diffIcon = styles.IconRemoved - case filetree.Modified: - color = styles.DiffModifiedColor - diffIcon = styles.IconModified - } - - // 2. Build line - name := node.Name - if name == "" { - name = "/" - } - - // Add symlink target if present - if node.Data.FileInfo.TypeFlag == 16 && node.Data.FileInfo.Linkname != "" { - name += " → " + node.Data.FileInfo.Linkname - } - - // Build line with prefix (indent) - line := prefix + diffIcon + " " + icon + " " + name - - // Apply color - style := lipgloss.NewStyle().Foreground(color) - sb.WriteString(style.Render(line)) - sb.WriteString("\n") - - // 3. Recursion for children (if folder not collapsed) - if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed && !node.IsLeaf() { - // Calculate prefix for children - childPrefix := prefix + " " - - // Sort and render children - sortedChildren := sortChildren(node.Children) - for _, child := range sortedChildren { - renderNode(sb, child, depth+1, childPrefix) - } - } -} - -// sortChildren sorts node children: directories first, then files, all alphabetically -func sortChildren(children map[string]*filetree.FileNode) []*filetree.FileNode { - if children == nil { - return nil - } - - // Split into directories and files - var dirs []*filetree.FileNode - var files []*filetree.FileNode - - for _, child := range children { - if child.Data.FileInfo.IsDir() { - dirs = append(dirs, child) - } else { - files = append(files, child) - } - } - - // Sort directories - sort.Slice(dirs, func(i, j int) bool { - return dirs[i].Name < dirs[j].Name - }) - - // Sort files - sort.Slice(files, func(i, j int) bool { - return files[i].Name < files[j].Name - }) - - // Combine: directories first, then files - result := append(dirs, files...) - return result -} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go new file mode 100644 index 0000000..3c54430 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go @@ -0,0 +1,51 @@ +package filetree + +// Selection manages the tree index selection state +type Selection struct { + treeIndex int + maxIndex int +} + +// NewSelection creates a new selection with default values +func NewSelection() *Selection { + return &Selection{ + treeIndex: 0, + maxIndex: 0, + } +} + +// SetTreeIndex sets the current tree index directly +func (s *Selection) SetTreeIndex(index int) { + s.treeIndex = index +} + +// GetTreeIndex returns the current tree index +func (s *Selection) GetTreeIndex() int { + return s.treeIndex +} + +// MoveToIndex moves selection to the specified index +func (s *Selection) MoveToIndex(index int) { + s.treeIndex = index + s.ValidateBounds() +} + +// SetMaxIndex updates the maximum valid index +func (s *Selection) SetMaxIndex(max int) { + s.maxIndex = max +} + +// GetMaxIndex returns the maximum valid index +func (s *Selection) GetMaxIndex() int { + return s.maxIndex +} + +// ValidateBounds ensures treeIndex is within [0, maxIndex] +func (s *Selection) ValidateBounds() { + if s.treeIndex >= s.maxIndex && s.maxIndex > 0 { + s.treeIndex = s.maxIndex - 1 + } + if s.treeIndex < 0 { + s.treeIndex = 0 + } +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go new file mode 100644 index 0000000..96a1ee1 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go @@ -0,0 +1,145 @@ +package filetree + +import ( + "sort" + "strings" + + "github.com/wagoodman/dive/dive/filetree" +) + +// VisibleNode represents a node with its tree prefix for rendering +type VisibleNode struct { + Node *filetree.FileNode + Prefix string // Tree guide prefix, e.g. "│ ├── ", "└── " +} + +// CollectVisibleNodes collects all visible nodes with tree guide prefixes +func CollectVisibleNodes(root *filetree.FileNode) []VisibleNode { + var nodes []VisibleNode + + // levels tracks state for each nesting level: + // true = this level is the last child (use spaces) + // false = more children follow (use │) + var traverse func(*filetree.FileNode, []bool) + + traverse = func(node *filetree.FileNode, levels []bool) { + if node == nil { + return + } + + // Generate tree prefix based on levels (compact, 2 chars per level) + // Example levels: [false, true] -> "│ └─" + var prefixBuilder strings.Builder + for i, isLast := range levels { + if i == len(levels)-1 { + // Current level (the node itself) - 2 chars + if isLast { + prefixBuilder.WriteString("└─") // Was "└── " + } else { + prefixBuilder.WriteString("├─") // Was "├── " + } + } else { + // Parent levels (indentation) - 2 chars + if isLast { + prefixBuilder.WriteString(" ") // Was " " + } else { + prefixBuilder.WriteString("│ ") // Was "│ " + } + } + } + + // Add current node (skip root when rendering) + if node.Parent != nil { + nodes = append(nodes, VisibleNode{ + Node: node, + Prefix: prefixBuilder.String(), + }) + } + + // Recurse into children if directory and not collapsed + if node.Data.FileInfo.IsDir() && !node.Data.ViewInfo.Collapsed { + sortedChildren := SortChildren(node.Children) + count := len(sortedChildren) + for i, child := range sortedChildren { + // Create new levels array for child + isLastChild := i == count-1 + newLevels := make([]bool, len(levels)+1) + copy(newLevels, levels) + newLevels[len(levels)] = isLastChild + + traverse(child, newLevels) + } + } + } + + // Start from root + if !root.Data.ViewInfo.Collapsed { + sortedChildren := SortChildren(root.Children) + count := len(sortedChildren) + for i, child := range sortedChildren { + traverse(child, []bool{i == count - 1}) + } + } + + return nodes +} + +// SortChildren sorts node children: directories first, then files, all alphabetically +func SortChildren(children map[string]*filetree.FileNode) []*filetree.FileNode { + if children == nil { + return nil + } + + // Split into directories and files + var dirs []*filetree.FileNode + var files []*filetree.FileNode + + for _, child := range children { + if child.Data.FileInfo.IsDir() { + dirs = append(dirs, child) + } else { + files = append(files, child) + } + } + + // Sort directories + sort.Slice(dirs, func(i, j int) bool { + return dirs[i].Name < dirs[j].Name + }) + + // Sort files + sort.Slice(files, func(i, j int) bool { + return files[i].Name < files[j].Name + }) + + // Combine: directories first, then files + result := append(dirs, files...) + return result +} + +// FindParentIndex finds the index of the parent directory of the node at the given index +// Returns -1 if the node has no parent or parent is not visible +func FindParentIndex(visibleNodes []VisibleNode, currentIndex int) int { + if currentIndex < 0 || currentIndex >= len(visibleNodes) { + return -1 + } + + currentNode := visibleNodes[currentIndex].Node + parentNode := currentNode.Parent + + // Root node has no parent + if parentNode == nil || parentNode.Parent == nil { + // parent.Parent == nil means parent is actually the root node + return -1 + } + + // Find the parent in the visible nodes + for i, vn := range visibleNodes { + if vn.Node == parentNode { + return i + } + } + + // Parent exists but is not visible (e.g., collapsed ancestor) + return -1 +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/filetree/viewport_manager.go b/cmd/dive/cli/internal/ui/v2/panes/filetree/viewport_manager.go new file mode 100644 index 0000000..0a01857 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/panes/filetree/viewport_manager.go @@ -0,0 +1,65 @@ +package filetree + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/viewport" +) + +// ViewportManager wraps bubbletea viewport with typed methods +type ViewportManager struct { + viewport viewport.Model +} + +// NewViewportManager creates a new viewport manager with the given dimensions +func NewViewportManager(width, height int) *ViewportManager { + vp := viewport.New(width, height) + return &ViewportManager{ + viewport: vp, + } +} + +// SetSize updates the viewport dimensions +func (v *ViewportManager) SetSize(width, height int) { + v.viewport.Width = width + v.viewport.Height = height +} + +// SetContent updates the viewport content +func (v *ViewportManager) SetContent(content string) { + v.viewport.SetContent(content) +} + +// GetViewport returns the underlying viewport model +func (v *ViewportManager) GetViewport() *viewport.Model { + return &v.viewport +} + +// GotoTop scrolls to the top of the viewport +func (v *ViewportManager) GotoTop() { + v.viewport.GotoTop() +} + +// GotoBottom scrolls to the bottom of the viewport +func (v *ViewportManager) GotoBottom() { + v.viewport.GotoBottom() +} + +// SetYOffset sets the vertical scroll offset +func (v *ViewportManager) SetYOffset(offset int) { + v.viewport.SetYOffset(offset) +} + +// GetYOffset returns the current vertical scroll offset +func (v *ViewportManager) GetYOffset() int { + return v.viewport.YOffset +} + +// GetHeight returns the viewport height +func (v *ViewportManager) GetHeight() int { + return v.viewport.Height +} + +// Update passes a message to the underlying viewport +func (v *ViewportManager) Update(msg tea.Msg) (viewport.Model, tea.Cmd) { + return v.viewport.Update(msg) +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/image/pane.go b/cmd/dive/cli/internal/ui/v2/panes/image/pane.go index 3416c04..9594ec2 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/image/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/image/pane.go @@ -139,13 +139,17 @@ func (m *Pane) updateContent() { func (m *Pane) generateContent() string { width := m.width - 2 // Subtract borders + // Count files > 0 bytes + filesGreaterThanZeroKB := m.countFilesAboveZeroBytes() + // Header with statistics headerText := fmt.Sprintf( - "Image name: %s\nTotal Image size: %s\nPotential wasted space: %s\nImage efficiency score: %.0f%%", + "Image name: %s\nTotal Image size: %s\nPotential wasted space: %s\nImage efficiency score: %.0f%%\nFiles > 0 KB total: %d", m.analysis.Image, utils.FormatSize(m.analysis.SizeBytes), utils.FormatSize(m.analysis.WastedBytes), m.analysis.Efficiency*100, + filesGreaterThanZeroKB, ) // Table header @@ -173,3 +177,20 @@ func (m *Pane) generateContent() string { return fullContent.String() } + +// countFilesAboveZeroBytes counts the total number of files with size > 0 bytes across all inefficiencies +func (m *Pane) countFilesAboveZeroBytes() int { + if m.analysis == nil { + return 0 + } + + count := 0 + for _, ineff := range m.analysis.Inefficiencies { + for _, node := range ineff.Nodes { + if node.Size > 0 { + count++ + } + } + } + return count +} diff --git a/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go b/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go index 06c003a..25b3b6e 100644 --- a/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go +++ b/cmd/dive/cli/internal/ui/v2/panes/layers/pane.go @@ -11,8 +11,11 @@ import ( "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/components" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils" + "github.com/wagoodman/dive/dive/filetree" + "github.com/wagoodman/dive/dive/image" ) // LayerChangedMsg is sent when the active layer changes @@ -20,25 +23,54 @@ type LayerChangedMsg struct { LayerIndex int } +// ShowLayerDetailMsg is sent to show the layer detail modal +type ShowLayerDetailMsg struct { + Layer *image.Layer +} + +// Define layout constants to ensure click detection matches rendering +const ( + ColWidthPrefix = 1 // " " + ColWidthID = 12 + ColWidthSize = 9 + ColPadding = 1 + // Calculation: Prefix(1) + ID(12) + Pad(1) + Size(9) + Pad(1) + StatsStartOffset = ColWidthPrefix + ColWidthID + ColPadding + ColWidthSize + ColPadding +) + // Pane manages the layers list type Pane struct { - focused bool - width int - height int - layerVM *viewmodel.LayerSetState - viewport viewport.Model - layerIndex int + focused bool + width int + height int + layerVM *viewmodel.LayerSetState + comparer *filetree.Comparer // For computing layer comparison trees + viewport viewport.Model + layerIndex int + statsRows []components.FileStatsRow // Stats row for each layer } // New creates a new layers pane -func New(layerVM *viewmodel.LayerSetState) Pane { +func New(layerVM *viewmodel.LayerSetState, comparer filetree.Comparer) Pane { vp := viewport.New(80, 20) + + // Initialize stats rows + var statsRows []components.FileStatsRow + if layerVM != nil && len(layerVM.Layers) > 0 { + statsRows = make([]components.FileStatsRow, len(layerVM.Layers)) + for i := range layerVM.Layers { + statsRows[i] = components.NewFileStatsRow() + } + } + p := Pane{ layerVM: layerVM, + comparer: &comparer, viewport: vp, layerIndex: 0, width: 80, height: 20, + statsRows: statsRows, } // IMPORTANT: Generate content immediately so viewport is not empty on startup p.updateContent() @@ -123,6 +155,13 @@ func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.moveUp()) case "down", "j": cmds = append(cmds, m.moveDown()) + case " ": + // Show layer detail modal + if m.layerVM != nil && m.layerIndex >= 0 && m.layerIndex < len(m.layerVM.Layers) { + cmds = append(cmds, func() tea.Msg { + return ShowLayerDetailMsg{Layer: m.layerVM.Layers[m.layerIndex]} + }) + } } case tea.MouseMsg: @@ -189,10 +228,12 @@ func (m *Pane) moveDown() tea.Cmd { // handleClick processes a mouse click func (m *Pane) handleClick(x, y int) tea.Cmd { + // 1. Basic Bounds Check if x < 0 || x >= m.width || y < 0 { return nil } + // 2. Adjust Y for Viewport scrolling and Header relativeY := y - layout.ContentVisualOffset if relativeY < 0 || relativeY >= m.viewport.Height { return nil @@ -203,6 +244,31 @@ func (m *Pane) handleClick(x, y int) tea.Cmd { return nil } + // 3. Adjust X for Border + // The pane is rendered with RenderBox, which adds 1 char border on the left. + // So the content technically starts at X=1 relative to the pane. + // We subtract 1 to get the X coordinate relative to the *content*. + contentX := x - 1 + if contentX < 0 { + return nil + } + + // 4. Check if click is in stats area + // We use the shared constant StatsStartOffset to ensure math matches GenerateContent + if targetIndex < len(m.statsRows) { + partType, found := m.statsRows[targetIndex].GetPartAtPosition(contentX, StatsStartOffset) + if found { + // Click on a stats part - toggle that specific part + part := m.statsRows[targetIndex].GetPart(partType) + if part != nil { + part.ToggleActive() + m.updateContent() + return nil + } + } + } + + // 5. Click outside stats - select layer return m.SetLayerIndex(targetIndex) } @@ -219,37 +285,80 @@ func (m *Pane) updateContent() { // generateContent creates the layers content func (m *Pane) generateContent() string { - width := m.width - 2 - - const ( - idWidth = 12 - sizeWidth = 9 - spaces = 4 - ) + width := m.width - 2 // Viewport width (without panel borders) var fullContent strings.Builder for i, layer := range m.layerVM.Layers { - prefix := " " + prefix := " " style := lipgloss.NewStyle() if i == m.layerIndex { - prefix = "● " + // No bullet, just color highlighting style = styles.SelectedLayerStyle } + // Format ID id := layer.Id - if len(id) > idWidth { - id = id[:idWidth] + if len(id) > ColWidthID { + id = id[:ColWidthID] } + // Format Size size := utils.FormatSize(layer.Size) + // Update and get stats from component + statsStr := "" + statsVisualWidth := 9 // Default approximate width + if i < len(m.statsRows) { + // Use comparer to get the comparison tree for this layer + // For layer i, we want to show changes from layer i-1 to i (or 0 to i for first layer) + var treeToCompare *filetree.FileTree + if m.comparer != nil { + // Get tree for comparing previous layer (or 0) to current layer + // This follows the CompareSingleLayer mode logic + bottomTreeStart := 0 + bottomTreeStop := i - 1 + if bottomTreeStop < 0 { + bottomTreeStop = i + } + topTreeStart := i + topTreeStop := i + + key := filetree.NewTreeIndexKey(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) + comparisonTree, err := m.comparer.GetTree(key) + if err == nil && comparisonTree != nil { + treeToCompare = comparisonTree + } + } + + // Fallback to layer.Tree if comparer didn't work + if treeToCompare == nil { + treeToCompare = layer.Tree + } + + stats := utils.CalculateFileStats(treeToCompare) + m.statsRows[i].SetStats(stats) + statsStr = m.statsRows[i].Render() + + // Calculate total visual width for command truncation math + addedW := m.statsRows[i].GetAdded().GetVisualWidth() + modW := m.statsRows[i].GetModified().GetVisualWidth() + remW := m.statsRows[i].GetRemoved().GetVisualWidth() + statsVisualWidth = addedW + 1 + modW + 1 + remW // +1 for spaces + } + + // Clean command from newlines rawCmd := strings.ReplaceAll(layer.Command, "\n", " ") rawCmd = strings.TrimSpace(rawCmd) - availableCmdWidth := width - 2 - idWidth - spaces - sizeWidth - if availableCmdWidth < 5 { + // Calculate available space for command + // Logic must match StatsStartOffset constants + // Used = Prefix(1) + ID(12) + Pad(1) + Size(9) + Pad(1) + StatsWidth + Pad(1) + usedWidth := StatsStartOffset + statsVisualWidth + 1 + + availableCmdWidth := width - usedWidth + if availableCmdWidth < 0 { availableCmdWidth = 0 } @@ -258,12 +367,22 @@ func (m *Pane) generateContent() string { cmd = runewidth.Truncate(rawCmd, availableCmdWidth, "...") } - text := fmt.Sprintf("%s%-*s %*s %s", prefix, idWidth, id, sizeWidth, size, cmd) - - maxLineWidth := width - if runewidth.StringWidth(text) > maxLineWidth { - text = runewidth.Truncate(text, maxLineWidth, "") - } + // Build the line using strict column widths + // %-1s = Prefix + // %-*s = ID (left align, width 12) + // " " = Padding + // %*s = Size (right align, width 9) + // " " = Padding + // %s = Stats + // " " = Padding + // %s = Command + text := fmt.Sprintf("%-1s%-*s %*s %s %s", + prefix, + ColWidthID, id, + ColWidthSize, size, + statsStr, + cmd, + ) fullContent.WriteString(style.Render(text)) fullContent.WriteString("\n") diff --git a/cmd/dive/cli/internal/ui/v2/styles/icons.go b/cmd/dive/cli/internal/ui/v2/styles/icons.go index 0f6186f..2c73bf3 100644 --- a/cmd/dive/cli/internal/ui/v2/styles/icons.go +++ b/cmd/dive/cli/internal/ui/v2/styles/icons.go @@ -3,9 +3,9 @@ package styles // --- File Icons --- var ( - IconDirOpen = "📂 " - IconDirClosed = "📁 " - IconFile = "📄 " + IconDirOpen = "󰝰 " // nf-md-folder_open + IconDirClosed = "󰉋 " // nf-md-folder + IconFile = "󰈔 " // nf-md-file IconSymlink = "🔗 " ) diff --git a/cmd/dive/cli/internal/ui/v2/styles/styles.go b/cmd/dive/cli/internal/ui/v2/styles/styles.go index c75a5ee..693b625 100644 --- a/cmd/dive/cli/internal/ui/v2/styles/styles.go +++ b/cmd/dive/cli/internal/ui/v2/styles/styles.go @@ -56,6 +56,23 @@ var FileTreeModifiedStyle = lipgloss.NewStyle(). Foreground(WarningColor). Bold(true) +// --- File Stats Styles --- + +// FileStatsAddedStyle for added files count +var FileStatsAddedStyle = lipgloss.NewStyle(). + Foreground(SuccessColor). + Bold(true) + +// FileStatsModifiedStyle for modified files count +var FileStatsModifiedStyle = lipgloss.NewStyle(). + Foreground(WarningColor). + Bold(true) + +// FileStatsRemovedStyle for removed files count +var FileStatsRemovedStyle = lipgloss.NewStyle(). + Foreground(ErrorColor). + Bold(true) + // --- Rendering Functions --- // RenderBox creates a bordered box with title and content @@ -116,3 +133,13 @@ func RenderBox(title string, width, height int, content string, isSelected bool) func TruncateString(s string, maxLen int) string { return runewidth.Truncate(s, maxLen, "...") } + +// --- File Tree Visual Styles --- + +// TreeGuideStyle for tree guide lines (│ ├ └) +var TreeGuideStyle = lipgloss.NewStyle(). + Foreground(DarkGrayColor) + +// MetaDataStyle for permissions, UID, and size (muted, less prominent) +var MetaDataStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6e6e73")) diff --git a/cmd/dive/cli/internal/ui/v2/utils/tree_stats.go b/cmd/dive/cli/internal/ui/v2/utils/tree_stats.go new file mode 100644 index 0000000..2abdfd0 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v2/utils/tree_stats.go @@ -0,0 +1,39 @@ +package utils + +import ( + "github.com/wagoodman/dive/dive/filetree" +) + +// FileStats holds file change statistics +type FileStats struct { + Added int + Modified int + Removed int +} + +// CalculateFileStats walks the file tree and counts file changes +func CalculateFileStats(tree *filetree.FileTree) FileStats { + stats := FileStats{} + + if tree == nil || tree.Root == nil { + return stats + } + + visitor := func(node *filetree.FileNode) error { + // Only count leaf nodes (actual files, not directories) + if len(node.Children) == 0 { + switch node.Data.DiffType { + case filetree.Added: + stats.Added++ + case filetree.Modified: + stats.Modified++ + case filetree.Removed: + stats.Removed++ + } + } + return nil + } + + _ = tree.VisitDepthChildFirst(visitor, nil) + return stats +}