mirror of
https://github.com/wagoodman/dive
synced 2026-03-14 22:35:50 +01:00
Refactor file tree pane: modularize components and improve rendering
- Introduced Selection, ViewportManager, and Navigation components to manage state and interactions in the file tree pane. - Removed the renderer.go file and integrated rendering logic directly into the pane. - Enhanced the updateContent and renderTreeContent methods for better performance and clarity. - Updated the View method to include a header and improved content layout using lipgloss. - Added tree traversal utilities to manage visible nodes and their prefixes. - Implemented file statistics tracking in the layers pane, displaying added, modified, and removed file counts. - Updated styles for file icons and added new styles for file statistics. - Improved mouse and keyboard interactions for better user experience.
This commit is contained in:
parent
bb7acef312
commit
d1b1ec85f3
19 changed files with 1940 additions and 467 deletions
200
cmd/dive/cli/internal/ui/v2/app/layer_detail_modal.go
Normal file
200
cmd/dive/cli/internal/ui/v2/app/layer_detail_modal.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
240
cmd/dive/cli/internal/ui/v2/components/file_stats.go
Normal file
240
cmd/dive/cli/internal/ui/v2/components/file_stats.go
Normal file
|
|
@ -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
|
||||
}
|
||||
46
cmd/dive/cli/internal/ui/v2/panes/filetree/doc.go
Normal file
46
cmd/dive/cli/internal/ui/v2/panes/filetree/doc.go
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
165
cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go
Normal file
165
cmd/dive/cli/internal/ui/v2/panes/filetree/input_handler.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:])
|
||||
}
|
||||
246
cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go
Normal file
246
cmd/dive/cli/internal/ui/v2/panes/filetree/navigation.go
Normal file
|
|
@ -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
|
||||
}
|
||||
211
cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go
Normal file
211
cmd/dive/cli/internal/ui/v2/panes/filetree/node_renderer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
51
cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go
Normal file
51
cmd/dive/cli/internal/ui/v2/panes/filetree/selection.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
145
cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go
Normal file
145
cmd/dive/cli/internal/ui/v2/panes/filetree/tree_traversal.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = "🔗 "
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
39
cmd/dive/cli/internal/ui/v2/utils/tree_stats.go
Normal file
39
cmd/dive/cli/internal/ui/v2/utils/tree_stats.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue