package ui import ( "fmt" "path/filepath" "strings" "github.com/charmbracelet/lipgloss" "vcom/internal/config" vfs "vcom/internal/fs" "vcom/internal/fs/remote" "vcom/internal/theme" ) type PaneID string const ( PaneLeft PaneID = "left" PaneRight PaneID = "right" ) type BrowserPane struct { ID PaneID Path string Entries []vfs.Entry Cursor int Offset int Marked map[string]struct{} Archive []ArchiveMount Remote []RemoteMount dirHistory []string dirFuture []string // cursorMemory remembers the last selected entry display name per directory // within a session. Keyed by directory path. Restored when re-entering a dir. cursorMemory map[string]string } type ArchiveMount struct { SourcePath string ParentPath string RootPath string TempDir string } // RemoteMount represents an active SSH/SFTP remote filesystem connection. type RemoteMount struct { Host remote.SSHHost RemotePath string Client *remote.SSHClient Connected bool } func (p *BrowserPane) Selected() (vfs.Entry, bool) { if len(p.Entries) == 0 || p.Cursor < 0 || p.Cursor >= len(p.Entries) { return vfs.Entry{}, false } return p.Entries[p.Cursor], true } func (p *BrowserPane) SetEntries(entries []vfs.Entry, preserveKey string) { p.Entries = entries p.PruneMarks() if len(entries) == 0 { p.Cursor = 0 p.Offset = 0 return } if preserveKey != "" { p.Cursor = vfs.FindSelected(entries, preserveKey) } if p.Cursor >= len(entries) { p.Cursor = len(entries) - 1 } if p.Cursor < 0 { p.Cursor = 0 } if p.Offset > p.Cursor { p.Offset = p.Cursor } } func (p *BrowserPane) Move(delta int, pageSize int) { if len(p.Entries) == 0 { p.Cursor = 0 return } p.Cursor += delta if p.Cursor < 0 { p.Cursor = 0 } if p.Cursor >= len(p.Entries) { p.Cursor = len(p.Entries) - 1 } if p.Cursor < p.Offset { p.Offset = p.Cursor } if pageSize > 0 && p.Cursor >= p.Offset+pageSize { p.Offset = p.Cursor - pageSize + 1 } if p.Offset < 0 { p.Offset = 0 } } func (p *BrowserPane) EnsureMarked(path string) { if strings.TrimSpace(path) == "" { return } if p.Marked == nil { p.Marked = map[string]struct{}{} } p.Marked[path] = struct{}{} } func (p *BrowserPane) ToggleMarked(path string) { if strings.TrimSpace(path) == "" { return } if p.Marked == nil { p.Marked = map[string]struct{}{} } if _, ok := p.Marked[path]; ok { delete(p.Marked, path) if len(p.Marked) == 0 { p.Marked = nil } return } p.Marked[path] = struct{}{} } func (p *BrowserPane) IsMarked(path string) bool { if p.Marked == nil { return false } _, ok := p.Marked[path] return ok } func (p *BrowserPane) ClearMarks() { p.Marked = nil } func (p *BrowserPane) PruneMarks() { if len(p.Marked) == 0 { return } valid := map[string]struct{}{} for _, entry := range p.Entries { if entry.IsParent { continue } valid[entry.Path] = struct{}{} } for path := range p.Marked { if _, ok := valid[path]; !ok { delete(p.Marked, path) } } if len(p.Marked) == 0 { p.Marked = nil } } func (p *BrowserPane) MarkedEntries() []vfs.Entry { if len(p.Marked) == 0 { return nil } result := make([]vfs.Entry, 0, len(p.Marked)) for _, entry := range p.Entries { if entry.IsParent { continue } if p.IsMarked(entry.Path) { result = append(result, entry) } } return result } func (p *BrowserPane) EnsureVisible(pageSize int) { if pageSize <= 0 { return } if p.Cursor < p.Offset { p.Offset = p.Cursor } if p.Cursor >= p.Offset+pageSize { p.Offset = p.Cursor - pageSize + 1 } if p.Offset < 0 { p.Offset = 0 } } func (p *BrowserPane) SaveCursor(dirPath string, entryName string) { if dirPath == "" || entryName == "" { return } if p.cursorMemory == nil { p.cursorMemory = map[string]string{} } p.cursorMemory[dirPath] = entryName } // LoadCursor returns the saved entry name for a directory, or empty string. func (p *BrowserPane) LoadCursor(dirPath string) string { if p.cursorMemory == nil { return "" } return p.cursorMemory[dirPath] } func (p *BrowserPane) InArchive() bool { return len(p.Archive) > 0 } // PushHistory saves the current path to the back-stack and clears the forward-stack. func (p *BrowserPane) PushHistory(path string) { p.dirHistory = append(p.dirHistory, path) p.dirFuture = nil } // PopHistory returns the most recent path from the back-stack. func (p *BrowserPane) PopHistory() (string, bool) { if len(p.dirHistory) == 0 { return "", false } path := p.dirHistory[len(p.dirHistory)-1] p.dirHistory = p.dirHistory[:len(p.dirHistory)-1] return path, true } // PushFuture saves the current path to the forward-stack. func (p *BrowserPane) PushFuture(path string) { p.dirFuture = append(p.dirFuture, path) } // PopFuture returns the most recent path from the forward-stack. func (p *BrowserPane) PopFuture() (string, bool) { if len(p.dirFuture) == 0 { return "", false } path := p.dirFuture[len(p.dirFuture)-1] p.dirFuture = p.dirFuture[:len(p.dirFuture)-1] return path, true } // HasHistory returns true if there are entries in the back-stack. func (p *BrowserPane) HasHistory() bool { return len(p.dirHistory) > 0 } // HasFuture returns true if there are entries in the forward-stack. func (p *BrowserPane) HasFuture() bool { return len(p.dirFuture) > 0 } // HistoryDepth returns the number of entries in the back-stack. func (p *BrowserPane) HistoryDepth() int { return len(p.dirHistory) } // FutureDepth returns the number of entries in the forward-stack. func (p *BrowserPane) FutureDepth() int { return len(p.dirFuture) } func (p *BrowserPane) PushArchive(mount ArchiveMount) { p.Archive = append(p.Archive, mount) } func (p *BrowserPane) PopArchive() (ArchiveMount, bool) { if len(p.Archive) == 0 { return ArchiveMount{}, false } last := p.Archive[len(p.Archive)-1] p.Archive = p.Archive[:len(p.Archive)-1] return last, true } func (p *BrowserPane) CurrentArchive() (ArchiveMount, bool) { if len(p.Archive) == 0 { return ArchiveMount{}, false } return p.Archive[len(p.Archive)-1], true } func (p *BrowserPane) ClearArchives() []ArchiveMount { if len(p.Archive) == 0 { return nil } out := make([]ArchiveMount, len(p.Archive)) copy(out, p.Archive) p.Archive = nil return out } func (p *BrowserPane) PushRemote(mount RemoteMount) { p.Remote = append(p.Remote, mount) } func (p *BrowserPane) PopRemote() (RemoteMount, bool) { if len(p.Remote) == 0 { return RemoteMount{}, false } last := p.Remote[len(p.Remote)-1] p.Remote = p.Remote[:len(p.Remote)-1] return last, true } func (p *BrowserPane) CurrentRemote() (RemoteMount, bool) { if len(p.Remote) == 0 { return RemoteMount{}, false } return p.Remote[len(p.Remote)-1], true } func (p *BrowserPane) InRemote() bool { return len(p.Remote) > 0 } func (p *BrowserPane) ClearRemotes() []RemoteMount { if len(p.Remote) == 0 { return nil } out := make([]RemoteMount, len(p.Remote)) copy(out, p.Remote) p.Remote = nil return out } func (p *BrowserPane) DisplayPath() string { if len(p.Remote) > 0 { top := p.Remote[len(p.Remote)-1] statusIcon := "󰖩" if !top.Connected { statusIcon = "󰤭" } if top.RemotePath == "/" || top.RemotePath == "" { return fmt.Sprintf("%s %s:", statusIcon, top.Host.Name) } return fmt.Sprintf("%s %s:%s", statusIcon, top.Host.Name, top.RemotePath) } if len(p.Archive) == 0 { return p.Path } top := p.Archive[len(p.Archive)-1] rel, err := filepath.Rel(top.RootPath, p.Path) if err != nil { return p.Path } rel = filepath.ToSlash(rel) if rel == "." { rel = "" } if rel == "" { return top.SourcePath + "::" } return top.SourcePath + "::/" + rel } func renderPane( pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, useNerdIcons bool, ) string { if width <= 0 || height <= 0 { return "" } borderColor := palette.Border headerBg := palette.PanelInactive bodyBg := palette.Panel if active { borderColor = palette.BorderActive headerBg = palette.Selection } innerWidth := max(width-2, 1) innerHeight := max(height-2, 1) box := lipgloss.NewStyle(). Width(innerWidth). Height(innerHeight). Background(bodyBg). Foreground(palette.Text). BorderStyle(borderStyle(cfg.UI.Border)). BorderForeground(borderColor). BorderBackground(bodyBg) header := lipgloss.NewStyle(). Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg)) isSSHHostList := pane.Path == "ssh://" rowsHeight := max(innerHeight-2, 1) headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons, isSSHHostList) rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons, isSSHHostList) content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows) return box.Render(content) } func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, active bool, headerBg lipgloss.Color) string { pathWidth := max(width, 4) pathStyle := lipgloss.NewStyle(). Width(pathWidth). Background(headerBg). Foreground(palette.Text). Bold(active) if active { pathStyle = pathStyle.Foreground(palette.ActivePath) } return lipgloss.NewStyle(). Width(width). Background(headerBg). Render(pathStyle.Render(truncateMiddle(compactPath(pane.DisplayPath(), cfg.UI.PathDisplay), pathWidth))) } func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string { columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols) parts := make([]string, 0, len(columns)) for idx, column := range columns { style := lipgloss.NewStyle(). Width(column.Width). Foreground(palette.Muted). Background(background). Bold(true) if column.AlignRight { style = style.Align(lipgloss.Right) } parts = append(parts, style.Render(truncateRight(column.Title, column.Width))) if idx < len(columns)-1 { parts = append(parts, columnSeparator(column.Key, palette, background)) } } return lipgloss.NewStyle(). Width(width). Background(background). Render(strings.Join(parts, "")) } func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string { if len(pane.Entries) == 0 { return lipgloss.NewStyle(). Width(width). Height(height). Padding(1, 1). Background(background). Foreground(palette.Muted). Render("Empty directory") } visibleHeight := max(height, 1) pane.EnsureVisible(visibleHeight) end := min(len(pane.Entries), pane.Offset+visibleHeight) lines := make([]string, 0, visibleHeight) for idx := pane.Offset; idx < end; idx++ { entry := pane.Entries[idx] isSelected := idx == pane.Cursor && active marked := !entry.IsParent && pane.IsMarked(entry.Path) row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background, useNerdIcons, hideExtraCols) lines = append(lines, row) } for len(lines) < visibleHeight { lines = append(lines, lipgloss.NewStyle().Width(width).Background(background).Render("")) } return lipgloss.NewStyle(). Width(width). Background(background). Render(strings.Join(lines, "\n")) } func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string { columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols) rowBackground := baseBackground switch { case marked: rowBackground = palette.Marked case selected: rowBackground = palette.Selection case hovered: rowBackground = palette.Hover } parts := make([]string, 0, len(columns)) for idx, column := range columns { value := column.Value(entry, cfg.Browser.HumanReadableSize) foreground := entryColor(entry, palette) if marked { foreground = palette.Background } style := lipgloss.NewStyle(). Width(column.Width). Foreground(foreground). Background(rowBackground) if entry.IsHidden && !marked { style = style.Foreground(palette.Muted) } if column.AlignRight { style = style.Align(lipgloss.Right) } parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight))) if idx < len(columns)-1 { parts = append(parts, columnSeparator(column.Key, palette, rowBackground)) } } rowStyle := lipgloss.NewStyle().Width(width).Background(rowBackground) if selected && active { rowStyle = rowStyle.Bold(true) } return rowStyle.Render(strings.Join(parts, "")) } type columnSpec struct { Key string Title string Width int MinWidth int AlignRight bool Value func(entry vfs.Entry, human bool) string } func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool, hideExtraCols bool) []columnSpec { fixed := []columnSpec{} if cfg.Browser.Columns.Permissions { fixed = append(fixed, columnSpec{ Key: "permissions", Title: "Perms", Width: 10, MinWidth: 9, Value: func(entry vfs.Entry, _ bool) string { return vfs.Permissions(entry.Mode) }, }) } if cfg.Browser.Columns.Extension { fixed = append(fixed, columnSpec{ Key: "extension", Title: "Ext", Width: 6, MinWidth: 4, Value: func(entry vfs.Entry, _ bool) string { if entry.IsDir { return "" } return entry.Extension }, }) } if cfg.Browser.Columns.Size && !hideExtraCols { fixed = append(fixed, columnSpec{ Key: "size", Title: "Size", Width: 9, MinWidth: 6, AlignRight: true, Value: func(entry vfs.Entry, human bool) string { if entry.IsDir { if !entry.DirSizeKnown { return "" } if human { return vfs.HumanSize(entry.Size) } return fmt.Sprintf("%d", entry.Size) } if human { return vfs.HumanSize(entry.Size) } return fmt.Sprintf("%d", entry.Size) }, }) } if cfg.Browser.Columns.Created { fixed = append(fixed, columnSpec{ Key: "created", Title: "Created", Width: 11, MinWidth: 8, Value: func(entry vfs.Entry, _ bool) string { if !entry.CreatedKnown { return "n/a" } return vfs.CompactTime(entry.CreatedAt) }, }) } if cfg.Browser.Columns.Modified && !hideExtraCols { fixed = append(fixed, columnSpec{ Key: "modified", Title: "Modified", Width: 11, MinWidth: 8, Value: func(entry vfs.Entry, _ bool) string { if entry.IsParent { return "" } return vfs.CompactTime(entry.ModifiedAt) }, }) } minNameWidth := 4 gaps := 0 for _, column := range fixed { gaps += separatorWidth(column.Key) } availableForColumns := totalWidth - gaps if availableForColumns < minNameWidth { availableForColumns = minNameWidth } fixedWidth := 0 for _, column := range fixed { fixedWidth += column.Width } for fixedWidth+minNameWidth > availableForColumns { changed := false for idx := len(fixed) - 1; idx >= 0 && fixedWidth+minNameWidth > availableForColumns; idx-- { if fixed[idx].Width > fixed[idx].MinWidth { fixed[idx].Width-- fixedWidth-- changed = true } } if !changed { break } } nameWidth := max(availableForColumns-fixedWidth, minNameWidth) name := columnSpec{ Key: "name", Title: "Name", Width: nameWidth, MinWidth: minNameWidth, Value: func(entry vfs.Entry, _ bool) string { return entryIcon(entry, useNerdIcons) + " " + entry.DisplayName() }, } return append([]columnSpec{name}, fixed...) } func columnSeparator(columnKey string, palette theme.Palette, background lipgloss.Color) string { width := separatorWidth(columnKey) style := lipgloss.NewStyle(). Width(width). Foreground(palette.Border) if background != lipgloss.Color("") { style = style.Background(background) } return style.Render(strings.Repeat(" ", width)) } func separatorWidth(columnKey string) int { if columnKey == "size" { return 2 } return 1 } func borderStyle(value string) lipgloss.Border { switch strings.ToLower(value) { case "double": return lipgloss.DoubleBorder() case "thick": return lipgloss.ThickBorder() default: return lipgloss.RoundedBorder() } } func compactPath(path string, mode string) string { switch strings.ToLower(mode) { case "full": return path case "smart": return smartPath(path, 42) default: return vfs.SafeBase(path) } } func smartPath(path string, maxWidth int) string { if lipgloss.Width(path) <= maxWidth { return path } return truncateMiddle(path, maxWidth) } func truncateMiddle(value string, maxWidth int) string { if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth { return value } if maxWidth <= 3 { return trimToWidthRight(value, maxWidth) } left := maxWidth/2 - 1 right := maxWidth - left - 1 if left < 1 { left = 1 } if right < 1 { right = 1 } return trimToWidthRight(value, left) + "…" + trimToWidthLeft(value, right) } func truncateRight(value string, maxWidth int) string { if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth { return value } if maxWidth == 1 { return trimToWidthRight(value, 1) } return trimToWidthRight(value, maxWidth-1) + "…" } func truncateForColumn(value string, maxWidth int, alignRight bool) string { if lipgloss.Width(value) <= maxWidth { return value } if alignRight { if maxWidth <= 1 { return trimToWidthLeft(value, 1) } return "…" + trimToWidthLeft(value, maxWidth-1) } return truncateRight(value, maxWidth) } func trimToWidthRight(value string, maxWidth int) string { if maxWidth <= 0 { return "" } width := 0 var builder strings.Builder for _, r := range value { rw := lipgloss.Width(string(r)) if width+rw > maxWidth { break } builder.WriteRune(r) width += rw } return builder.String() } func trimToWidthLeft(value string, maxWidth int) string { if maxWidth <= 0 { return "" } runes := []rune(value) width := 0 start := len(runes) for i := len(runes) - 1; i >= 0; i-- { rw := lipgloss.Width(string(runes[i])) if width+rw > maxWidth { break } width += rw start = i } return string(runes[start:]) } func entryIcon(entry vfs.Entry, useNerdIcons bool) string { if !useNerdIcons { switch entry.Category() { case "parent": return "<-" case "remote": return "[SV]" case "directory": return "[D]" case "config": return "[C]" case "text": return "[T]" case "image": return "[I]" case "executable": return "[X]" case "archive": return "[A]" default: return "[F]" } } switch entry.Category() { case "parent": return "↩" case "remote": return "󰒋" case "directory": return "" case "config": return "" case "text": return "󰈙" case "image": return "󰋩" case "executable": return "󰆍" case "archive": return "" default: return "󰈔" } } func entryColor(entry vfs.Entry, palette theme.Palette) lipgloss.Color { switch entry.Category() { case "remote": return palette.ExecFile case "directory", "parent": return palette.Folder case "config": return palette.ConfigFile case "text": return palette.TextFile case "image": return palette.ImageFile case "executable": return palette.ExecFile default: return palette.BinaryFile } }