feat: add new UI panes for image details, layer details, and file tree

- Implemented a details pane to display information about a single layer, including tags, ID, size, digest, and command.
- Created a file tree pane to manage and display the file structure of the image, allowing for navigation and selection of files and directories.
- Added an image pane to show image-level statistics and inefficiencies, including total size and potential wasted space.
- Developed a layers pane to list and manage layers, allowing users to navigate through layers and view their details.
- Introduced new styles for UI elements, including colors and icons for better visual representation.
- Added utility functions for formatting sizes and rendering content in a human-readable format.
This commit is contained in:
Aslan Dukaev 2026-01-11 14:15:15 +03:00
commit bb7acef312
13 changed files with 324 additions and 265 deletions

View file

@ -1,37 +1,21 @@
package app
// Note: LayerChangedMsg is now defined in panes/layers package
// Note: NodeToggledMsg, TreeSelectionChangedMsg, RefreshTreeContentMsg are now defined in panes/filetree package
// LayerChangedMsg is sent when the active layer changes
type LayerChangedMsg struct {
LayerIndex int
}
// The following message types are kept for potential future use or for app-level coordination
// NodeToggledMsg is sent when a tree node is collapsed/expanded
type NodeToggledMsg struct {
NodeIndex int
}
// PaneChangedMsg is sent when the active pane changes
// PaneChangedMsg is sent when the active pane changes (for future use)
type PaneChangedMsg struct {
Pane Pane
}
// LayerSelectionChangedMsg is sent when a layer is selected (via click or keyboard)
// LayerSelectionChangedMsg is sent when a layer is selected (for future use)
type LayerSelectionChangedMsg struct {
LayerIndex int
}
// TreeSelectionChangedMsg is sent when a tree node is selected
type TreeSelectionChangedMsg struct {
NodeIndex int
}
// PaneFocusRequestMsg requests focus to be moved to a specific pane
// PaneFocusRequestMsg requests focus to be moved to a specific pane (for future use)
type PaneFocusRequestMsg struct {
Pane Pane
}
// RefreshTreeContentMsg requests tree content to be refreshed
type RefreshTreeContentMsg struct {
LayerIndex int
}

View file

@ -11,8 +11,13 @@ import (
v1 "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1"
"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"
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"
"github.com/wagoodman/dive/dive/image"
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// Pane represents a UI pane
@ -77,10 +82,10 @@ type Model struct {
layout LayoutCache
// Pane components (independent tea.Models)
layersPane LayersPane
detailsPane DetailsPane
imagePane ImagePane
treePane TreePane
layersPane layers.Pane
detailsPane details.Pane
imagePane imagepane.Pane
treePane filetree.Pane
// Active pane state
activePane Pane
@ -89,7 +94,7 @@ type Model struct {
filter FilterModel
// Help and key bindings
keys KeyMap
keys keys.KeyMap
help help.Model
}
@ -118,17 +123,17 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
h := help.New()
h.Width = 80
h.Styles.ShortKey = v2styles.StatusStyle
h.Styles.ShortDesc = v2styles.StatusStyle
h.Styles.Ellipsis = v2styles.StatusStyle
h.Styles.ShortKey = styles.StatusStyle
h.Styles.ShortDesc = styles.StatusStyle
h.Styles.Ellipsis = styles.StatusStyle
f := NewFilterModel()
// Create pane components
layersPane := NewLayersPane(layerVM)
detailsPane := NewDetailsPane()
imagePane := NewImagePane(&analysis)
treePane := NewTreePane(treeVM)
layersPane := layers.New(layerVM)
detailsPane := details.New()
imagePane := imagepane.New(&analysis)
treePane := filetree.New(treeVM)
// Set initial focus
layersPane.Focus()
@ -149,7 +154,7 @@ func NewModel(analysis image.Analysis, content image.ContentReader, prefs v1.Pre
height: 24,
quitting: false,
activePane: PaneLayer,
keys: Keys,
keys: keys.Keys,
help: h,
filter: f,
}
@ -220,7 +225,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.activePane {
case PaneLayer:
newPane, cmd := m.layersPane.Update(msg)
m.layersPane = newPane.(LayersPane)
m.layersPane = newPane.(layers.Pane)
cmds = append(cmds, cmd)
case PaneDetails:
@ -228,12 +233,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case PaneImage:
newPane, cmd := m.imagePane.Update(msg)
m.imagePane = newPane.(ImagePane)
m.imagePane = newPane.(imagepane.Pane)
cmds = append(cmds, cmd)
case PaneTree:
newPane, cmd := m.treePane.Update(msg)
m.treePane = newPane.(TreePane)
m.treePane = newPane.(filetree.Pane)
cmds = append(cmds, cmd)
}
@ -250,7 +255,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filter.Show()
}
case LayerChangedMsg:
case layers.LayerChangedMsg:
// Layer changed - update details pane and tree
if m.layerVM != nil && msg.LayerIndex >= 0 && msg.LayerIndex < len(m.layerVM.Layers) {
m.detailsPane.SetLayer(m.layerVM.Layers[msg.LayerIndex])
@ -263,11 +268,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.imagePane.Blur()
m.treePane.Blur()
case NodeToggledMsg:
case filetree.NodeToggledMsg:
// Tree node was toggled - tree pane already updated its content
// Nothing to do here
case RefreshTreeContentMsg:
case filetree.RefreshTreeContentMsg:
// Request to refresh tree content
m.treePane.SetTreeVM(m.treeVM)
@ -287,7 +292,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if y < layersEndY {
// Layers pane
newPane, cmd := m.layersPane.Update(msg)
m.layersPane = newPane.(LayersPane)
m.layersPane = newPane.(layers.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneLayer {
m.activePane = PaneLayer
@ -302,7 +307,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
// Image pane
newPane, cmd := m.imagePane.Update(msg)
m.imagePane = newPane.(ImagePane)
m.imagePane = newPane.(imagepane.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneImage {
m.activePane = PaneImage
@ -312,7 +317,7 @@ 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.(TreePane)
m.treePane = newPane.(filetree.Pane)
cmds = append(cmds, cmd)
if m.activePane != PaneTree {
m.activePane = PaneTree
@ -391,7 +396,7 @@ func (m *Model) updateTreeForCurrentLayer() {
// View implements tea.Model (PURE FUNCTION - no side effects!)
func (m Model) View() string {
if m.quitting {
return v2styles.TitleStyle.Foreground(v2styles.SuccessColor).Render("Thanks for using Dive V2UI!")
return styles.TitleStyle.Foreground(styles.SuccessColor).Render("Thanks for using Dive V2UI!")
}
// Calculate layout if not yet calculated (first run)
@ -403,7 +408,7 @@ func (m Model) View() string {
statusBar := m.help.View(m.keys)
// Add active pane indicator to status bar
paneName := v2styles.StatusStyle.Render(fmt.Sprintf(" Active: %s ", m.activePane))
paneName := styles.StatusStyle.Render(fmt.Sprintf(" Active: %s ", m.activePane))
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, strings.Repeat(" ", 5), paneName)
// Render panes directly using their View() methods

View file

@ -1,4 +1,4 @@
package app
package keys
import "github.com/charmbracelet/bubbles/key"
@ -24,9 +24,9 @@ func (k KeyMap) ShortHelp() []key.Binding {
// FullHelp returns all keys (for extended help)
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down}, // Navigation
{k.Enter, k.Space}, // Actions
{k.Tab, k.Filter, k.Quit}, // System
{k.Up, k.Down}, // Navigation
{k.Enter, k.Space}, // Actions
{k.Tab, k.Filter, k.Quit}, // System
}
}

View file

@ -1,4 +1,4 @@
package app
package details
import (
"fmt"
@ -8,71 +8,72 @@ import (
"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/image"
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// DetailsPane displays information about a single layer
type DetailsPane struct {
// Pane displays information about a single layer
type Pane struct {
focused bool
width int
height int
layer *image.Layer
}
// NewDetailsPane creates a new details pane
func NewDetailsPane() DetailsPane {
return DetailsPane{
// New creates a new details pane
func New() Pane {
return Pane{
width: 80,
height: 10,
}
}
// SetSize updates the pane dimensions
func (m *DetailsPane) SetSize(width, height int) {
func (m *Pane) SetSize(width, height int) {
m.width = width
m.height = height
}
// SetLayer updates the layer to display
func (m *DetailsPane) SetLayer(layer *image.Layer) {
func (m *Pane) SetLayer(layer *image.Layer) {
m.layer = layer
}
// Focus sets the pane as active
func (m *DetailsPane) Focus() {
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *DetailsPane) Blur() {
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *DetailsPane) IsFocused() bool {
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m DetailsPane) Init() tea.Cmd {
func (m Pane) Init() tea.Cmd {
return nil
}
// Update handles messages
func (m DetailsPane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Details pane doesn't handle any messages - it's read-only
return m, nil
}
// View renders the pane
func (m DetailsPane) View() string {
func (m Pane) View() string {
content := m.renderContent()
return v2styles.RenderBox("Layer Details", m.width, m.height, content, m.focused)
return styles.RenderBox("Layer Details", m.width, m.height, content, m.focused)
}
// renderContent generates the details content
func (m DetailsPane) renderContent() string {
func (m Pane) renderContent() string {
// Calculate available space: Height - Borders(2) - Header(2)
maxLines := m.height - 4
if maxLines < 0 {
@ -101,16 +102,16 @@ func (m DetailsPane) renderContent() string {
if lipgloss.Width(tags) > m.width-8 {
tags = runewidth.Truncate(tags, m.width-8, "...")
}
if !addLine(v2styles.LayerHeaderStyle.Render(fmt.Sprintf("Tags: %s", tags))) {
if !addLine(styles.LayerHeaderStyle.Render(fmt.Sprintf("Tags: %s", tags))) {
goto finish
}
}
// ID & Size
if !addLine(v2styles.LayerValueStyle.Render(fmt.Sprintf("Id: %s", layer.Id))) {
if !addLine(styles.LayerValueStyle.Render(fmt.Sprintf("Id: %s", layer.Id))) {
goto finish
}
if !addLine(v2styles.LayerValueStyle.Render(fmt.Sprintf("Size: %s", formatSize(layer.Size)))) {
if !addLine(styles.LayerValueStyle.Render(fmt.Sprintf("Size: %s", utils.FormatSize(layer.Size)))) {
goto finish
}
@ -125,18 +126,18 @@ func (m DetailsPane) renderContent() string {
if lipgloss.Width(digest) > maxDigestWidth {
digest = runewidth.Truncate(digest, maxDigestWidth, "...")
}
if !addLine(v2styles.LayerValueStyle.Render(fmt.Sprintf("Digest: %s", digest))) {
if !addLine(styles.LayerValueStyle.Render(fmt.Sprintf("Digest: %s", digest))) {
goto finish
}
}
// Command - Maximum 2 lines!
if !addLine(v2styles.LayerHeaderStyle.Render("Command:")) {
if !addLine(styles.LayerHeaderStyle.Render("Command:")) {
goto finish
}
if layer.Command == "" {
addLine(v2styles.LayerValueStyle.Render("(unavailable)"))
addLine(styles.LayerValueStyle.Render("(unavailable)"))
} else {
maxWidth := m.width - 4
if maxWidth < 10 {
@ -150,14 +151,14 @@ func (m DetailsPane) renderContent() string {
// Show max 2 lines: first line + last line (with "..." prefix if long)
if len(cmdLines) == 1 {
// Short command - fits in 1 line
addLine(v2styles.LayerValueStyle.Render(cmdLines[0]))
addLine(styles.LayerValueStyle.Render(cmdLines[0]))
} else if len(cmdLines) == 2 {
// Exactly 2 lines - show both
addLine(v2styles.LayerValueStyle.Render(cmdLines[0]))
addLine(v2styles.LayerValueStyle.Render(cmdLines[1]))
addLine(styles.LayerValueStyle.Render(cmdLines[0]))
addLine(styles.LayerValueStyle.Render(cmdLines[1]))
} else {
// Long command (>2 lines) - show first and last
addLine(v2styles.LayerValueStyle.Render(cmdLines[0]))
addLine(styles.LayerValueStyle.Render(cmdLines[0]))
// Last line with "..." prefix
lastLine := cmdLines[len(cmdLines)-1]
@ -168,7 +169,7 @@ func (m DetailsPane) renderContent() string {
secondLine = runewidth.Truncate(secondLine, maxWidth, "...")
}
addLine(v2styles.LayerValueStyle.Render(secondLine))
addLine(styles.LayerValueStyle.Render(secondLine))
}
}

View file

@ -1,4 +1,4 @@
package app
package filetree
import (
"strings"
@ -6,11 +6,27 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/viewport"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// TreePane manages the file tree
type TreePane struct {
// NodeToggledMsg is sent when a tree node is collapsed/expanded
type NodeToggledMsg struct {
NodeIndex int
}
// TreeSelectionChangedMsg is sent when a tree node is selected
type TreeSelectionChangedMsg struct {
NodeIndex int
}
// RefreshTreeContentMsg requests tree content to be refreshed
type RefreshTreeContentMsg struct {
LayerIndex int
}
// Pane manages the file tree
type Pane struct {
focused bool
width int
height int
@ -19,10 +35,10 @@ type TreePane struct {
treeIndex int
}
// NewTreePane creates a new tree pane
func NewTreePane(treeVM *viewmodel.FileTreeViewModel) TreePane {
// New creates a new tree pane
func New(treeVM *viewmodel.FileTreeViewModel) Pane {
vp := viewport.New(80, 20)
p := TreePane{
p := Pane{
treeVM: treeVM,
viewport: vp,
treeIndex: 0,
@ -35,12 +51,12 @@ func NewTreePane(treeVM *viewmodel.FileTreeViewModel) TreePane {
}
// SetSize updates the pane dimensions
func (m *TreePane) SetSize(width, height int) {
func (m *Pane) SetSize(width, height int) {
m.width = width
m.height = height
viewportWidth := width - 2
viewportHeight := height - BoxContentPadding
viewportHeight := height - layout.BoxContentPadding
if viewportHeight < 0 {
viewportHeight = 0
}
@ -54,7 +70,7 @@ func (m *TreePane) SetSize(width, height int) {
}
// SetTreeVM updates the tree viewmodel
func (m *TreePane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
func (m *Pane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
m.treeVM = treeVM
m.treeIndex = 0
m.viewport.GotoTop()
@ -62,39 +78,39 @@ func (m *TreePane) SetTreeVM(treeVM *viewmodel.FileTreeViewModel) {
}
// SetTreeIndex sets the current tree index
func (m *TreePane) SetTreeIndex(index int) {
func (m *Pane) SetTreeIndex(index int) {
m.treeIndex = index
m.syncScroll()
}
// GetTreeIndex returns the current tree index
func (m *TreePane) GetTreeIndex() int {
func (m *Pane) GetTreeIndex() int {
return m.treeIndex
}
// Focus sets the pane as active
func (m *TreePane) Focus() {
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *TreePane) Blur() {
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *TreePane) IsFocused() bool {
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m TreePane) Init() tea.Cmd {
func (m Pane) Init() tea.Cmd {
m.updateContent()
return nil
}
// Update handles messages
func (m TreePane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@ -139,13 +155,13 @@ func (m TreePane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the pane
func (m TreePane) View() string {
func (m Pane) View() string {
content := m.viewport.View()
return v2styles.RenderBox("Current Layer Contents", m.width, m.height, content, m.focused)
return styles.RenderBox("Current Layer Contents", m.width, m.height, content, m.focused)
}
// moveUp moves selection up
func (m *TreePane) moveUp() tea.Cmd {
func (m *Pane) moveUp() tea.Cmd {
if m.treeIndex > 0 {
m.treeIndex--
m.syncScroll()
@ -154,7 +170,7 @@ func (m *TreePane) moveUp() tea.Cmd {
}
// moveDown moves selection down
func (m *TreePane) moveDown() tea.Cmd {
func (m *Pane) moveDown() tea.Cmd {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return nil
}
@ -168,7 +184,7 @@ func (m *TreePane) moveDown() tea.Cmd {
}
// toggleCollapse toggles the current node's collapse state
func (m *TreePane) toggleCollapse() tea.Cmd {
func (m *Pane) toggleCollapse() tea.Cmd {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return nil
}
@ -200,12 +216,12 @@ func (m *TreePane) toggleCollapse() tea.Cmd {
}
// handleClick processes a mouse click
func (m *TreePane) handleClick(x, y int) tea.Cmd {
func (m *Pane) handleClick(x, y int) tea.Cmd {
if x < 0 || x >= m.width || y < 0 {
return nil
}
relativeY := y - ContentVisualOffset
relativeY := y - layout.ContentVisualOffset
if relativeY < 0 || relativeY >= m.viewport.Height {
return nil
}
@ -233,7 +249,7 @@ func (m *TreePane) handleClick(x, y int) tea.Cmd {
}
// syncScroll ensures the cursor is always visible
func (m *TreePane) syncScroll() {
func (m *Pane) syncScroll() {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return
}
@ -265,7 +281,7 @@ func (m *TreePane) syncScroll() {
}
// updateContent regenerates the viewport content
func (m *TreePane) updateContent() {
func (m *Pane) updateContent() {
if m.treeVM == nil {
m.viewport.SetContent("No tree data")
return
@ -279,7 +295,7 @@ func (m *TreePane) updateContent() {
}
// renderTreeContent generates the tree content
func (m *TreePane) renderTreeContent() string {
func (m *Pane) renderTreeContent() string {
if m.treeVM == nil || m.treeVM.ViewTree == nil {
return "No tree data"
}
@ -296,6 +312,6 @@ func (m *TreePane) renderTreeContent() string {
}
// GetViewport returns the underlying viewport
func (m *TreePane) GetViewport() *viewport.Model {
func (m *Pane) GetViewport() *viewport.Model {
return &m.viewport
}

View file

@ -1,4 +1,4 @@
package app
package filetree
import (
"fmt"
@ -7,7 +7,8 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/dive/filetree"
)
@ -67,31 +68,31 @@ func renderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, depth in
}
// 2. Icon and color
icon := v2styles.IconFile
icon := styles.IconFile
diffIcon := ""
color := v2styles.DiffNormalColor
color := styles.DiffNormalColor
if node.Data.FileInfo.IsDir() {
if node.Data.ViewInfo.Collapsed {
icon = v2styles.IconDirClosed
icon = styles.IconDirClosed
} else {
icon = v2styles.IconDirOpen
icon = styles.IconDirOpen
}
} else if node.Data.FileInfo.TypeFlag == 16 { // Symlink
icon = v2styles.IconSymlink
icon = styles.IconSymlink
}
// 3. Diff status
switch node.Data.DiffType {
case filetree.Added:
color = v2styles.DiffAddedColor
diffIcon = v2styles.IconAdded
color = styles.DiffAddedColor
diffIcon = styles.IconAdded
case filetree.Removed:
color = v2styles.DiffRemovedColor
diffIcon = v2styles.IconRemoved
color = styles.DiffRemovedColor
diffIcon = styles.IconRemoved
case filetree.Modified:
color = v2styles.DiffModifiedColor
diffIcon = v2styles.IconModified
color = styles.DiffModifiedColor
diffIcon = styles.IconModified
}
// 4. Format name
@ -130,7 +131,7 @@ func renderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, depth in
// 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(v2styles.PrimaryColor).
Background(styles.PrimaryColor).
Foreground(lipgloss.Color("#000000")).
Bold(true).
MaxWidth(width) // Prevent exceeding width, but don't add padding
@ -142,15 +143,15 @@ func renderNodeWithCursor(sb *strings.Builder, node *filetree.FileNode, depth in
// Note: no recursion here since we're using collectVisibleNodes instead
}
// renderNode рекурсивно рендерит узел дерева с иконками и цветами
// 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 {
@ -160,61 +161,61 @@ func renderNode(sb *strings.Builder, node *filetree.FileNode, depth int, prefix
return
}
// 1. Определяем иконку
icon := v2styles.IconFile
// 1. Determine icon
icon := styles.IconFile
diffIcon := ""
// Определяем тип файла
// Determine file type
if node.Data.FileInfo.IsDir() {
if node.Data.ViewInfo.Collapsed {
icon = v2styles.IconDirClosed
icon = styles.IconDirClosed
} else {
icon = v2styles.IconDirOpen
icon = styles.IconDirOpen
}
} else if node.Data.FileInfo.TypeFlag == 16 { // tar.TypeSymlink
icon = v2styles.IconSymlink
icon = styles.IconSymlink
}
// Определяем Diff (Добавлен/Удален/Изменен)
color := v2styles.DiffNormalColor
// Determine Diff (Added/Removed/Modified)
color := styles.DiffNormalColor
switch node.Data.DiffType {
case filetree.Added:
color = v2styles.DiffAddedColor
diffIcon = v2styles.IconAdded
color = styles.DiffAddedColor
diffIcon = styles.IconAdded
case filetree.Removed:
color = v2styles.DiffRemovedColor
diffIcon = v2styles.IconRemoved
color = styles.DiffRemovedColor
diffIcon = styles.IconRemoved
case filetree.Modified:
color = v2styles.DiffModifiedColor
diffIcon = v2styles.IconModified
color = styles.DiffModifiedColor
diffIcon = styles.IconModified
}
// 2. Формируем строку
// 2. Build line
name := node.Name
if name == "" {
name = "/"
}
// Добавляем symlink target если есть
// 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. Рекурсия для детей (если папка не свернута)
// 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)
@ -222,13 +223,13 @@ func renderNode(sb *strings.Builder, node *filetree.FileNode, depth int, prefix
}
}
// sortChildren сортирует детей узла: сначала папки, потом файлы, все по алфавиту
// 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
@ -240,17 +241,17 @@ func sortChildren(children map[string]*filetree.FileNode) []*filetree.FileNode {
}
}
// Сортируем папки
// 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
}

View file

@ -1,4 +1,4 @@
package app
package image
import (
"fmt"
@ -9,12 +9,14 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
"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"
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
)
// ImagePane displays image-level statistics and inefficiencies
type ImagePane struct {
// Pane displays image-level statistics and inefficiencies
type Pane struct {
focused bool
width int
height int
@ -22,10 +24,10 @@ type ImagePane struct {
viewport viewport.Model
}
// NewImagePane creates a new image pane
func NewImagePane(analysis *image.Analysis) ImagePane {
// New creates a new image pane
func New(analysis *image.Analysis) Pane {
vp := viewport.New(80, 20)
p := ImagePane{
p := Pane{
analysis: analysis,
viewport: vp,
width: 80,
@ -37,12 +39,12 @@ func NewImagePane(analysis *image.Analysis) ImagePane {
}
// SetSize updates the pane dimensions
func (m *ImagePane) SetSize(width, height int) {
func (m *Pane) SetSize(width, height int) {
m.width = width
m.height = height
viewportWidth := width - 2
viewportHeight := height - BoxContentPadding
viewportHeight := height - layout.BoxContentPadding
if viewportHeight < 0 {
viewportHeight = 0
}
@ -55,34 +57,34 @@ func (m *ImagePane) SetSize(width, height int) {
}
// SetAnalysis updates the analysis data
func (m *ImagePane) SetAnalysis(analysis *image.Analysis) {
func (m *Pane) SetAnalysis(analysis *image.Analysis) {
m.analysis = analysis
m.updateContent()
}
// Focus sets the pane as active
func (m *ImagePane) Focus() {
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *ImagePane) Blur() {
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *ImagePane) IsFocused() bool {
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m ImagePane) Init() tea.Cmd {
func (m Pane) Init() tea.Cmd {
m.updateContent()
return nil
}
// Update handles messages
func (m ImagePane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@ -117,13 +119,13 @@ func (m ImagePane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the pane
func (m ImagePane) View() string {
func (m Pane) View() string {
content := m.viewport.View()
return v2styles.RenderBox("Image Details", m.width, m.height, content, m.focused)
return styles.RenderBox("Image Details", m.width, m.height, content, m.focused)
}
// updateContent regenerates the viewport content
func (m *ImagePane) updateContent() {
func (m *Pane) updateContent() {
if m.analysis == nil {
m.viewport.SetContent("No image data")
return
@ -134,15 +136,15 @@ func (m *ImagePane) updateContent() {
}
// generateContent creates the image statistics content
func (m *ImagePane) generateContent() string {
func (m *Pane) generateContent() string {
width := m.width - 2 // Subtract borders
// Header with statistics
headerText := fmt.Sprintf(
"Image name: %s\nTotal Image size: %s\nPotential wasted space: %s\nImage efficiency score: %.0f%%",
m.analysis.Image,
formatSize(m.analysis.SizeBytes),
formatSize(m.analysis.WastedBytes),
utils.FormatSize(m.analysis.SizeBytes),
utils.FormatSize(m.analysis.WastedBytes),
m.analysis.Efficiency*100,
)
@ -153,16 +155,16 @@ func (m *ImagePane) generateContent() string {
var fullContent strings.Builder
fullContent.WriteString(headerText)
fullContent.WriteString("\n")
fullContent.WriteString(v2styles.LayerHeaderStyle.Render(tableHeader))
fullContent.WriteString(styles.LayerHeaderStyle.Render(tableHeader))
fullContent.WriteString("\n")
if len(m.analysis.Inefficiencies) > 0 {
for _, file := range m.analysis.Inefficiencies {
row := fmt.Sprintf("%-5d %-12s %s", len(file.Nodes), formatSize(uint64(file.CumulativeSize)), file.Path)
row := fmt.Sprintf("%-5d %-12s %s", len(file.Nodes), utils.FormatSize(uint64(file.CumulativeSize)), file.Path)
if lipgloss.Width(row) > width {
row = runewidth.Truncate(row, width, "...")
}
fullContent.WriteString(v2styles.FileTreeModifiedStyle.Render(row))
fullContent.WriteString(styles.FileTreeModifiedStyle.Render(row))
fullContent.WriteString("\n")
}
} else {

View file

@ -1,4 +1,4 @@
package app
package layers
import (
"fmt"
@ -10,28 +10,35 @@ import (
"github.com/mattn/go-runewidth"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/viewmodel"
v2styles "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/app/layout"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/styles"
"github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v2/utils"
)
// LayersPane manages the layers list
type LayersPane struct {
focused bool
width int
height int
layerVM *viewmodel.LayerSetState
viewport viewport.Model
// LayerChangedMsg is sent when the active layer changes
type LayerChangedMsg struct {
LayerIndex int
}
// Pane manages the layers list
type Pane struct {
focused bool
width int
height int
layerVM *viewmodel.LayerSetState
viewport viewport.Model
layerIndex int
}
// NewLayersPane creates a new layers pane
func NewLayersPane(layerVM *viewmodel.LayerSetState) LayersPane {
// New creates a new layers pane
func New(layerVM *viewmodel.LayerSetState) Pane {
vp := viewport.New(80, 20)
p := LayersPane{
layerVM: layerVM,
viewport: vp,
p := Pane{
layerVM: layerVM,
viewport: vp,
layerIndex: 0,
width: 80,
height: 20,
width: 80,
height: 20,
}
// IMPORTANT: Generate content immediately so viewport is not empty on startup
p.updateContent()
@ -39,12 +46,12 @@ func NewLayersPane(layerVM *viewmodel.LayerSetState) LayersPane {
}
// SetSize updates the pane dimensions
func (m *LayersPane) SetSize(width, height int) {
func (m *Pane) SetSize(width, height int) {
m.width = width
m.height = height
viewportWidth := width - 2
viewportHeight := height - BoxContentPadding
viewportHeight := height - layout.BoxContentPadding
if viewportHeight < 0 {
viewportHeight = 0
}
@ -57,7 +64,7 @@ func (m *LayersPane) SetSize(width, height int) {
}
// SetLayerVM updates the layer viewmodel
func (m *LayersPane) SetLayerVM(layerVM *viewmodel.LayerSetState) {
func (m *Pane) SetLayerVM(layerVM *viewmodel.LayerSetState) {
m.layerVM = layerVM
if layerVM != nil {
m.layerIndex = layerVM.LayerIndex
@ -66,7 +73,7 @@ func (m *LayersPane) SetLayerVM(layerVM *viewmodel.LayerSetState) {
}
// SetLayerIndex sets the current layer index
func (m *LayersPane) SetLayerIndex(index int) tea.Cmd {
func (m *Pane) SetLayerIndex(index int) tea.Cmd {
if m.layerVM == nil || index < 0 || index >= len(m.layerVM.Layers) {
return nil
}
@ -81,28 +88,28 @@ func (m *LayersPane) SetLayerIndex(index int) tea.Cmd {
}
// Focus sets the pane as active
func (m *LayersPane) Focus() {
func (m *Pane) Focus() {
m.focused = true
}
// Blur sets the pane as inactive
func (m *LayersPane) Blur() {
func (m *Pane) Blur() {
m.focused = false
}
// IsFocused returns true if the pane is focused
func (m *LayersPane) IsFocused() bool {
func (m *Pane) IsFocused() bool {
return m.focused
}
// Init initializes the pane
func (m LayersPane) Init() tea.Cmd {
func (m Pane) Init() tea.Cmd {
m.updateContent()
return nil
}
// Update handles messages
func (m LayersPane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@ -145,13 +152,13 @@ func (m LayersPane) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the pane
func (m LayersPane) View() string {
func (m Pane) View() string {
content := m.viewport.View()
return v2styles.RenderBox("Layers", m.width, m.height, content, m.focused)
return styles.RenderBox("Layers", m.width, m.height, content, m.focused)
}
// moveUp moves selection up
func (m *LayersPane) moveUp() tea.Cmd {
func (m *Pane) moveUp() tea.Cmd {
if m.layerVM == nil || m.layerIndex <= 0 {
return nil
}
@ -166,7 +173,7 @@ func (m *LayersPane) moveUp() tea.Cmd {
}
// moveDown moves selection down
func (m *LayersPane) moveDown() tea.Cmd {
func (m *Pane) moveDown() tea.Cmd {
if m.layerVM == nil || m.layerIndex >= len(m.layerVM.Layers)-1 {
return nil
}
@ -181,12 +188,12 @@ func (m *LayersPane) moveDown() tea.Cmd {
}
// handleClick processes a mouse click
func (m *LayersPane) handleClick(x, y int) tea.Cmd {
func (m *Pane) handleClick(x, y int) tea.Cmd {
if x < 0 || x >= m.width || y < 0 {
return nil
}
relativeY := y - ContentVisualOffset
relativeY := y - layout.ContentVisualOffset
if relativeY < 0 || relativeY >= m.viewport.Height {
return nil
}
@ -200,7 +207,7 @@ func (m *LayersPane) handleClick(x, y int) tea.Cmd {
}
// updateContent regenerates the viewport content
func (m *LayersPane) updateContent() {
func (m *Pane) updateContent() {
if m.layerVM == nil || len(m.layerVM.Layers) == 0 {
m.viewport.SetContent("No layer data")
return
@ -211,7 +218,7 @@ func (m *LayersPane) updateContent() {
}
// generateContent creates the layers content
func (m *LayersPane) generateContent() string {
func (m *Pane) generateContent() string {
width := m.width - 2
const (
@ -228,7 +235,7 @@ func (m *LayersPane) generateContent() string {
if i == m.layerIndex {
prefix = "● "
style = v2styles.SelectedLayerStyle
style = styles.SelectedLayerStyle
}
id := layer.Id
@ -236,7 +243,7 @@ func (m *LayersPane) generateContent() string {
id = id[:idWidth]
}
size := formatSize(layer.Size)
size := utils.FormatSize(layer.Size)
rawCmd := strings.ReplaceAll(layer.Command, "\n", " ")
rawCmd = strings.TrimSpace(rawCmd)
@ -266,11 +273,11 @@ func (m *LayersPane) generateContent() string {
}
// GetLayerIndex returns the current layer index
func (m *LayersPane) GetLayerIndex() int {
func (m *Pane) GetLayerIndex() int {
return m.layerIndex
}
// GetViewport returns the underlying viewport
func (m *LayersPane) GetViewport() *viewport.Model {
func (m *Pane) GetViewport() *viewport.Model {
return &m.viewport
}

View file

@ -0,0 +1,34 @@
package styles
import "github.com/charmbracelet/lipgloss"
// --- UI Colors ---
var (
// Primary theme colors
PrimaryColor = lipgloss.Color("#007AFF")
SecondaryColor = lipgloss.Color("#5856D6")
// Status colors
SuccessColor = lipgloss.Color("#34C759")
WarningColor = lipgloss.Color("#FF9500")
ErrorColor = lipgloss.Color("#FF3B30")
// Gray scale
GrayColor = lipgloss.Color("#8E8E93")
LightGrayColor = lipgloss.Color("#C7C7CC")
DarkGrayColor = lipgloss.Color("#48484A")
// UI elements
BorderColor = lipgloss.Color("#3A3A3C")
)
// --- File Tree Diff Colors ---
var (
// Diff type colors
DiffAddedColor = lipgloss.Color("#A3BE8C") // Green for added files
DiffRemovedColor = lipgloss.Color("#BF616A") // Red for removed files
DiffModifiedColor = lipgloss.Color("#EBCB8B") // Yellow for modified files
DiffNormalColor = lipgloss.Color("#D8DEE9") // Default color
)

View file

@ -0,0 +1,18 @@
package styles
// --- File Icons ---
var (
IconDirOpen = "📂 "
IconDirClosed = "📁 "
IconFile = "📄 "
IconSymlink = "🔗 "
)
// --- Diff Type Icons ---
var (
IconAdded = "✨ "
IconRemoved = "❌ "
IconModified = "✏️ "
)

View file

@ -5,33 +5,24 @@ import (
"github.com/mattn/go-runewidth"
)
// --- Colors ---
var (
PrimaryColor = lipgloss.Color("#007AFF")
SecondaryColor = lipgloss.Color("#5856D6")
SuccessColor = lipgloss.Color("#34C759")
WarningColor = lipgloss.Color("#FF9500")
ErrorColor = lipgloss.Color("#FF3B30")
GrayColor = lipgloss.Color("#8E8E93")
LightGrayColor = lipgloss.Color("#C7C7CC")
DarkGrayColor = lipgloss.Color("#48484A")
BorderColor = lipgloss.Color("#3A3A3C")
)
// --- Base Styles ---
var (
// TitleStyle for main titles
TitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(PrimaryColor).
Background(lipgloss.Color("#1C1C1E")).
Padding(0, 1)
// StatusStyle for status bar
StatusStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
Background(SecondaryColor).
Padding(0, 1)
// FilterStyle for filter input
FilterStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(DarkGrayColor).
@ -40,10 +31,37 @@ var (
// --- Component Styles ---
// RenderBox создает рамку с заголовком.
// ИСПРАВЛЕНО: Обрезает заголовок чтобы гарантировать одну строку
// SelectedLayerStyle highlights the currently selected layer
var SelectedLayerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(PrimaryColor).
Background(lipgloss.Color("#1C1C1E"))
// LayerHeaderStyle for layer field headers
var LayerHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(SecondaryColor)
// LayerValueStyle for layer field values
var LayerValueStyle = lipgloss.NewStyle().
Foreground(LightGrayColor)
// FileTreeDirStyle for directories in file tree
var FileTreeDirStyle = lipgloss.NewStyle().
Foreground(SuccessColor).
Bold(true)
// FileTreeModifiedStyle for modified files in file tree
var FileTreeModifiedStyle = lipgloss.NewStyle().
Foreground(WarningColor).
Bold(true)
// --- Rendering Functions ---
// RenderBox creates a bordered box with title and content
// IMPORTANT: Truncates title to guarantee single line height
func RenderBox(title string, width, height int, content string, isSelected bool) string {
// 1. Защита минимальных размеров
// 1. Protect minimum sizes
if width < 2 {
width = 2
}
@ -69,8 +87,8 @@ func RenderBox(title string, width, height int, content string, isSelected bool)
return boxStyle.Render(content)
}
// 2. ИСПРАВЛЕНИЕ: Обрезаем заголовок, чтобы он не переносился на 2 строки
// Ширина заголовка: Ширина окна - 2 (рамки) - 2 (запас)
// 2. Truncate title to prevent wrapping to 2 lines
// Title width: Window width - 2 (borders) - 2 (margin)
maxTitleWidth := width - 4
if maxTitleWidth < 0 {
maxTitleWidth = 0
@ -78,48 +96,23 @@ func RenderBox(title string, width, height int, content string, isSelected bool)
truncatedTitle := runewidth.Truncate(title, maxTitleWidth, "…")
// 3. Рендерим заголовок
// 3. Render title
titleStyle := lipgloss.NewStyle().
Foreground(borderColor).
Bold(true)
titleLine := titleStyle.Render(truncatedTitle)
// 4. Собираем контент: Заголовок + Пробел + Данные
// Используем " " (пробел), чтобы гарантировать высоту отступа в 1 строку
// 4. Assemble content: Title + Space + Data
// Using " " (space) to guarantee 1 line height for padding
innerContent := lipgloss.JoinVertical(lipgloss.Left, titleLine, " ", content)
return boxStyle.Render(innerContent)
}
// --- Specific Styles for content ---
// --- Utility Functions ---
var (
SelectedLayerStyle = lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor).Background(lipgloss.Color("#1C1C1E"))
LayerHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(SecondaryColor)
LayerValueStyle = lipgloss.NewStyle().Foreground(LightGrayColor)
FileTreeDirStyle = lipgloss.NewStyle().Foreground(SuccessColor).Bold(true)
FileTreeModifiedStyle = lipgloss.NewStyle().Foreground(WarningColor).Bold(true)
)
// --- Icons ---
var (
IconDirOpen = "📂 "
IconDirClosed = "📁 "
IconFile = "📄 "
IconSymlink = "🔗 "
IconAdded = "✨ "
IconRemoved = "❌ "
IconModified = "✏️ "
DiffAddedColor = lipgloss.Color("#A3BE8C")
DiffRemovedColor = lipgloss.Color("#BF616A")
DiffModifiedColor = lipgloss.Color("#EBCB8B")
DiffNormalColor = lipgloss.Color("#D8DEE9")
)
// TruncateString обрезает строку по визуальной ширине
// TruncateString truncates a string by visual width
func TruncateString(s string, maxLen int) string {
return runewidth.Truncate(s, maxLen, "...")
}

View file

@ -1,12 +1,10 @@
package app
package utils
import (
"fmt"
)
import "fmt"
// formatSize formats bytes into human-readable size
// FormatSize formats bytes into human-readable size
// This is a shared utility used by all panes
func formatSize(bytes uint64) string {
func FormatSize(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)

BIN
dive-test

Binary file not shown.