package ui import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "time" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "vcom/internal/config" vfs "vcom/internal/fs" "vcom/internal/theme" ) const version = "v0.2.1" type modalKind int const ( modalNone modalKind = iota modalMkdir modalRename modalConfirm modalCopyProgress modalNotice modalHelp modalArchiveType modalArchiveProgress ) type fileOpKind int const ( opCopy fileOpKind = iota opMove opDelete opPermanentDelete opMkdir opRename opEdit opView opArchive opExecute ) type pendingOperation struct { kind fileOpKind sourcePaths []string targetDir string overwrite bool existingTargets int stats vfs.TransferStats } type modalState struct { kind modalKind title string body string note string input textinput.Model pending *pendingOperation } type previewMsg struct { entryPath string preview vfs.Preview } type dirSizeMsg struct { path string size int64 err error } type opMsg struct { kind fileOpKind sourcePath string targetPath string err error } type copyPlanMsg struct { kind fileOpKind sourcePaths []string targetDir string overwrite bool existingTargets int stats vfs.TransferStats err error } type copyProgressMsg struct { jobID int progress vfs.CopyProgress } type deletePlanMsg struct { kind fileOpKind sourcePaths []string stats vfs.TransferStats err error } type archivePlanMsg struct { sourcePaths []string targetDir string stats vfs.TransferStats err error } type archiveProgressMsg struct { jobID int progress vfs.CopyProgress } type archiveDoneMsg struct { jobID int sourcePaths []string targetPath string err error } type copyDoneMsg struct { jobID int kind fileOpKind sourcePaths []string targetDir string err error } type dismissNoticeMsg struct{} type dismissYankFlashMsg struct{} type externalOpenMsg struct { path string err error } type copyJobState struct { id int kind fileOpKind sourcePaths []string targetDir string progress vfs.CopyProgress overwrite bool background bool cancel context.CancelFunc startedAt time.Time } type archiveJobState struct { id int sourcePaths []string targetPath string progress vfs.CopyProgress background bool cancel context.CancelFunc startedAt time.Time } type mouseClickState struct { pane PaneID index int at time.Time } type hoverState struct { pane PaneID index int ok bool } type Model struct { cfg config.Config configPath string palette theme.Palette keys KeyMap nerdIcons bool overlay *imageOverlayManager width int height int left BrowserPane right BrowserPane active PaneID infoMode bool selectMode bool cursorMode bool cursorLine int cursorCol int visualMode bool visualAnchor int visualAnchorCol int viewMode bool viewPrevInfo bool previewModel viewport.Model previewData vfs.Preview filterMode bool filterQuery string filterInput textinput.Model filterPaneID PaneID modal modalState status string busy bool lastClick mouseClickState hover hoverState pendingY bool yankFlashLine int copyJob *copyJobState nextCopyJob int copyProgress chan tea.Msg copyPath string archiveJob *archiveJobState nextArchiveJob int archiveProgress chan tea.Msg archiveFormat string } func NewModel(cfg config.Config, configPath string) (Model, error) { palette, err := theme.Resolve(cfg.UI.Theme) if err != nil { return Model{}, err } cwd, err := os.Getwd() if err != nil { return Model{}, err } leftPath, err := resolveStartPath(cfg.Startup.LeftPath, cwd) if err != nil { return Model{}, err } rightPath, err := resolveStartPath(cfg.Startup.RightPath, cwd) if err != nil { return Model{}, err } model := Model{ cfg: cfg, configPath: configPath, palette: palette, keys: DefaultKeyMap(), overlay: newImageOverlayManager(), left: BrowserPane{ID: PaneLeft, Path: leftPath}, right: BrowserPane{ID: PaneRight, Path: rightPath}, active: PaneLeft, status: "Ready", copyProgress: make(chan tea.Msg, 256), archiveProgress: make(chan tea.Msg, 256), } model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode) if model.status == "" { model.status = "Ready" } filterInput := textinput.New() filterInput.Placeholder = "filter by name…" filterInput.CharLimit = 64 filterInput.Width = 40 model.filterInput = filterInput model.previewModel = viewport.New(0, 0) if err := model.reloadPane(PaneLeft, ""); err != nil { return Model{}, err } if err := model.reloadPane(PaneRight, ""); err != nil { return Model{}, err } return model, nil } func (m Model) Init() tea.Cmd { return m.loadPreviewCmd() } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.resizePreview() m.syncPreviewContent() return m, nil case previewMsg: if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath { m.applyPreview(msg.preview) } if m.selectMode && !m.viewMode && msg.preview.Kind != vfs.PreviewKindText { m.selectMode = false return m, enableMouseCmd() } if (m.cursorMode || m.visualMode) && msg.preview.Kind != vfs.PreviewKindText { m.cursorMode = false m.visualMode = false m.status = "Text cursor mode: off" } return m, nil case dirSizeMsg: m.busy = false if msg.err != nil { m.status = fmt.Sprintf("Dir size failed: %v", msg.err) return m, nil } m.applyDirSize(msg.path, msg.size) m.status = fmt.Sprintf("Directory size calculated: %s", vfs.HumanSize(msg.size)) return m, m.loadPreviewCmd() case opMsg: m.busy = false if msg.err != nil { m.status = msg.err.Error() return m, nil } m.modal = modalState{} switch msg.kind { case opCopy: m.status = fmt.Sprintf("Copied to %s", msg.targetPath) case opMove: m.status = fmt.Sprintf("Moved to %s", msg.targetPath) case opDelete: m.status = "Moved to trash" m.activePane().ClearMarks() case opPermanentDelete: m.status = "Permanently deleted" m.activePane().ClearMarks() case opMkdir: m.status = fmt.Sprintf("Created %s", msg.targetPath) case opRename: m.status = fmt.Sprintf("Renamed to %s", filepath.Base(msg.targetPath)) case opEdit: m.status = "Editor closed" return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) case opView: m.status = "Viewer closed" return m, enableMouseCmd() case opExecute: m.status = "Executable closed" return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) } leftSelection := selectedName(&m.left) rightSelection := selectedName(&m.right) if msg.kind == opRename && msg.targetPath != "" { renamed := filepath.Base(msg.targetPath) if m.active == PaneLeft { leftSelection = renamed } else { rightSelection = renamed } } _ = m.reloadPane(PaneLeft, leftSelection) _ = m.reloadPane(PaneRight, rightSelection) return m, m.loadPreviewCmd() case copyPlanMsg: m.busy = false if msg.err != nil { m.status = msg.err.Error() return m, nil } verb := operationVerb(msg.kind) title := fmt.Sprintf("%s selected entry?", strings.Title(verb)) body := strings.Join([]string{ fmt.Sprintf("Items: %d", len(msg.sourcePaths)), fmt.Sprintf("Files: %d", msg.stats.FilesTotal), fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)), }, "\n") note := "confirm-actions" m.openConfirmModal(title, body, note, pendingOperation{ kind: msg.kind, sourcePaths: append([]string(nil), msg.sourcePaths...), targetDir: msg.targetDir, overwrite: msg.overwrite, existingTargets: msg.existingTargets, stats: msg.stats, }) return m, nil case deletePlanMsg: m.busy = false if msg.err != nil { m.status = msg.err.Error() return m, nil } title := "Move selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + " to trash?" if msg.kind == opPermanentDelete { title = "Permanently delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + "?" } bodyLines := []string{ fmt.Sprintf("Items: %d", len(msg.sourcePaths)), fmt.Sprintf("Files: %d", msg.stats.FilesTotal), fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)), } m.openConfirmModal( title, strings.Join(bodyLines, "\n"), "confirm-actions", pendingOperation{ kind: msg.kind, sourcePaths: append([]string(nil), msg.sourcePaths...), stats: msg.stats, }, ) return m, nil case archivePlanMsg: m.busy = false if msg.err != nil { m.status = msg.err.Error() return m, nil } m.archiveFormat = "zip" bodyLines := []string{ fmt.Sprintf("Items: %d", len(msg.sourcePaths)), fmt.Sprintf("Files: %d", msg.stats.FilesTotal), fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)), } m.modal = modalState{ kind: modalArchiveType, title: "Archive selected files?", body: strings.Join(bodyLines, "\n"), note: fmt.Sprintf( "Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel", m.archiveFormat, ), pending: &pendingOperation{ kind: opArchive, sourcePaths: append([]string(nil), msg.sourcePaths...), targetDir: msg.targetDir, stats: msg.stats, }, } return m, nil case archiveProgressMsg: if m.archiveJob == nil || msg.jobID != m.archiveJob.id { return m, nil } m.archiveJob.progress = msg.progress if m.archiveJob.background { m.status = formatArchiveStatus(msg.progress) } return m, waitArchiveProgressCmd(m.archiveProgress) case archiveDoneMsg: if m.archiveJob == nil || msg.jobID != m.archiveJob.id { return m, nil } m.busy = false if msg.err != nil { activeSelection := selectedName(m.activePane()) _ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneRight, activeSelection) if msg.err == context.Canceled { m.status = "Archiving cancelled" } else { m.status = fmt.Sprintf("Archiving failed: %v", msg.err) } m.archiveJob = nil if m.modal.kind == modalArchiveProgress { m.modal = modalState{} } return m, m.loadPreviewCmd() } m.status = fmt.Sprintf("Archived %d entr%s to %s", len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetPath) activeSelection := selectedName(m.activePane()) _ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneRight, activeSelection) background := m.archiveJob.background sourceCount := len(m.archiveJob.sourcePaths) m.archiveJob = nil m.activePane().ClearMarks() cmd := m.loadPreviewCmd() if m.modal.kind == modalArchiveProgress { m.modal = modalState{} } if background { doneBody := fmt.Sprintf("%d entr%s archived successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies")) if sourceCount == 1 && len(msg.sourcePaths) == 1 { doneBody = filepath.Base(msg.sourcePaths[0]) + " archived successfully." } m.modal = modalState{ kind: modalNotice, title: "Archive complete", body: doneBody, note: "Press Esc to close", } } return m, cmd case copyProgressMsg: if m.copyJob == nil || msg.jobID != m.copyJob.id { return m, nil } m.copyJob.progress = msg.progress if m.copyJob.background { m.status = formatCopyStatus(m.copyJob.kind, msg.progress) } return m, waitCopyProgressCmd(m.copyProgress) case copyDoneMsg: if m.copyJob == nil || msg.jobID != m.copyJob.id { return m, nil } m.busy = false if msg.err != nil { activeSelection := selectedName(m.activePane()) _ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneRight, activeSelection) if msg.err == context.Canceled { m.status = strings.Title(operationVerb(msg.kind)) + " cancelled" } else { m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err) } m.copyJob = nil if m.modal.kind == modalCopyProgress { m.modal = modalState{} } return m, m.loadPreviewCmd() } m.status = fmt.Sprintf("%s %d entr%s to %s", operationDoneLabel(msg.kind), len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetDir) activeSelection := selectedName(m.activePane()) _ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneRight, activeSelection) background := m.copyJob.background kind := m.copyJob.kind sourceCount := len(m.copyJob.sourcePaths) m.copyJob = nil m.activePane().ClearMarks() cmd := m.loadPreviewCmd() if m.modal.kind == modalCopyProgress { m.modal = modalState{} } if background { doneWord := "copied" if kind == opMove { doneWord = "moved" } doneBody := fmt.Sprintf("%d entr%s %s successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies"), doneWord) if sourceCount == 1 && len(msg.sourcePaths) == 1 { doneBody = filepath.Base(msg.sourcePaths[0]) + " " + doneWord + " successfully." } m.modal = modalState{ kind: modalNotice, title: strings.Title(operationVerb(kind)) + " complete", body: doneBody, } cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second)) } return m, cmd case dismissNoticeMsg: if m.modal.kind == modalNotice { m.modal = modalState{} } return m, nil case dismissYankFlashMsg: m.yankFlashLine = -1 m.syncPreviewContent() return m, nil case externalOpenMsg: if msg.err != nil { m.status = fmt.Sprintf("Open failed: %v", msg.err) return m, nil } m.status = fmt.Sprintf("Opened %s", filepath.Base(msg.path)) return m, nil case tea.KeyMsg: msg = translateKeyMsg(msg) if msg.String() != "y" { m.pendingY = false } if m.modal.kind != modalNone { return m.handleModalKey(msg) } if m.viewMode { if m.previewData.Kind == vfs.PreviewKindText && m.infoMode && !m.selectMode { switch { case key.Matches(msg, m.keys.Visual): return m.toggleVisualMode() case msg.String() == "y": return m.yankVisualSelection() } } switch { case key.Matches(msg, m.keys.Cancel): if m.visualMode { return m.exitVisualMode("Visual mode: off") } return m.exitViewMode() case key.Matches(msg, m.keys.View), msg.String() == "q": return m.exitViewMode() case key.Matches(msg, m.keys.Up): if m.visualMode { m.moveTextCursorLine(-1) } else { m.previewModel.LineUp(1) } return m, nil case msg.String() == "left": if m.visualMode { m.moveTextCursorCol(-1) } return m, nil case msg.String() == "h": if m.visualMode { m.moveTextCursorCol(-1) } return m, nil case key.Matches(msg, m.keys.Down): if m.visualMode { m.moveTextCursorLine(1) } else { m.previewModel.LineDown(1) } return m, nil case msg.String() == "right": if m.visualMode { m.moveTextCursorCol(1) } return m, nil case msg.String() == "l": if m.visualMode { m.moveTextCursorCol(1) } return m, nil case key.Matches(msg, m.keys.PageUp): if m.visualMode { m.moveTextCursorLine(-max(m.previewModel.Height-2, 1)) } else { m.previewModel.LineUp(max(m.previewModel.Height-2, 1)) } return m, nil case key.Matches(msg, m.keys.PageDown): if m.visualMode { m.moveTextCursorLine(max(m.previewModel.Height-2, 1)) } else { m.previewModel.LineDown(max(m.previewModel.Height-2, 1)) } return m, nil default: return m, nil } } if (m.cursorMode || m.visualMode) && m.previewData.Kind == vfs.PreviewKindText { switch { case key.Matches(msg, m.keys.Caret): return m.toggleCaretMode() case key.Matches(msg, m.keys.Visual): if m.visualMode { return m.exitVisualMode("Visual mode: off") } if m.cursorMode { return m.toggleVisualMode() } return m, nil case key.Matches(msg, m.keys.Cancel), msg.String() == "q": if m.visualMode { return m.exitVisualMode("Visual mode: off") } return m.exitCaretMode("Caret mode: off") case msg.String() == "y": if !m.visualMode { if m.pendingY { m.pendingY = false return m.yankCursorLine() } m.pendingY = true m.status = "Press y again to copy current line" return m, nil } return m.yankVisualSelection() case key.Matches(msg, m.keys.Up): m.moveTextCursorLine(-1) return m, nil case msg.String() == "left": m.moveTextCursorCol(-1) return m, nil case msg.String() == "h": m.moveTextCursorCol(-1) return m, nil case key.Matches(msg, m.keys.Down): m.moveTextCursorLine(1) return m, nil case msg.String() == "right": m.moveTextCursorCol(1) return m, nil case msg.String() == "l": m.moveTextCursorCol(1) return m, nil case key.Matches(msg, m.keys.PageUp): m.moveTextCursorLine(-max(m.previewModel.Height-2, 1)) return m, nil case key.Matches(msg, m.keys.PageDown): m.moveTextCursorLine(max(m.previewModel.Height-2, 1)) return m, nil case msg.String() == "w": m.moveTextCursorWordForward() return m, nil case msg.String() == "b": m.moveTextCursorWordBackward() return m, nil default: return m, nil } } // Copy file path when info mode is open if m.infoMode && m.copyPath != "" { switch msg.String() { case "y", "Y", "ctrl+c": if err := clipboard.WriteAll(m.copyPath); err != nil { m.status = fmt.Sprintf("Copy path error: %v", err) } else { m.status = fmt.Sprintf("Path copied: %s", m.copyPath) } return m, nil } } // Filter mode: route keys to the filter input if m.filterMode { switch { case msg.String() == "esc": m.filterQuery = "" m.filterInput.SetValue("") m.filterInput.Blur() m.filterMode = false m.status = "Filter cleared" return m, nil case key.Matches(msg, m.keys.Confirm): m.filterMode = false m.filterInput.Blur() m.status = fmt.Sprintf("Filter: %s", m.filterQuery) return m, nil case key.Matches(msg, m.keys.Up): m.moveFilteredCursor(-1) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.Down): m.moveFilteredCursor(1) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.PageUp): m.moveFilteredCursor(-max(m.bodyHeight()-6, 5)) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.PageDown): m.moveFilteredCursor(max(m.bodyHeight()-6, 5)) return m, m.loadPreviewCmd() default: var cmd tea.Cmd m.filterInput, cmd = m.filterInput.Update(msg) m.filterQuery = m.filterInput.Value() m.snapFilterCursor() return m, cmd } } // Toggle filter mode — attaches filter to the currently active pane if key.Matches(msg, m.keys.Filter) { m.filterMode = true m.filterPaneID = m.active m.filterInput.Focus() m.filterInput.SetValue(m.filterQuery) m.status = "Filter: type to filter, Enter to confirm, Esc to clear" return m, nil } switch { case key.Matches(msg, m.keys.Quit): m.cleanupArchiveMounts() m.cleanupImageOverlay() return m, tea.Quit case key.Matches(msg, m.keys.Help): m.openHelpModal() return m, nil case key.Matches(msg, m.keys.Rename): m.openRenameModal() return m, nil case key.Matches(msg, m.keys.Cancel), msg.String() == "q": // Esc on the pane where filter is active clears it. if m.filterQuery != "" && m.filterPaneID == m.active { m.clearFilter() m.status = "Filter cleared" return m, nil } if m.infoMode { m.infoMode = false m.selectMode = false m.cursorMode = false m.visualMode = false m.copyPath = "" m.status = "Info pane closed" return m, nil } if len(m.activePane().MarkedEntries()) > 0 { m.activePane().ClearMarks() m.status = "Selection cleared" return m, m.loadPreviewCmd() } return m, nil case key.Matches(msg, m.keys.View): return m.handleView() case key.Matches(msg, m.keys.Caret): return m.toggleCaretMode() case key.Matches(msg, m.keys.Visual): if m.cursorMode { return m.toggleVisualMode() } return m, nil case key.Matches(msg, m.keys.Edit): return m.handleEdit() case key.Matches(msg, m.keys.Archive): return m.handleArchive() case key.Matches(msg, m.keys.Info): return m.toggleInfo() case key.Matches(msg, m.keys.SelectText): return m.toggleSelectMode() case key.Matches(msg, m.keys.ToggleHidden): return m.toggleHidden() case key.Matches(msg, m.keys.CycleTheme): return m.cycleTheme() case key.Matches(msg, m.keys.CycleSort): return m.cycleSort() case key.Matches(msg, m.keys.Switch): m.left.ClearMarks() m.right.ClearMarks() if m.active == PaneLeft { m.active = PaneRight } else { m.active = PaneLeft } m.status = fmt.Sprintf("Active pane: %s", strings.ToUpper(string(m.active))) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.Up): m.moveCursor(-1) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.Down): m.moveCursor(1) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.SelectUp): m.selectMoveCursor(-1) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.SelectDown): m.selectMoveCursor(1) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.PageUp): m.moveCursor(-max(m.bodyHeight()-6, 5)) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.PageDown): m.moveCursor(max(m.bodyHeight()-6, 5)) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.Open): return m.handleOpenSelected() case key.Matches(msg, m.keys.Back): if err := m.goParent(); err != nil { m.status = err.Error() } return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.HistoryBack): return m.historyBack() case key.Matches(msg, m.keys.HistoryForward): return m.historyForward() case key.Matches(msg, m.keys.Refresh): return m.refreshAllPanes("Refreshed") case key.Matches(msg, m.keys.DirSize): return m.handleDirSize() case key.Matches(msg, m.keys.Copy): return m.handleTransfer(opCopy) case key.Matches(msg, m.keys.Move): return m.handleTransfer(opMove) case key.Matches(msg, m.keys.Mkdir): m.openMkdirModal() return m, nil case key.Matches(msg, m.keys.Delete): return m.handleDelete() case key.Matches(msg, m.keys.PermanentDelete): return m.handlePermanentDelete() } case tea.MouseMsg: return m.handleMouse(msg) } return m, nil } func (m Model) View() string { if m.width < 72 || m.height < 18 { return lipgloss.NewStyle(). Foreground(m.palette.Warning). Padding(1, 2). Render("Terminal is too small for vcom. Resize the window.") } leftWidth, previewWidth, rightWidth := m.layoutWidths() bodyHeight := m.bodyHeight() gap := lipgloss.NewStyle(). Width(m.cfg.UI.PaneGap). Height(bodyHeight). Background(m.palette.Panel). Render("") // Filter is sticky: once activated on a pane it stays on that pane // even after switching to the other pane with Tab. leftPane := m.left rightPane := m.right if m.filterQuery != "" { switch m.filterPaneID { case PaneLeft: leftPane = m.filteredPane(m.left) case PaneRight: rightPane = m.filteredPane(m.right) } } var panels string if m.viewMode && m.previewData.Kind == vfs.PreviewKindImage { panels = lipgloss.NewStyle(). Width(m.width). Height(bodyHeight). Background(m.palette.Background). Render("") } else if m.viewMode && m.previewData.Kind == vfs.PreviewKindText { panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) } else if m.viewMode { panels = renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, m.width, bodyHeight, m.nerdIcons) } else if m.selectMode && m.infoMode { panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) } else if m.infoMode { if m.active == PaneLeft { panels = lipgloss.JoinHorizontal( lipgloss.Top, renderPane(leftPane, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons), gap, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons), ) } else { panels = lipgloss.JoinHorizontal( lipgloss.Top, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons), gap, renderPane(rightPane, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons), ) } } else { panels = lipgloss.JoinHorizontal( lipgloss.Top, renderPane(leftPane, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft), m.nerdIcons), gap, renderPane(rightPane, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight), m.nerdIcons), ) } parts := make([]string, 0, 4) parts = append(parts, panels) if m.filterMode { parts = append(parts, renderFilterBar(m)) } if m.cfg.UI.ShowFooter && !m.viewMode { parts = append(parts, renderFooter(m)) } view := lipgloss.NewStyle(). Width(m.width). Height(m.height). Background(m.palette.Background). Foreground(m.palette.Text). Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) if m.modal.kind != modalNone { if m.overlay != nil { m.overlay.hide() } modalWidth := min(72, m.width-8) if m.modal.kind == modalHelp { modalWidth = min(96, m.width-8) } view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width) return view } m.syncImageOverlay(leftWidth, previewWidth, bodyHeight) return view } func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch m.modal.kind { case modalMkdir, modalRename: switch { case msg.String() == "esc": m.modal = modalState{} m.status = "Cancelled" return m, nil case key.Matches(msg, m.keys.Confirm): value := strings.TrimSpace(m.modal.input.Value()) if value == "" { if m.modal.kind == modalMkdir { m.status = "Directory name must not be empty" } else { m.status = "Name must not be empty" } return m, nil } m.busy = true if m.modal.kind == modalMkdir { return m, mkdirCmd(m.activePane().Path, value) } selected, ok := m.activePane().Selected() if !ok || selected.IsParent { m.busy = false m.modal = modalState{} m.status = "No entry selected" return m, nil } return m, renameCmd(selected.Path, value) } var cmd tea.Cmd m.modal.input, cmd = m.modal.input.Update(msg) return m, cmd case modalConfirm: switch { case isModalCloseKey(msg, m.keys): m.modal = modalState{} m.status = "Cancelled" return m, nil case key.Matches(msg, m.keys.Confirm): if m.modal.pending == nil { m.modal = modalState{} m.status = "Nothing to confirm" return m, nil } pending := *m.modal.pending m.modal = modalState{} if pending.kind == opCopy || pending.kind == opMove { if m.copyJob != nil { m.status = "Transfer is already running" return m, nil } m.busy = true return m, m.startCopyJob(pending.kind, pending.sourcePaths, pending.targetDir, pending.overwrite, pending.stats) } m.busy = true return m, pending.cmd() } case modalArchiveType: switch { case isModalCloseKey(msg, m.keys): m.modal = modalState{} m.status = "Cancelled" return m, nil case key.Matches(msg, m.keys.Confirm): if m.modal.pending == nil { m.modal = modalState{} m.status = "Nothing to confirm" return m, nil } pending := *m.modal.pending m.modal = modalState{} if m.archiveJob != nil { m.status = "Archive is already running" return m, nil } m.busy = true return m, m.startArchiveJob(pending.sourcePaths, pending.targetDir, m.archiveFormat, pending.stats) case msg.String() == "f": switch m.archiveFormat { case "zip": m.archiveFormat = "tar" case "tar": m.archiveFormat = "tar.gz" default: m.archiveFormat = "zip" } m.modal.note = fmt.Sprintf( "Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel", m.archiveFormat, ) return m, nil } return m, nil case modalArchiveProgress: if key.Matches(msg, m.keys.Background) { if m.archiveJob == nil { m.modal = modalState{} return m, nil } m.archiveJob.background = true m.modal = modalState{} m.status = "Archive continues in background" return m, nil } if key.Matches(msg, m.keys.ProgressCancel) { if m.archiveJob == nil { m.modal = modalState{} return m, nil } if m.archiveJob.cancel != nil { m.archiveJob.cancel() } m.status = "Archiving cancelling..." return m, nil } return m, nil case modalCopyProgress: if key.Matches(msg, m.keys.Background) { if m.copyJob == nil { m.modal = modalState{} return m, nil } m.copyJob.background = true m.modal = modalState{} m.status = "Transfer continues in background" return m, nil } if key.Matches(msg, m.keys.ProgressCancel) { if m.copyJob == nil { m.modal = modalState{} return m, nil } if m.copyJob.cancel != nil { m.copyJob.cancel() } m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..." return m, nil } return m, nil case modalNotice: if key.Matches(msg, m.keys.Confirm) || isModalCloseKey(msg, m.keys) { m.modal = modalState{} } return m, nil case modalHelp: if isModalCloseKey(msg, m.keys) || key.Matches(msg, m.keys.Confirm) || key.Matches(msg, m.keys.Help) { m.modal = modalState{} m.status = "Help closed" } return m, nil } return m, nil } func isModalCloseKey(msg tea.KeyMsg, keys KeyMap) bool { return key.Matches(msg, keys.Cancel) || msg.String() == "q" } func (m *Model) reloadPane(id PaneID, preserve string) error { pane := m.paneByID(id) entries, err := vfs.ListDir(pane.Path, vfs.ListOptions{ ShowHidden: m.cfg.Browser.ShowHidden, DirsFirst: m.cfg.Browser.DirsFirst, SortBy: m.cfg.Browser.Sort.By, SortReverse: m.cfg.Browser.Sort.Reverse, }) if err != nil { return err } pane.SetEntries(entries, strings.ToLower(preserve)) return nil } func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) { leftSelected := selectedName(&m.left) rightSelected := selectedName(&m.right) if err := m.reloadPane(PaneLeft, leftSelected); err != nil { m.status = err.Error() return m, nil } if err := m.reloadPane(PaneRight, rightSelected); err != nil { m.status = err.Error() return m, nil } m.status = status return m, m.loadPreviewCmd() } func (m *Model) moveCursor(delta int) { // When a filter query is active on this pane, move through filtered entries // only, so the cursor always lands on a matching item. if m.filterQuery != "" && m.filterPaneID == m.active { m.moveFilteredCursor(delta) return } pane := m.activePane() pane.Move(delta, max(m.bodyHeight()-4, 1)) m.hover = hoverState{} } // snapFilterCursor moves the real cursor to the nearest entry matching the // current filter query. Called after each filter keystroke so the user // always sees a selected item in the filtered view. func (m *Model) snapFilterCursor() { pane := m.activePane() if m.filterQuery == "" || len(pane.Entries) == 0 { return } query := strings.ToLower(m.filterQuery) // If current cursor position already matches, keep it. if pane.Cursor >= 0 && pane.Cursor < len(pane.Entries) { entry := pane.Entries[pane.Cursor] if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { return } } // Search forward from cursor. for i := pane.Cursor + 1; i < len(pane.Entries); i++ { entry := pane.Entries[i] if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { pane.Cursor = i return } } // Search backward from cursor. for i := pane.Cursor - 1; i >= 0; i-- { entry := pane.Entries[i] if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { pane.Cursor = i return } } } // moveFilteredCursor moves the real cursor to the next/prev entry matching // the current filter query. Used for Up/Down navigation in filter mode. func (m *Model) moveFilteredCursor(delta int) { pane := m.activePane() if m.filterQuery == "" || len(pane.Entries) == 0 { m.moveCursor(delta) return } query := strings.ToLower(m.filterQuery) maxIdx := len(pane.Entries) - 1 idx := pane.Cursor + delta for idx >= 0 && idx <= maxIdx { entry := pane.Entries[idx] if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { pane.Cursor = idx pageSize := max(m.bodyHeight()-4, 1) if pane.Cursor < pane.Offset { pane.Offset = pane.Cursor } if pageSize > 0 && pane.Cursor >= pane.Offset+pageSize { pane.Offset = pane.Cursor - pageSize + 1 } if pane.Offset < 0 { pane.Offset = 0 } m.hover = hoverState{} return } idx += delta } } // filteredCount returns the number of entries matching the current filter. func (m *Model) filteredCount(pane *BrowserPane) int { if m.filterQuery == "" { return len(pane.Entries) } query := strings.ToLower(m.filterQuery) count := 0 for _, entry := range pane.Entries { if strings.Contains(strings.ToLower(entry.DisplayName()), query) { count++ } } return count } // filteredPane returns a copy of the pane with entries filtered by the current query. // The cursor in the returned copy reflects the position of the real cursor // within the filtered subset, so Selected() on the original pane still returns // the correct entry. Offset is recomputed in filtered-entry space so the // viewport does not inherit the real-list offset (which would be out of range). func (m Model) filteredPane(pane BrowserPane) BrowserPane { if m.filterQuery == "" { return pane } query := strings.ToLower(m.filterQuery) filtered := make([]vfs.Entry, 0, len(pane.Entries)) filteredCursor := 0 for i, entry := range pane.Entries { if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { if i == pane.Cursor { filteredCursor = len(filtered) } filtered = append(filtered, entry) } } pane.Entries = filtered pane.Cursor = filteredCursor if pane.Cursor >= len(filtered) { pane.Cursor = max(len(filtered)-1, 0) } // Recompute offset in filtered-entry space. The source offset is in // real-entry-index space and is meaningless for the shorter filtered list. pageSize := max(m.bodyHeight()-4, 1) offset := 0 if pane.Cursor >= pageSize { offset = pane.Cursor - pageSize + 1 } pane.Offset = offset return pane } func (m *Model) selectMoveCursor(delta int) { pane := m.activePane() if selected, ok := pane.Selected(); ok && !selected.IsParent { pane.ToggleMarked(selected.Path) } if m.filterQuery != "" && m.filterPaneID == m.active { m.moveFilteredCursor(delta) } else { pane.Move(delta, max(m.bodyHeight()-4, 1)) } m.hover = hoverState{} } func (m *Model) operationSources() []string { pane := m.activePane() marked := pane.MarkedEntries() if len(marked) > 0 { paths := make([]string, 0, len(marked)) for _, entry := range marked { if entry.IsParent { continue } paths = append(paths, entry.Path) } return paths } selected, ok := pane.Selected() if !ok || selected.IsParent { return nil } return []string{selected.Path} } func (m *Model) enterSelected() error { m.hover = hoverState{} pane := m.activePane() selected, ok := pane.Selected() if !ok { return nil } if !selected.IsDir { m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor." return nil } // Save current directory to history before navigating. pane.PushHistory(pane.Path) pane.Path = selected.Path if err := m.reloadPane(pane.ID, selected.Name); err != nil { return err } m.status = fmt.Sprintf("Entered %s", pane.Path) return nil } func (m *Model) clearFilter() { m.filterQuery = "" m.filterInput.SetValue("") m.filterInput.Blur() m.filterMode = false m.filterPaneID = "" } func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok { return m, nil } // Navigating up via ".." — use goParent which preserves the cursor // position on the directory/archive we came from (by finding its name // in the parent listing via FindSelected). This applies both inside // archive mounts (where pane.Path must stay within the temp mount) // and regular directories (for consistent cursor placement). if selected.IsParent { if err := m.goParent(); err != nil { m.status = err.Error() return m, nil } return m, m.loadPreviewCmd() } if selected.IsDir { if err := m.enterSelected(); err != nil { m.status = err.Error() return m, nil } return m, m.loadPreviewCmd() } if isArchiveEntry(selected) { if err := m.enterArchive(selected); err != nil { m.status = err.Error() return m, nil } return m, m.loadPreviewCmd() } switch selected.Category() { case "text", "config": return m.handleEdit() case "executable": return m.handleExecute(selected) default: return m.handleOpenExternal() } } func (m *Model) goParent() error { m.hover = hoverState{} pane := m.activePane() if mount, ok := pane.CurrentArchive(); ok { root := filepath.Clean(mount.RootPath) current := filepath.Clean(pane.Path) if current == root { // At archive root — pop archive and return to the directory containing it // Save current path to history before leaving so forward can restore it. pane.PushHistory(pane.Path) if _, popped := pane.PopArchive(); popped { _ = os.RemoveAll(mount.TempDir) } pane.Path = mount.ParentPath if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil { return err } m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath)) return nil } // Inside archive subdirectory — go up one level within the archive pane.PushHistory(pane.Path) parent := filepath.Dir(current) pane.Path = parent if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil { return err } m.status = fmt.Sprintf("Moved to %s", parent) return nil } parent := filepath.Dir(pane.Path) if parent == pane.Path { return nil } pane.PushHistory(pane.Path) currentName := filepath.Base(pane.Path) pane.Path = parent if err := m.reloadPane(pane.ID, currentName); err != nil { return err } m.status = fmt.Sprintf("Moved to %s", parent) return nil } // historyBack navigates the active pane to the previous directory in its history. func (m *Model) historyBack() (tea.Model, tea.Cmd) { pane := m.activePane() prevPath, ok := pane.PopHistory() if !ok { m.status = "No directory history" return m, nil } // Save current path to forward-stack so forward navigation can restore it. pane.PushFuture(pane.Path) pane.Path = prevPath pane.Cursor = 0 pane.Offset = 0 if err := m.reloadPane(pane.ID, ""); err != nil { m.status = err.Error() return m, nil } m.status = fmt.Sprintf("History back to %s", prevPath) return m, m.loadPreviewCmd() } // historyForward navigates the active pane to the next directory in its forward-stack. func (m *Model) historyForward() (tea.Model, tea.Cmd) { pane := m.activePane() nextPath, ok := pane.PopFuture() if !ok { m.status = "No forward history" return m, nil } // Save current path to back-stack so back navigation can restore it. pane.PushHistory(pane.Path) pane.Path = nextPath pane.Cursor = 0 pane.Offset = 0 if err := m.reloadPane(pane.ID, ""); err != nil { m.status = err.Error() return m, nil } m.status = fmt.Sprintf("History forward to %s", nextPath) return m, m.loadPreviewCmd() } func (m Model) loadPreviewCmd() tea.Cmd { selected, ok := m.activePane().Selected() if !ok { return func() tea.Msg { return previewMsg{ entryPath: "", preview: vfs.Preview{ Kind: vfs.PreviewKindEmpty, Title: "Nothing selected", Body: "No entry selected.", }, } } } options := vfs.PreviewOptions{ ShowHidden: m.cfg.Browser.ShowHidden, DirsFirst: m.cfg.Browser.DirsFirst, SortBy: m.cfg.Browser.Sort.By, SortReverse: m.cfg.Browser.Sort.Reverse, MaxPreviewBytes: m.cfg.Preview.MaxPreviewBytes, DirectoryPreviewLimit: m.cfg.Preview.DirectoryPreviewLimit, HumanReadableSize: m.cfg.Browser.HumanReadableSize, ThemeName: m.cfg.UI.Theme, UseNerdIcons: m.nerdIcons, } return func() tea.Msg { return previewMsg{ entryPath: selected.Path, preview: vfs.BuildPreview(selected, options), } } } func (m *Model) handleDirSize() (tea.Model, tea.Cmd) { if !m.cfg.Behavior.CalculateDirSizeOnSpace { m.status = "Directory size on Space is disabled in config" return m, nil } selected, ok := m.activePane().Selected() if !ok || !selected.IsDir || selected.IsParent { m.status = "Select a directory first" return m, nil } if selected.DirSizeKnown { m.status = fmt.Sprintf("Directory size: %s", formatSize(selected.Size, m.cfg.Browser.HumanReadableSize)) return m, nil } m.busy = true m.status = fmt.Sprintf("Calculating directory size for %s", selected.DisplayName()) return m, dirSizeCmd(selected.Path) } func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) { if m.activePane().InArchive() && kind != opCopy { m.status = "Archive mode is read-only; only copy is allowed" return m, nil } sources := m.operationSources() if len(sources) == 0 { m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind)) return m, nil } targetDir := m.passivePane().Path existingTargets := 0 for _, sourcePath := range sources { targetPath := filepath.Join(targetDir, filepath.Base(sourcePath)) exists, err := vfs.PathExists(targetPath) if err != nil { m.status = err.Error() return m, nil } if exists { existingTargets++ } } if kind == opCopy || kind == opMove { if m.copyJob != nil { m.status = "Transfer is already running" return m, nil } overwrite := existingTargets > 0 if existingTargets > 0 && !m.cfg.Behavior.ConfirmOverwrite { overwrite = true } m.busy = true m.status = fmt.Sprintf("Calculating %s size", operationVerb(kind)) return m, copyPlanCmd(kind, sources, targetDir, overwrite, existingTargets) } return m, nil } func (m *Model) handleArchive() (tea.Model, tea.Cmd) { sources := m.operationSources() if len(sources) == 0 { m.status = "Nothing to archive" return m, nil } if m.archiveJob != nil { m.status = "Archive is already running" return m, nil } m.busy = true m.status = "Calculating archive size" return m, archivePlanCmd(sources, m.passivePane().Path) } func (m *Model) handleDelete() (tea.Model, tea.Cmd) { if m.activePane().InArchive() { m.status = "Archive mode is read-only; delete is disabled" return m, nil } sources := m.operationSources() if len(sources) == 0 { m.status = "Nothing to delete" return m, nil } if !m.cfg.Behavior.ConfirmDelete { m.busy = true m.status = fmt.Sprintf("Moving %d entr%s to trash", len(sources), pluralSuffix(len(sources), "y", "ies")) return m, trashPathsCmd(sources) } m.busy = true m.status = "Calculating trash size" return m, trashPlanCmd(sources) } func (m *Model) handlePermanentDelete() (tea.Model, tea.Cmd) { if m.activePane().InArchive() { m.status = "Archive mode is read-only; permanent delete is disabled" return m, nil } sources := m.operationSources() if len(sources) == 0 { m.status = "Nothing to delete" return m, nil } if !m.cfg.Behavior.ConfirmDelete { m.busy = true m.status = fmt.Sprintf("Permanently deleting %d entr%s", len(sources), pluralSuffix(len(sources), "y", "ies")) return m, deletePathsPermanentCmd(sources) } m.busy = true m.status = "Calculating delete size" return m, deletePlanPermanentCmd(sources) } func (m *Model) handleView() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok || selected.IsParent || selected.IsDir { m.status = "Select a file to view" return m, nil } if m.viewMode { return m.exitViewMode() } m.viewPrevInfo = m.infoMode m.infoMode = true m.selectMode = m.previewData.Kind == vfs.PreviewKindText m.visualMode = false m.viewMode = true m.resizePreview() m.syncPreviewContent() m.status = "View mode: F3/Esc/q to close" if m.selectMode { return m, tea.Batch(m.loadPreviewCmd(), disableMouseCmd()) } return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) } func (m *Model) exitViewMode() (tea.Model, tea.Cmd) { if !m.viewMode { return m, nil } m.viewMode = false m.selectMode = false m.visualMode = false m.infoMode = m.viewPrevInfo m.resizePreview() m.syncPreviewContent() m.status = "View mode: off" return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) } func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok || selected.IsParent || selected.IsDir { m.status = "Select a file to open" return m, nil } command, name, err := externalCommand("", []string{"xdg-open", "open"}, selected.Path) if err != nil { m.status = "No system opener found (tried xdg-open/open)" return m, nil } m.cleanupImageOverlay() m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name) return m, startExternalOpenCmd(command, selected.Path) } func (m *Model) handleEdit() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok || selected.IsParent || selected.IsDir { m.status = "Select a file to edit" return m, nil } command, name, err := externalCommandFromEnv([]string{"VISUAL", "EDITOR"}, []string{"nvim", "vim", "vi", "nano"}, selected.Path) if err != nil { m.status = "Set $VISUAL/$EDITOR or install nvim/vim/vi/nano to enable F4 editing" return m, nil } m.cleanupImageOverlay() m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name) return m, tea.ExecProcess(command, func(err error) tea.Msg { return opMsg{kind: opEdit, sourcePath: selected.Path, err: err} }) } func (m *Model) handleExecute(entry vfs.Entry) (tea.Model, tea.Cmd) { m.cleanupImageOverlay() cmd := exec.Command(entry.Path) cmd.Dir = filepath.Dir(entry.Path) m.status = fmt.Sprintf("Executing %s", entry.DisplayName()) return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return opMsg{kind: opExecute, sourcePath: entry.Path, err: err} }) } func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.viewMode { switch { case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp: m.previewModel.LineUp(3) return m, nil case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown: m.previewModel.LineDown(3) return m, nil default: return m, nil } } switch { case msg.Action == tea.MouseActionMotion: paneID, index, ok := m.mouseTarget(msg.X, msg.Y) if ok { m.hover = hoverState{pane: paneID, index: index, ok: true} } else { m.hover = hoverState{} } return m, nil case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp: if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { m.previewModel.LineUp(3) return m, nil } m.moveCursor(-1) return m, m.loadPreviewCmd() case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown: if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { m.previewModel.LineDown(3) return m, nil } m.moveCursor(1) return m, m.loadPreviewCmd() case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft: if m.copyPath != "" && m.mouseOverPathLine(msg.X, msg.Y) { if err := clipboard.WriteAll(m.copyPath); err != nil { m.status = fmt.Sprintf("Copy path error: %v", err) } else { m.status = fmt.Sprintf("Path copied: %s", m.copyPath) } return m, nil } paneID, index, ok := m.mouseTarget(msg.X, msg.Y) if !ok { return m, nil } m.hover = hoverState{pane: paneID, index: index, ok: true} if paneID != m.active { m.left.ClearMarks() m.right.ClearMarks() } m.active = paneID pane := m.paneByID(paneID) if index >= 0 && index < len(pane.Entries) { pane.Cursor = index pane.EnsureVisible(max(m.bodyHeight()-4, 1)) } now := time.Now() doubleClick := m.lastClick.pane == paneID && m.lastClick.index == index && now.Sub(m.lastClick.at) <= 450*time.Millisecond m.lastClick = mouseClickState{pane: paneID, index: index, at: now} if doubleClick { return m.handleOpenSelected() } m.status = fmt.Sprintf("Selected %s pane", strings.ToUpper(string(paneID))) return m, m.loadPreviewCmd() case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight: if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { m.infoMode = false m.cursorMode = false m.visualMode = false m.resizePreview() m.syncPreviewContent() m.copyPath = "" m.status = "Info mode: off" return m, nil } paneID, index, ok := m.mouseTarget(msg.X, msg.Y) if !ok { return m, nil } if m.infoMode && paneID == m.active && index == m.activePane().Cursor { m.infoMode = false m.cursorMode = false m.visualMode = false m.hover = hoverState{pane: paneID, index: index, ok: true} m.copyPath = "" m.status = "Info mode: off" return m, nil } m.hover = hoverState{pane: paneID, index: index, ok: true} if paneID != m.active { m.left.ClearMarks() m.right.ClearMarks() } m.active = paneID pane := m.paneByID(paneID) if index >= 0 && index < len(pane.Entries) { pane.Cursor = index pane.EnsureVisible(max(m.bodyHeight()-4, 1)) } m.infoMode = true m.resizePreview() m.syncPreviewContent() m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(paneID))) return m, m.loadPreviewCmd() default: return m, nil } } func (m *Model) toggleInfo() (tea.Model, tea.Cmd) { m.infoMode = !m.infoMode m.resizePreview() m.syncPreviewContent() if m.infoMode { m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(m.active))) return m, m.loadPreviewCmd() } wasSelect := m.selectMode if m.selectMode { m.selectMode = false } if m.cursorMode { m.cursorMode = false } if m.visualMode { m.visualMode = false } if wasSelect { return m, enableMouseCmd() } m.status = "Info mode: off" return m, nil } func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) { if m.viewMode { m.status = "Use v/y in F3 view for keyboard selection" return m, nil } if m.selectMode { m.selectMode = false m.status = "Text selection mode: off" return m, enableMouseCmd() } if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText { m.status = "Text selection mode works only for text preview in info pane" return m, nil } m.selectMode = true m.status = "Text selection mode: on" return m, disableMouseCmd() } func (m *Model) toggleCaretMode() (tea.Model, tea.Cmd) { if m.viewMode { m.status = "F3 uses plain text mouse selection" return m, nil } if m.selectMode { m.status = "Disable Ctrl+T mouse selection first" return m, nil } if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText { m.status = "Caret mode works only for text preview in info pane" return m, nil } if m.cursorMode { return m.exitCaretMode("Caret mode: off") } lineCount := len(m.previewPlainLines()) if lineCount == 0 { m.status = "Nothing to navigate" return m, nil } m.cursorMode = true m.cursorLine = clamp(m.previewModel.YOffset, 0, lineCount-1) m.cursorCol = clamp(m.cursorCol, 0, m.lineRuneCount(m.cursorLine)) m.visualMode = false m.ensureTextCursorVisible() m.syncPreviewContent() m.status = "Caret mode: h/j/k/l move, v select, Esc exit" return m, nil } func (m *Model) exitCaretMode(status string) (tea.Model, tea.Cmd) { if !m.cursorMode { m.status = status return m, nil } m.cursorMode = false m.visualMode = false m.syncPreviewContent() m.status = status return m, nil } func (m *Model) toggleVisualMode() (tea.Model, tea.Cmd) { if m.viewMode { m.status = "F3 uses plain text mouse selection; visual mode is for info pane" return m, nil } if m.selectMode { m.status = "Disable Ctrl+T mouse selection first" return m, nil } if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText { m.status = "Visual mode works only for text preview" return m, nil } if m.visualMode { return m.exitVisualMode("Visual mode: off") } lineCount := len(m.previewPlainLines()) if lineCount == 0 { m.status = "Nothing to select" return m, nil } start := clamp(m.previewModel.YOffset, 0, lineCount-1) if m.cursorMode { start = clamp(m.cursorLine, 0, lineCount-1) } else { m.cursorMode = true m.cursorLine = start m.cursorCol = 0 } m.visualMode = true m.visualAnchor = start m.visualAnchorCol = m.cursorCol m.ensureTextCursorVisible() m.syncPreviewContent() m.status = "Visual mode: h/j/k/l move, y copy, Esc exit" return m, nil } func (m *Model) exitVisualMode(status string) (tea.Model, tea.Cmd) { if !m.visualMode { m.status = status return m, nil } m.visualMode = false m.syncPreviewContent() m.status = status return m, nil } func (m *Model) toggleHidden() (tea.Model, tea.Cmd) { m.cfg.Browser.ShowHidden = !m.cfg.Browser.ShowHidden return m.refreshAllPanes(fmt.Sprintf("Show hidden: %t", m.cfg.Browser.ShowHidden)) } func (m *Model) cycleTheme() (tea.Model, tea.Cmd) { next := theme.Next(m.cfg.UI.Theme) palette, err := theme.Resolve(next) if err != nil { m.status = err.Error() return m, nil } m.cfg.UI.Theme = next m.palette = palette savedPath, saveErr := config.Save(m.cfg, m.configPath) if saveErr != nil { m.status = fmt.Sprintf("Theme: %s (save failed: %v)", next, saveErr) return m, nil } m.configPath = savedPath m.status = fmt.Sprintf("Theme: %s (saved)", next) return m, nil } func copyTextToClipboard(text string) error { if err := clipboard.WriteAll(text); err == nil { return nil } _, err := fmt.Fprint(os.Stderr, ansi.SetSystemClipboard(text)) return err } func (m *Model) yankVisualSelection() (tea.Model, tea.Cmd) { if !m.visualMode || m.previewData.Kind != vfs.PreviewKindText { m.status = "Visual mode is not active" return m, nil } lines := m.previewPlainLines() if len(lines) == 0 { return m.exitVisualMode("Nothing to copy") } startLine, startCol, endLine, endCol := m.visualSelectionBounds() startLine = clamp(startLine, 0, len(lines)-1) endLine = clamp(endLine, 0, len(lines)-1) parts := make([]string, 0, endLine-startLine+1) for line := startLine; line <= endLine; line++ { raw := lines[line] lineStart := 0 lineEnd := len([]rune(raw)) if line == startLine { lineStart = clamp(startCol, 0, lineEnd) } if line == endLine { lineEnd = clamp(endCol, lineStart, len([]rune(raw))) } parts = append(parts, sliceRunes(raw, lineStart, lineEnd)) } text := strings.Join(parts, "\n") if err := copyTextToClipboard(text); err != nil { m.status = fmt.Sprintf("Copy failed: %v", err) return m, nil } return m.exitVisualMode("Copied selection") } func (m *Model) yankCursorLine() (tea.Model, tea.Cmd) { if !m.cursorMode || m.previewData.Kind != vfs.PreviewKindText { m.status = "Caret mode is not active" return m, nil } lines := m.previewPlainLines() if len(lines) == 0 { m.status = "Nothing to copy" return m, nil } line := clamp(m.cursorLine, 0, len(lines)-1) if err := copyTextToClipboard(lines[line]); err != nil { m.status = fmt.Sprintf("Copy failed: %v", err) return m, nil } m.yankFlashLine = line m.syncPreviewContent() m.status = "Copied current line" return m, dismissYankFlashCmd(140 * time.Millisecond) } func (m *Model) cycleSort() (tea.Model, tea.Cmd) { order := []string{"name", "modified", "size", "created", "extension"} current := strings.ToLower(strings.TrimSpace(m.cfg.Browser.Sort.By)) next := order[0] for idx, value := range order { if value == current { next = order[(idx+1)%len(order)] break } } m.cfg.Browser.Sort.By = next return m.refreshAllPanes(fmt.Sprintf("Sort: %s", next)) } func (m *Model) openMkdirModal() { if m.activePane().InArchive() { m.status = "Archive mode is read-only; create directory is disabled" return } input := textinput.New() input.Placeholder = "new-directory" input.Focus() input.CharLimit = 128 input.Width = 42 m.modal = modalState{ kind: modalMkdir, title: "Create directory", body: fmt.Sprintf("Active pane: %s", m.activePane().Path), note: "Enter to confirm, Esc to cancel", input: input, } } func (m *Model) openRenameModal() { if m.activePane().InArchive() { m.status = "Archive mode is read-only; rename is disabled" return } selected, ok := m.activePane().Selected() if !ok || selected.IsParent { m.status = "Select an entry to rename" return } input := textinput.New() input.SetValue(selected.Name) input.Focus() input.CharLimit = 255 input.Width = 42 m.modal = modalState{ kind: modalRename, title: "Rename entry", body: fmt.Sprintf("Path: %s", selected.Path), note: "Enter to confirm, Esc to cancel", input: input, } } func (m *Model) openConfirmModal(title, body, note string, pending pendingOperation) { m.modal = modalState{ kind: modalConfirm, title: title, body: body, note: note, pending: &pending, } } func (m *Model) openHelpModal() { sections := []string{ "Navigation", " j / Down move down", " k / Up move up", " Shift+Down/J extend selection down", " Shift+Up/K extend selection up", " PgUp / b page up", " Enter / Right open selected entry", " Backspace/Left go to parent directory", " Tab / h / l switch active pane", " Alt+Left directory history back", " Alt+Right directory history forward", " / filter entries by name in current pane", " Ctrl+r refresh both panes", "", "View and Panels", " o toggle preview/info pane", " i show text caret in preview pane", " v start visual selection from caret", " Esc / q close view/info/caret mode", " yy copy current line in caret mode", " y copy visual selection to clipboard", " h / l move caret left/right", " w / b move caret by word", " Ctrl+t mouse selection mode in text preview pane", " Space calculate selected directory size", " s cycle sort mode", " . toggle hidden files", " t cycle theme", "", "Dialogs and Transfers", " F8 / x move selected entry to trash", " F11 / d permanently delete selected entry", " r rename selected entry", " Enter / y confirm action", " Esc / n cancel action", " b run copy/move in background (progress dialog)", " c cancel active copy/move transfer", "", "Mouse", " Left click select entry and activate pane", " Double click open selected entry", " Right click toggle preview/info mode for clicked entry", " Wheel scroll list or preview area", "", "F1–F10 actions are shown in the footer.", } m.modal = modalState{ kind: modalHelp, title: "Keyboard and Mouse Help", body: strings.Join(sections, "\n"), note: version + " — F1/? or Esc to close", } m.status = "Help opened" } func (m *Model) applyDirSize(path string, size int64) { for _, pane := range []*BrowserPane{&m.left, &m.right} { for idx := range pane.Entries { if pane.Entries[idx].Path == path { pane.Entries[idx].Size = size pane.Entries[idx].DirSizeKnown = true } } } } func (m *Model) applyPreview(preview vfs.Preview) { m.previewData = preview m.syncPreviewContent() if m.infoMode { m.copyPath = preview.Metadata.Path } } func (m *Model) syncPreviewContent() { content := m.previewData.Body if (m.cursorMode || m.visualMode) && m.previewData.Kind == vfs.PreviewKindText { content = m.renderTextCursorContent() } if m.cfg.Preview.WrapText && m.previewModel.Width > 0 { content = lipgloss.NewStyle().Width(m.previewModel.Width).Render(content) } m.previewModel.SetContent(content) } func (m Model) previewPlainLines() []string { content := m.previewData.PlainBody if content == "" { content = m.previewData.Body } content = strings.ReplaceAll(content, "\r\n", "\n") return strings.Split(content, "\n") } func (m Model) previewRenderedLines() []string { content := m.previewData.Body if content == "" { content = m.previewData.PlainBody } content = strings.ReplaceAll(content, "\r\n", "\n") return strings.Split(content, "\n") } func (m Model) lineRuneCount(line int) int { lines := m.previewPlainLines() if line < 0 || line >= len(lines) { return 0 } return len([]rune(lines[line])) } func sliceRunes(text string, start int, end int) string { runes := []rune(text) start = clamp(start, 0, len(runes)) end = clamp(end, start, len(runes)) return string(runes[start:end]) } func isWordRune(r rune) bool { return r == '_' || r == '-' || r == '.' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= 'А' && r <= 'я') || (r >= 'Ё' && r <= 'ё') } func normalizeSelection(startLine, startCol, endLine, endCol int) (int, int, int, int) { if startLine == endLine && startCol == endCol { return startLine, startCol, endLine, endCol + 1 } if startLine < endLine || (startLine == endLine && startCol <= endCol) { return startLine, startCol, endLine, endCol + 1 } return endLine, endCol, startLine, startCol + 1 } func (m Model) visualSelectionBounds() (int, int, int, int) { return normalizeSelection(m.visualAnchor, m.visualAnchorCol, m.cursorLine, m.cursorCol) } func (m *Model) renderTextCursorContent() string { lines := append([]string(nil), m.previewRenderedLines()...) plainLines := m.previewPlainLines() if len(lines) == 0 { return "" } startLine, startCol, endLine, endCol := m.visualSelectionBounds() hasSelection := false if m.visualMode { startLine = clamp(startLine, 0, len(lines)-1) endLine = clamp(endLine, 0, len(lines)-1) hasSelection = startLine != endLine || startCol != endCol } selected := lipgloss.NewStyle(). Background(m.palette.Marked). Foreground(m.palette.Text) flashed := lipgloss.NewStyle(). Background(m.palette.Accent). Foreground(m.palette.Background). Bold(true) cursor := lipgloss.NewStyle(). Background(m.palette.Warning). Foreground(m.palette.Background). Bold(true) gutterBase := lipgloss.NewStyle(). Width(2). Foreground(m.palette.Muted) gutterAnchor := lipgloss.NewStyle(). Width(2). Foreground(m.palette.Info). Bold(true) gutterCursor := lipgloss.NewStyle(). Width(2). Foreground(m.palette.Accent). Bold(true) gutterBoth := lipgloss.NewStyle(). Width(2). Foreground(m.palette.Warning). Bold(true) for idx := range lines { marker := " " switch { case m.visualMode && idx == m.visualAnchor && idx == m.cursorLine: marker = gutterBoth.Render("◆ ") case m.visualMode && idx == m.visualAnchor: marker = gutterAnchor.Render("│ ") case idx == m.cursorLine: marker = gutterCursor.Render("▶ ") default: marker = gutterBase.Render(" ") } line := lines[idx] plain := "" if idx < len(plainLines) { plain = plainLines[idx] } lineLen := len([]rune(plain)) cursorCol := clamp(m.cursorCol, 0, lineLen) if hasSelection && idx >= startLine && idx <= endLine { segStart := 0 segEnd := lineLen if idx == startLine { segStart = clamp(startCol, 0, lineLen) } if idx == endLine { segEnd = clamp(endCol, segStart, lineLen) } left := ansi.Cut(line, 0, segStart) mid := ansi.Cut(line, segStart, segEnd) right := ansi.Cut(line, segEnd, lineLen) if mid != "" { line = left + selected.Render(mid) + right } } if idx == m.cursorLine { left := ansi.Cut(line, 0, cursorCol) mid := ansi.Cut(line, cursorCol, min(cursorCol+1, max(lineLen, cursorCol+1))) right := ansi.Cut(line, min(cursorCol+1, lineLen), lineLen) if cursorCol >= lineLen { mid = cursor.Render(" ") right = "" } else { mid = cursor.Render(mid) } line = left + mid + right } if idx == m.yankFlashLine { lines[idx] = flashed.Render(marker + line) continue } lines[idx] = marker + line } result := strings.Join(lines, "\n") // Replace full ANSI resets with background-preserving resets. // lipgloss.Render() appends \x1b[0m which resets the panel background // set by the outer renderPreviewContent wrapper. Instead, reset only // foreground and text attributes, then restore the panel background // so that gutter markers, cursor highlights, and selection highlights // don't break the panel background for subsequent text on the line. panelBG := lipgloss.NewStyle().Background(m.palette.Panel).Render("") bgCode := strings.TrimSuffix(panelBG, "\x1b[0m") inner := bgCode[2 : len(bgCode)-1] safeReset := "\x1b[39;22;23;24;59;" + inner + "m" result = strings.ReplaceAll(result, "\x1b[0m", safeReset) return result } func (m *Model) moveTextCursorWordForward() { if !m.cursorMode { return } lines := m.previewPlainLines() if len(lines) == 0 { return } line := clamp(m.cursorLine, 0, len(lines)-1) col := clamp(m.cursorCol, 0, len([]rune(lines[line]))) for { runes := []rune(lines[line]) for col < len(runes) && isWordRune(runes[col]) { col++ } for col < len(runes) && !isWordRune(runes[col]) { col++ } if col < len(runes) { m.cursorLine = line m.cursorCol = col m.ensureTextCursorVisible() m.syncPreviewContent() return } if line >= len(lines)-1 { m.cursorLine = line m.cursorCol = len(runes) m.ensureTextCursorVisible() m.syncPreviewContent() return } line++ col = 0 } } func (m *Model) moveTextCursorWordBackward() { if !m.cursorMode { return } lines := m.previewPlainLines() if len(lines) == 0 { return } line := clamp(m.cursorLine, 0, len(lines)-1) col := clamp(m.cursorCol, 0, len([]rune(lines[line]))) for { runes := []rune(lines[line]) if col > len(runes) { col = len(runes) } // Start from the character immediately before the cursor. if col == 0 { if line == 0 { m.cursorLine = 0 m.cursorCol = 0 m.ensureTextCursorVisible() m.syncPreviewContent() return } line-- col = len([]rune(lines[line])) continue } col-- for { runes = []rune(lines[line]) for col >= 0 && !isWordRune(runes[col]) { col-- } if col >= 0 { break } if line == 0 { m.cursorLine = 0 m.cursorCol = 0 m.ensureTextCursorVisible() m.syncPreviewContent() return } line-- runes = []rune(lines[line]) col = len(runes) - 1 } for col > 0 && isWordRune(runes[col-1]) { col-- } m.cursorLine = line m.cursorCol = col m.ensureTextCursorVisible() m.syncPreviewContent() return } } func (m *Model) activePane() *BrowserPane { if m.active == PaneLeft { return &m.left } return &m.right } func (m *Model) passivePane() *BrowserPane { if m.active == PaneLeft { return &m.right } return &m.left } func (m *Model) paneByID(id PaneID) *BrowserPane { if id == PaneLeft { return &m.left } return &m.right } func (m *Model) layoutWidths() (int, int, int) { total := m.width gaps := m.cfg.UI.PaneGap usable := max(total-gaps, 60) left := usable / 2 right := usable - left if m.active == PaneLeft { if m.infoMode { return left, right, 0 } return left, 0, right } if m.infoMode { return 0, left, right } return left, 0, right } func (m *Model) bodyHeight() int { height := m.height if m.cfg.UI.ShowFooter && !m.viewMode { height-- } return max(height, 8) } func (m *Model) resizePreview() { _, previewWidth, _ := m.layoutWidths() metaHeight := 0 if m.cfg.Preview.ShowMetadata { metaHeight = 7 } innerWidth := max(previewWidth-2, 1) innerHeight := max(m.bodyHeight()-2, 1) m.previewModel.Width = max(innerWidth-2, 10) m.previewModel.Height = max(innerHeight-metaHeight-3, 3) } func (m *Model) moveTextCursorLine(delta int) { lines := m.previewPlainLines() if len(lines) == 0 { return } if !m.cursorMode { return } m.cursorLine = clamp(m.cursorLine+delta, 0, len(lines)-1) m.cursorCol = clamp(m.cursorCol, 0, m.lineRuneCount(m.cursorLine)) m.ensureTextCursorVisible() m.syncPreviewContent() } func (m *Model) moveTextCursorCol(delta int) { if !m.cursorMode { return } m.cursorCol = clamp(m.cursorCol+delta, 0, m.lineRuneCount(m.cursorLine)) m.ensureTextCursorVisible() m.syncPreviewContent() } func (m *Model) ensureTextCursorVisible() { if !m.cursorMode { return } visible := max(m.previewModel.Height, 1) if m.cursorLine < m.previewModel.YOffset { m.previewModel.SetYOffset(m.cursorLine) return } bottom := m.previewModel.YOffset + visible - 1 if m.cursorLine > bottom { m.previewModel.SetYOffset(m.cursorLine - visible + 1) } } func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int, useNerdfont bool) string { innerWidth := max(width-2, 1) innerHeight := max(height-2, 1) contentWidth := max(innerWidth-2, 1) box := lipgloss.NewStyle(). Width(innerWidth). Height(innerHeight). Background(palette.Panel). Foreground(palette.Text). BorderStyle(borderStyle(cfg.UI.Border)). BorderForeground(palette.BorderActive). BorderBackground(palette.Panel) title := lipgloss.NewStyle(). Width(contentWidth). Padding(0, 1). Background(palette.Info). Foreground(palette.Background). Bold(true). Render("PREVIEW " + previewIcon(preview) + " " + preview.Title) parts := []string{title} usedHeight := lipgloss.Height(title) if cfg.Preview.ShowMetadata { metaView := renderMetadata(preview.Metadata, palette, innerWidth, useNerdfont) parts = append(parts, metaView) usedHeight += lipgloss.Height(metaView) } contentHeight := max(innerHeight-usedHeight, 3) viewportModel.Width = max(innerWidth-2, 10) viewportModel.Height = max(contentHeight-3, 1) // Directory previews: borrow the column layout from the browser pane // (renderPaneRows + renderColumnsHeader at the same innerWidth), // but non-interactive (no cursor, no selection). if preview.Kind == vfs.PreviewKindDirectory && len(preview.Entries) > 0 { dirPane := BrowserPane{Entries: preview.Entries} headerRow := renderColumnsHeader(cfg, innerWidth, palette, palette.Panel, useNerdfont) rows := renderPaneRows(dirPane, cfg, palette, innerWidth, contentHeight, false, -1, palette.Panel, useNerdfont) parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, headerRow, rows)) } else { parts = append(parts, renderPreviewContent(viewportModel, palette, innerWidth, contentHeight)) } content := lipgloss.NewStyle(). Width(innerWidth). MaxHeight(innerHeight). Background(palette.Panel). Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) return box.Render(content) } func renderSelectionPane(preview vfs.Preview, viewportModel *viewport.Model, palette theme.Palette, width int, height int) string { content := preview.PlainBody if strings.TrimSpace(content) == "" { content = preview.Body } viewportModel.Width = max(width, 1) viewportModel.Height = max(height, 1) viewportModel.SetContent(content) return lipgloss.NewStyle(). Width(width). Height(height). Background(palette.Panel). Foreground(palette.Text). Render(viewportModel.View()) } func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int, useNerdfont bool) string { outerWidth := max(width-2, 1) innerWidth := max(outerWidth-2, 1) leftRows := []string{ fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")), fmt.Sprintf("size: %s", metaSize(meta)), fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")), } rightRows := []string{ fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")), fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")), } if meta.ImageFormat != "" { rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize)) } if meta.Duration != "" { rightRows = append(rightRows, fmt.Sprintf("duration: %s", meta.Duration)) } if meta.Bitrate != "" { rightRows = append(rightRows, fmt.Sprintf("bitrate: %s", meta.Bitrate)) } if meta.AudioCodec != "" { rightRows = append(rightRows, fmt.Sprintf("audio: %s", meta.AudioCodec)) } if meta.VideoCodec != "" { rightRows = append(rightRows, fmt.Sprintf("video: %s", meta.VideoCodec)) } if meta.Dimensions != "" { rightRows = append(rightRows, fmt.Sprintf("resolution: %s", meta.Dimensions)) } if meta.SampleRate != "" { rightRows = append(rightRows, fmt.Sprintf("rate: %s", meta.SampleRate)) } if meta.Channels != "" { rightRows = append(rightRows, fmt.Sprintf("channels: %s", meta.Channels)) } if meta.PageCount != "" { rightRows = append(rightRows, fmt.Sprintf("pages: %s", meta.PageCount)) } leftWidth := max(innerWidth/2, 18) if leftWidth > innerWidth { leftWidth = innerWidth } rightWidth := max(innerWidth-leftWidth, 0) columnHeight := max(len(leftRows), len(rightRows)) left := lipgloss.NewStyle(). Width(leftWidth). Height(columnHeight). Background(palette.PanelElevated). Foreground(palette.Muted). Render(strings.Join(leftRows, "\n")) right := lipgloss.NewStyle(). Width(rightWidth). Height(columnHeight). Background(palette.PanelElevated). Foreground(palette.Text). Render(strings.Join(rightRows, "\n")) copyIcon := "📋" if useNerdfont { copyIcon = "󰆏" } iconWidth := lipgloss.Width(copyIcon) pathAvailable := max(innerWidth-6-iconWidth-3, 10) // "path: "=6, icon, spacing pathLine := lipgloss.NewStyle(). Width(innerWidth). Background(palette.PanelElevated). Foreground(palette.Text). Render(fmt.Sprintf("path: %s %s", truncateMiddle(meta.Path, pathAvailable), copyIcon)) return lipgloss.NewStyle(). Width(outerWidth). Padding(0, 1). Background(palette.PanelElevated). BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). BorderForeground(palette.Border). BorderBackground(palette.PanelElevated). Render(lipgloss.JoinVertical( lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Top, left, right), "", pathLine, )) } func renderTitleBar(m Model) string { left := lipgloss.NewStyle(). Foreground(m.palette.Background). Background(m.palette.Accent). Bold(true). Padding(0, 1). Render(strings.ToUpper(m.cfg.UI.AppTitle)) centerParts := []string{ fmt.Sprintf("theme:%s", m.cfg.UI.Theme), fmt.Sprintf("hidden:%t", m.cfg.Browser.ShowHidden), fmt.Sprintf("sort:%s", m.cfg.Browser.Sort.By), fmt.Sprintf("info:%t", m.infoMode), } center := lipgloss.NewStyle(). Foreground(m.palette.Text). Background(m.palette.Panel). Padding(0, 1). Render(strings.Join(centerParts, " ")) configLabel := "cfg:default" if m.configPath != "" { configLabel = "cfg:" + filepath.Base(m.configPath) } right := lipgloss.NewStyle(). Foreground(m.palette.Muted). Background(m.palette.Panel). Padding(0, 1). Render(configLabel) fillWidth := max(m.width-lipgloss.Width(left)-lipgloss.Width(center)-lipgloss.Width(right), 0) fill := lipgloss.NewStyle(). Width(fillWidth). Background(m.palette.Panel). Render("") return left + center + fill + right } func renderStatus(m Model) string { active := m.activePane() selected, _ := active.Selected() summary := fmt.Sprintf( "%s | %s | items:%d | selected:%s", strings.ToUpper(string(m.active)), compactPath(active.Path, m.cfg.UI.PathDisplay), len(active.Entries), fallback(selected.DisplayName(), "n/a"), ) return lipgloss.NewStyle(). Width(m.width). Padding(0, 1). Background(m.palette.StatusBar). Foreground(m.palette.Text). Render(summary + " :: " + m.status) } func renderFooter(m Model) string { parts := make([]string, 0, 8) sep := lipgloss.NewStyle().Background(m.palette.Footer).Render(" ") prefix := lipgloss.NewStyle().Background(m.palette.Footer).Render(" ") for _, binding := range m.keys.ShortHelp() { help := binding.Help() if help.Key == "" || help.Desc == "" { continue } keyView := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.FooterKey). Bold(true). Render(help.Key) descView := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.Text). Render(" " + help.Desc) parts = append(parts, keyView+descView) } line := strings.Join(parts, sep) if m.selectMode { modeLabel := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.Info). Bold(true). Render("SELECT TEXT MODE") if line != "" { line += sep } line += modeLabel } if m.visualMode { modeLabel := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.Marked). Bold(true). Render("VISUAL MODE") if line != "" { line += sep } line += modeLabel } line = prefix + line line = ansi.Truncate(line, m.width, "") fill := m.width - ansi.StringWidth(line) if fill > 0 { line += lipgloss.NewStyle(). Background(m.palette.Footer). Render(strings.Repeat(" ", fill)) } return line } func renderFilterBar(m Model) string { prompt := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.FooterKey). Bold(true). Render(" / ") inputView := m.filterInput.View() inputStyle := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.Text) line := prompt + inputStyle.Render(inputView) filtered := m.filteredCount(m.activePane()) if m.filterQuery != "" { countStyle := lipgloss.NewStyle(). Background(m.palette.Footer). Foreground(m.palette.Muted) line += countStyle.Render(fmt.Sprintf(" (%d)", filtered)) } line = ansi.Truncate(line, m.width, "") fill := m.width - ansi.StringWidth(line) if fill > 0 { line += lipgloss.NewStyle(). Background(m.palette.Footer). Render(strings.Repeat(" ", fill)) } return line } func renderModal(m Model, palette theme.Palette, width int) string { if m.modal.kind == modalCopyProgress && m.copyJob != nil { return renderCopyProgressModal(*m.copyJob, palette, width) } if m.modal.kind == modalArchiveProgress && m.archiveJob != nil { return renderArchiveProgressModal(*m.archiveJob, palette, width) } if m.modal.kind == modalHelp { return renderHelpModal(m.modal, palette, width) } modal := m.modal outerWidth := max(width, 8) contentWidth := max(outerWidth-6, 1) titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent) noteStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted) spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ") box := lipgloss.NewStyle(). Width(contentWidth). Padding(1, 2). Background(palette.Panel). Foreground(palette.Text). BorderStyle(lipgloss.DoubleBorder()). BorderForeground(palette.BorderActive). BorderBackground(palette.Panel) lines := []string{titleStyle.Render(modal.title), spacer} for _, raw := range strings.Split(modal.body, "\n") { lines = append(lines, renderModalBodyLine(raw, contentWidth, palette)) } if modal.kind == modalMkdir || modal.kind == modalRename { lines = append(lines, spacer, lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(modal.input.View())) } if modal.note != "" { lines = append(lines, spacer) if modal.note == "confirm-actions" { lines = append(lines, renderModalNoteLine("Enter to confirm, Esc to cancel", contentWidth, palette, noteStyle)) } else { for _, raw := range strings.Split(modal.note, "\n") { lines = append(lines, renderModalNoteLine(raw, contentWidth, palette, noteStyle)) } } } return box.Render(strings.Join(lines, "\n")) } func renderModalBodyLine(raw string, width int, palette theme.Palette) string { base := lipgloss.NewStyle(). Width(width). Background(palette.Panel). Foreground(palette.Text) if strings.TrimSpace(raw) == "" { return base.Render("") } if idx := strings.Index(raw, ":"); idx > 0 { keyText := strings.TrimSpace(raw[:idx+1]) valueText := strings.TrimLeft(raw[idx+1:], " ") keyWidth := min(max(idx+2, 8), width) valueWidth := max(width-keyWidth, 0) keyStyle := lipgloss.NewStyle(). Width(keyWidth). Background(palette.Panel). Foreground(palette.FooterKey). Bold(true) valueStyle := lipgloss.NewStyle(). Width(valueWidth). Background(palette.Panel). Foreground(palette.Text) return base.Render(keyStyle.Render(keyText) + valueStyle.Render(valueText)) } if strings.HasPrefix(strings.TrimSpace(raw), "Existing targets") { return lipgloss.NewStyle(). Width(width). Background(palette.Panel). Foreground(palette.Warning). Render(strings.TrimSpace(raw)) } return base.Render(raw) } func renderModalNoteLine(raw string, width int, palette theme.Palette, fallback lipgloss.Style) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return fallback.Render("") } if highlighted, ok := renderModalHintTokens(raw, width, palette, palette.Muted); ok { return highlighted } for _, sep := range []string{" to ", " (", ","} { if idx := strings.Index(raw, sep); idx > 0 { keyLabel := strings.TrimSpace(raw[:idx]) action := strings.TrimLeft(raw[idx:], " ") keyWidth := min(max(lipgloss.Width(keyLabel)+2, 10), width) actionWidth := max(width-keyWidth, 0) keyStyle := lipgloss.NewStyle(). Width(keyWidth). Background(palette.Panel). Foreground(palette.FooterKey). Bold(true) actionStyle := lipgloss.NewStyle(). Width(actionWidth). Background(palette.Panel). Foreground(palette.Muted) return keyStyle.Render(keyLabel) + actionStyle.Render(action) } } return fallback.Render(raw) } func renderModalHintTokens(raw string, width int, palette theme.Palette, baseColor lipgloss.Color) (string, bool) { type tokenStyle struct { token string color lipgloss.Color } tokens := []tokenStyle{ {token: "Background", color: palette.Info}, {token: "Cancel", color: palette.CancelButton}, {token: "Enter", color: palette.ConfirmButton}, {token: "Esc", color: palette.CancelButton}, } contains := false for _, entry := range tokens { if strings.Contains(raw, entry.token) { contains = true break } } if !contains { return "", false } var line strings.Builder rest := raw for len(rest) > 0 { nextIdx := -1 nextToken := "" nextColor := baseColor for _, entry := range tokens { idx := strings.Index(rest, entry.token) if idx >= 0 && (nextIdx == -1 || idx < nextIdx) { nextIdx = idx nextToken = entry.token nextColor = entry.color } } if nextIdx == -1 { line.WriteString(lipgloss.NewStyle(). Background(palette.Panel). Foreground(baseColor). Render(rest)) break } if nextIdx > 0 { line.WriteString(lipgloss.NewStyle(). Background(palette.Panel). Foreground(baseColor). Render(rest[:nextIdx])) } line.WriteString(lipgloss.NewStyle(). Background(palette.Panel). Foreground(nextColor). Bold(true). Render(nextToken)) rest = rest[nextIdx+len(nextToken):] } return lipgloss.NewStyle(). Width(width). Background(palette.Panel). Render(line.String()), true } func renderConfirmActions(width int, palette theme.Palette) string { const minButtonWidth = 10 const maxButtonWidth = 14 const gapWidth = 4 labelWidth := max(lipgloss.Width("Enter / y"), lipgloss.Width("Esc / n")) buttonWidth := min(max(labelWidth+2, minButtonWidth), maxButtonWidth) buttonWidth = min(buttonWidth, max((width-gapWidth)/2, labelWidth)) confirm := lipgloss.NewStyle(). Width(buttonWidth). Align(lipgloss.Center). Background(palette.ConfirmButton). Foreground(palette.Background). Bold(true). Render("Enter / y") cancel := lipgloss.NewStyle(). Width(buttonWidth). Align(lipgloss.Center). Background(palette.CancelButton). Foreground(palette.Background). Bold(true). Render("Esc / n") gap := lipgloss.NewStyle(). Width(gapWidth). Background(palette.Panel). Render("") enterBias := lipgloss.NewStyle(). Width(9). Background(palette.Panel). Render("") cancelTail := lipgloss.NewStyle(). Width(5). Background(palette.Panel). Render("") group := lipgloss.JoinHorizontal(lipgloss.Top, confirm, enterBias, gap, cancel, cancelTail) row := lipgloss.PlaceHorizontal( width, lipgloss.Center, group, lipgloss.WithWhitespaceBackground(palette.Panel), ) return lipgloss.NewStyle(). Width(width). Background(palette.Panel). Render(row) } func renderHelpModal(modal modalState, palette theme.Palette, width int) string { outerWidth := max(width, 8) contentWidth := max(outerWidth-6, 1) titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent) lineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Text) mutedLineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted) noteStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.FooterKey).Bold(true) spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ") keyColStyle := lipgloss.NewStyle().Width(24).Background(palette.Panel).Foreground(palette.FooterKey).Bold(true) descColStyle := lipgloss.NewStyle().Background(palette.Panel).Foreground(palette.Text) box := lipgloss.NewStyle(). Width(contentWidth). Padding(1, 2). Background(palette.Panel). Foreground(palette.Text). BorderStyle(lipgloss.DoubleBorder()). BorderForeground(palette.BorderActive). BorderBackground(palette.Panel) lines := []string{titleStyle.Render(modal.title), spacer} for _, raw := range strings.Split(modal.body, "\n") { trimmed := strings.TrimSpace(raw) if trimmed == "" { lines = append(lines, spacer) continue } if strings.HasPrefix(raw, " ") { keyLabel, action := splitHelpItem(raw) if action == "" { lines = append(lines, lineStyle.Render(trimmed)) continue } row := keyColStyle.Render(keyLabel) + descColStyle.Render(action) lines = append(lines, lineStyle.Render(row)) continue } if strings.HasSuffix(trimmed, ".") { lines = append(lines, mutedLineStyle.Render(trimmed)) continue } sectionColor := sectionColorForHeader(trimmed, palette) header := lipgloss.NewStyle(). Width(contentWidth). Background(palette.Panel). Foreground(sectionColor). Bold(true). Render(trimmed) lines = append(lines, header) } if modal.note != "" { lines = append(lines, spacer) if highlighted, ok := renderModalHintTokens(modal.note, contentWidth, palette, palette.FooterKey); ok { lines = append(lines, highlighted) } else { lines = append(lines, noteStyle.Render(modal.note)) } } return box.Render(strings.Join(lines, "\n")) } func splitHelpItem(raw string) (string, string) { value := strings.TrimSpace(raw) for idx := 0; idx < len(value)-1; idx++ { if value[idx] == ' ' && value[idx+1] == ' ' { keyLabel := strings.TrimSpace(value[:idx]) action := strings.TrimSpace(value[idx:]) if keyLabel != "" && action != "" { return keyLabel, action } } } return value, "" } func sectionColorForHeader(header string, palette theme.Palette) lipgloss.Color { switch header { case "Navigation": return palette.HelpNav case "View and Panels": return palette.HelpPanels case "Dialogs and Transfers": return palette.HelpDialogs case "Mouse": return palette.HelpMouse default: return palette.Accent } } func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) string { outerWidth := max(width, 8) contentWidth := max(outerWidth-6, 1) titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent) mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted) spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ") box := lipgloss.NewStyle(). Width(contentWidth). Padding(1, 2). Background(palette.Panel). Foreground(palette.Text). BorderStyle(lipgloss.DoubleBorder()). BorderForeground(palette.BorderActive). BorderBackground(palette.Panel) progress := job.progress ratio := 0.0 if progress.BytesTotal > 0 { ratio = float64(progress.BytesDone) / float64(progress.BytesTotal) } lines := []string{ titleStyle.Render(progressTitle(job.kind)), spacer, renderProgressBarLine(ratio, contentWidth, palette), spacer, renderProgressPercentLine(ratio, contentWidth, palette), renderProgressStatLine("Stage:", progressStageLabel(progress, job.kind), contentWidth, palette), spacer, renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette), renderProgressStatLine("Size:", fmt.Sprintf("%s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true)), contentWidth, palette), renderProgressStatLine("Speed:", transferSpeed(progress.BytesDone, job.startedAt), contentWidth, palette), spacer, renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle), } if job.background { lines = append(lines, mutedStyle.Render("Transfer continues in background")) } return box.Render(strings.Join(lines, "\n")) } func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, width int) string { outerWidth := max(width, 8) contentWidth := max(outerWidth-6, 1) titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent) mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted) spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ") box := lipgloss.NewStyle(). Width(contentWidth). Padding(1, 2). Background(palette.Panel). Foreground(palette.Text). BorderStyle(lipgloss.DoubleBorder()). BorderForeground(palette.BorderActive). BorderBackground(palette.Panel) progress := job.progress ratio := 0.0 if progress.BytesTotal > 0 { ratio = float64(progress.BytesDone) / float64(progress.BytesTotal) } stage := progress.Stage if stage == "" { stage = "Archiving data" } lines := []string{ titleStyle.Render("Archiving"), spacer, renderProgressBarLine(ratio, contentWidth, palette), spacer, renderProgressPercentLine(ratio, contentWidth, palette), renderProgressStatLine("Stage:", stage, contentWidth, palette), spacer, renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette), renderProgressStatLine("Size:", fmt.Sprintf("%s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true)), contentWidth, palette), renderProgressStatLine("Speed:", transferSpeed(progress.BytesDone, job.startedAt), contentWidth, palette), spacer, renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle), } if job.background { lines = append(lines, mutedStyle.Render("Archive continues in background")) } return box.Render(strings.Join(lines, "\n")) } func renderProgressBar(ratio float64, width int, palette theme.Palette) string { if width < 10 { width = 10 } if ratio < 0 { ratio = 0 } if ratio > 1 { ratio = 1 } filled := int(float64(width) * ratio) if filled > width { filled = width } if filled < 0 { filled = 0 } bar := lipgloss.NewStyle().Foreground(palette.ProgressFill).Render(strings.Repeat("█", filled)) rest := lipgloss.NewStyle().Foreground(palette.ProgressEmpty).Render(strings.Repeat("░", width-filled)) return bar + rest } func renderProgressBarLine(ratio float64, width int, palette theme.Palette) string { sidePad := max(width/8, 6) barWidth := max(width-(sidePad*2), 10) rightPad := max(width-sidePad-barWidth, 0) left := lipgloss.NewStyle(). Width(sidePad). Background(palette.Panel). Render("") bar := lipgloss.NewStyle(). Width(barWidth). Background(palette.Panel). Render(renderProgressBar(ratio, barWidth, palette)) right := lipgloss.NewStyle(). Width(rightPad). Background(palette.Panel). Render("") return lipgloss.NewStyle(). Width(width). Background(palette.Panel). Render(left + bar + right) } func renderProgressStatLine(label string, value string, width int, palette theme.Palette) string { keyWidth := min(max(lipgloss.Width(label)+1, 8), width) valueWidth := max(width-keyWidth, 0) keyStyle := lipgloss.NewStyle(). Width(keyWidth). Background(palette.Panel). Foreground(palette.FooterKey). Bold(true) valueStyle := lipgloss.NewStyle(). Width(valueWidth). Background(palette.Panel). Foreground(palette.Text) return keyStyle.Render(label) + valueStyle.Render(value) } func renderProgressActions(width int, palette theme.Palette) string { const minButtonWidth = 10 const maxButtonWidth = 14 const gapWidth = 4 labelWidth := max(lipgloss.Width("Background / b"), lipgloss.Width("Cancel / c")) buttonWidth := min(max(labelWidth+2, minButtonWidth), maxButtonWidth) buttonWidth = min(buttonWidth, max((width-gapWidth)/2, labelWidth)) backgroundBtn := lipgloss.NewStyle(). Width(buttonWidth). Align(lipgloss.Center). Background(palette.Info). Foreground(palette.Background). Bold(true). Render("Background / b") cancelBtn := lipgloss.NewStyle(). Width(buttonWidth). Align(lipgloss.Center). Background(palette.CancelButton). Foreground(palette.Background). Bold(true). Render("Cancel / c") gap := lipgloss.NewStyle(). Width(gapWidth). Background(palette.Panel). Render("") enterBias := lipgloss.NewStyle(). Width(9). Background(palette.Panel). Render("") cancelTail := lipgloss.NewStyle(). Width(5). Background(palette.Panel). Render("") group := lipgloss.JoinHorizontal(lipgloss.Top, backgroundBtn, enterBias, gap, cancelBtn, cancelTail) row := lipgloss.PlaceHorizontal( width, lipgloss.Center, group, lipgloss.WithWhitespaceBackground(palette.Panel), ) return lipgloss.NewStyle(). Width(width). Background(palette.Panel). Render(row) } func renderProgressPercentLine(ratio float64, width int, palette theme.Palette) string { percent := lipgloss.NewStyle(). Width(width). Background(palette.Panel). Foreground(palette.Info). Bold(true). Render(fmt.Sprintf("%.0f%%", ratio*100)) return percent } func transferSpeed(bytesDone int64, startedAt time.Time) string { if startedAt.IsZero() || bytesDone <= 0 { return "calculating..." } elapsed := time.Since(startedAt) if elapsed <= 0 { return "calculating..." } perSecond := int64(float64(bytesDone) / elapsed.Seconds()) if perSecond <= 0 { return "calculating..." } return fmt.Sprintf("%s/s", vfs.HumanSize(perSecond)) } func progressStageLabel(progress vfs.CopyProgress, kind fileOpKind) string { if strings.TrimSpace(progress.Stage) != "" { if progress.Stage == "Transferring data" && progress.BytesTotal > 0 && progress.BytesDone >= progress.BytesTotal { if progress.FilesDone < progress.FilesTotal { return "Finalizing file" } if kind == opMove { return "Preparing move finalization" } return "Finalizing transfer" } return progress.Stage } if kind == opMove { return "Preparing move" } return "Transferring data" } func overlayCenter(base string, overlay string, width int) string { if width <= 0 { return base } baseLines := strings.Split(base, "\n") overlayLines := strings.Split(overlay, "\n") if len(baseLines) == 0 || len(overlayLines) == 0 { return base } overlayWidth := 0 for _, line := range overlayLines { overlayWidth = max(overlayWidth, ansi.StringWidth(line)) } startY := max((len(baseLines)-len(overlayLines))/2, 0) startX := max((width-overlayWidth)/2, 0) endX := startX + overlayWidth for idx, line := range overlayLines { targetY := startY + idx if targetY >= len(baseLines) { break } baseLine := baseLines[targetY] left := ansi.Cut(baseLine, 0, startX) right := ansi.Cut(baseLine, endX, width) baseLines[targetY] = left + line + right } return strings.Join(baseLines, "\n") } func renderPreviewContent(viewportModel *viewport.Model, palette theme.Palette, width int, height int) string { outerWidth := max(width-2, 1) innerWidth := max(outerWidth-2, 1) innerHeight := max(height, 1) body := lipgloss.NewStyle(). Width(innerWidth). Height(max(innerHeight, 1)). Padding(0, 1). Background(palette.Panel). Render(viewportModel.View()) return lipgloss.NewStyle(). Width(outerWidth). Height(innerHeight). Background(palette.Panel). BorderStyle(lipgloss.NormalBorder()). BorderTop(true). BorderForeground(palette.Border). BorderBackground(palette.Panel). Render(body) } func previewIcon(preview vfs.Preview) string { switch preview.Kind { case vfs.PreviewKindDirectory: return "" case vfs.PreviewKindImage: return "󰋩" case vfs.PreviewKindText: return "󰈙" case vfs.PreviewKindPDF: return "󰷉" case vfs.PreviewKindAudio: return "󰋋" case vfs.PreviewKindVideo: return "󰋲" case vfs.PreviewKindBinary: return "󰈔" case vfs.PreviewKindError: return "" default: return "󰇙" } } func (p pendingOperation) cmd() tea.Cmd { switch p.kind { case opMove: return nil case opDelete: return trashPathsCmd(p.sourcePaths) case opPermanentDelete: return deletePathsPermanentCmd(p.sourcePaths) default: return nil } } func operationCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd { switch kind { case opMove: return moveCmd(sourcePath, targetDir, overwrite) default: return nil } } func dirSizeCmd(path string) tea.Cmd { return func() tea.Msg { size, err := vfs.DirectorySize(path) return dirSizeMsg{path: path, size: size, err: err} } } func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, existingTargets int) tea.Cmd { return func() tea.Msg { stats := vfs.TransferStats{} var err error for _, sourcePath := range sourcePaths { part, statErr := vfs.CopyStats(sourcePath) if statErr != nil { err = statErr break } stats.FilesTotal += part.FilesTotal stats.BytesTotal += part.BytesTotal } return copyPlanMsg{ kind: kind, sourcePaths: append([]string(nil), sourcePaths...), targetDir: targetDir, overwrite: overwrite, existingTargets: existingTargets, stats: stats, err: err, } } } func (m *Model) enterArchive(selected vfs.Entry) error { pane := m.activePane() // Save current path to history before opening the archive. pane.PushHistory(pane.Path) tempDir, err := vfs.ExtractArchiveToTemp(selected.Path) if err != nil { return err } pane.PushArchive(ArchiveMount{ SourcePath: selected.Path, ParentPath: pane.Path, RootPath: tempDir, TempDir: tempDir, }) pane.Path = tempDir pane.Cursor = 0 pane.Offset = 0 if err := m.reloadPane(pane.ID, ""); err != nil { _ = os.RemoveAll(tempDir) _, _ = pane.PopArchive() return err } m.status = fmt.Sprintf("Opened archive %s", selected.DisplayName()) return nil } func (m *Model) cleanupArchiveMounts() { for _, pane := range []*BrowserPane{&m.left, &m.right} { for _, mount := range pane.ClearArchives() { _ = os.RemoveAll(mount.TempDir) } } } func archivePlanCmd(sourcePaths []string, targetDir string) tea.Cmd { return func() tea.Msg { stats := vfs.TransferStats{} var err error for _, sourcePath := range sourcePaths { part, statErr := vfs.CopyStats(sourcePath) if statErr != nil { err = statErr break } stats.FilesTotal += part.FilesTotal stats.BytesTotal += part.BytesTotal } return archivePlanMsg{ sourcePaths: append([]string(nil), sourcePaths...), targetDir: targetDir, stats: stats, err: err, } } } func waitCopyProgressCmd(ch <-chan tea.Msg) tea.Cmd { return func() tea.Msg { return <-ch } } func waitArchiveProgressCmd(ch <-chan tea.Msg) tea.Cmd { return func() tea.Msg { return <-ch } } func dismissNoticeCmd(delay time.Duration) tea.Cmd { return tea.Tick(delay, func(time.Time) tea.Msg { return dismissNoticeMsg{} }) } func dismissYankFlashCmd(delay time.Duration) tea.Cmd { return tea.Tick(delay, func(time.Time) tea.Msg { return dismissYankFlashMsg{} }) } func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd { m.nextCopyJob++ jobID := m.nextCopyJob ctx, cancel := context.WithCancel(context.Background()) m.copyJob = ©JobState{ id: jobID, kind: kind, sourcePaths: append([]string(nil), sourcePaths...), targetDir: targetDir, overwrite: overwrite, progress: vfs.CopyProgress{ FilesDone: 0, FilesTotal: stats.FilesTotal, BytesDone: 0, BytesTotal: stats.BytesTotal, CurrentPath: sourcePaths[0], }, cancel: cancel, startedAt: time.Now(), } m.modal = modalState{kind: modalCopyProgress} m.status = strings.Title(operationVerb(kind)) + " started" return tea.Batch( func() tea.Msg { go func() { doneFiles := 0 var doneBytes int64 for _, sourcePath := range sourcePaths { entryStats, statErr := vfs.CopyStats(sourcePath) if statErr != nil { m.copyProgress <- copyDoneMsg{ jobID: jobID, kind: kind, sourcePaths: append([]string(nil), sourcePaths...), targetDir: targetDir, err: statErr, } return } progressFn := func(progress vfs.CopyProgress) { m.copyProgress <- copyProgressMsg{ jobID: jobID, progress: vfs.CopyProgress{ FilesDone: doneFiles + progress.FilesDone, FilesTotal: stats.FilesTotal, BytesDone: doneBytes + progress.BytesDone, BytesTotal: stats.BytesTotal, CurrentPath: progress.CurrentPath, Stage: progress.Stage, }, } } switch kind { case opMove: _, statErr = vfs.MovePathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn) default: _, statErr = vfs.CopyPathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn) } if statErr != nil { m.copyProgress <- copyDoneMsg{ jobID: jobID, kind: kind, sourcePaths: append([]string(nil), sourcePaths...), targetDir: targetDir, err: statErr, } return } doneFiles += entryStats.FilesTotal doneBytes += entryStats.BytesTotal } m.copyProgress <- copyDoneMsg{ jobID: jobID, kind: kind, sourcePaths: append([]string(nil), sourcePaths...), targetDir: targetDir, } }() return nil }, waitCopyProgressCmd(m.copyProgress), ) } func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format string, stats vfs.TransferStats) tea.Cmd { m.nextArchiveJob++ jobID := m.nextArchiveJob ctx, cancel := context.WithCancel(context.Background()) archiveName := vfs.ArchiveName(sourcePaths, format) archivePath := filepath.Join(targetDir, archiveName) m.archiveJob = &archiveJobState{ id: jobID, sourcePaths: append([]string(nil), sourcePaths...), targetPath: archivePath, progress: vfs.CopyProgress{ FilesDone: 0, FilesTotal: stats.FilesTotal, BytesDone: 0, BytesTotal: stats.BytesTotal, CurrentPath: sourcePaths[0], }, cancel: cancel, startedAt: time.Now(), } m.modal = modalState{kind: modalArchiveProgress} m.status = "Archiving started" return tea.Batch( func() tea.Msg { go func() { emitProgress := func(p vfs.CopyProgress) { m.archiveProgress <- archiveProgressMsg{ jobID: jobID, progress: p, } } err := vfs.CreateArchive(ctx, sourcePaths, archivePath, emitProgress) if err != nil { m.archiveProgress <- archiveDoneMsg{ jobID: jobID, sourcePaths: append([]string(nil), sourcePaths...), targetPath: archivePath, err: err, } return } m.archiveProgress <- archiveDoneMsg{ jobID: jobID, sourcePaths: append([]string(nil), sourcePaths...), targetPath: archivePath, } }() return nil }, waitArchiveProgressCmd(m.archiveProgress), ) } func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd { return func() tea.Msg { targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite) return opMsg{kind: opMove, sourcePath: sourcePath, targetPath: targetPath, err: err} } } func trashPathsCmd(paths []string) tea.Cmd { return func() tea.Msg { for _, path := range paths { if err := vfs.MoveToTrash(path); err != nil { return opMsg{kind: opDelete, sourcePath: path, err: err} } } return opMsg{kind: opDelete} } } func deletePathsPermanentCmd(paths []string) tea.Cmd { return func() tea.Msg { for _, path := range paths { if err := vfs.DeletePath(path); err != nil { return opMsg{kind: opPermanentDelete, sourcePath: path, err: err} } } return opMsg{kind: opPermanentDelete} } } func trashPlanCmd(sourcePaths []string) tea.Cmd { return func() tea.Msg { stats := vfs.TransferStats{} var err error for _, sourcePath := range sourcePaths { part, statErr := vfs.CopyStats(sourcePath) if statErr != nil { err = statErr break } stats.FilesTotal += part.FilesTotal stats.BytesTotal += part.BytesTotal } return deletePlanMsg{ kind: opDelete, sourcePaths: append([]string(nil), sourcePaths...), stats: stats, err: err, } } } func deletePlanPermanentCmd(sourcePaths []string) tea.Cmd { return func() tea.Msg { stats := vfs.TransferStats{} var err error for _, sourcePath := range sourcePaths { part, statErr := vfs.CopyStats(sourcePath) if statErr != nil { err = statErr break } stats.FilesTotal += part.FilesTotal stats.BytesTotal += part.BytesTotal } return deletePlanMsg{ kind: opPermanentDelete, sourcePaths: append([]string(nil), sourcePaths...), stats: stats, err: err, } } } func mkdirCmd(parent, name string) tea.Cmd { return func() tea.Msg { targetPath, err := vfs.MakeDir(parent, name) return opMsg{kind: opMkdir, targetPath: targetPath, err: err} } } func renameCmd(sourcePath, newName string) tea.Cmd { return func() tea.Msg { targetPath, err := vfs.RenamePath(sourcePath, newName) return opMsg{kind: opRename, sourcePath: sourcePath, targetPath: targetPath, err: err} } } func selectedName(pane *BrowserPane) string { selected, ok := pane.Selected() if !ok { return "" } return selected.Name } func metaSize(meta vfs.Metadata) string { if !meta.SizeKnown { return "press Space" } return vfs.HumanSize(meta.Size) } func fallback(value string, defaultValue string) string { if strings.TrimSpace(value) == "" { return defaultValue } return value } func formatSize(size int64, human bool) string { if human { return vfs.HumanSize(size) } return fmt.Sprintf("%d", size) } func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string { return fmt.Sprintf( "%s in background: %d/%d files, %s/%s", strings.Title(operationVerb(kind)), progress.FilesDone, progress.FilesTotal, formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true), ) } func formatArchiveStatus(progress vfs.CopyProgress) string { return fmt.Sprintf( "Archiving in background: %d/%d files, %s/%s", progress.FilesDone, progress.FilesTotal, formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true), ) } func transferSourceLabel(paths []string) string { if len(paths) == 0 { return "n/a" } if len(paths) == 1 { return paths[0] } return fmt.Sprintf("%d selected entries", len(paths)) } func pluralSuffix(count int, singular string, plural string) string { if count == 1 { return singular } return plural } func progressTitle(kind fileOpKind) string { switch kind { case opMove: return "Moving" case opArchive: return "Archiving" default: return "Copying" } } func operationDoneLabel(kind fileOpKind) string { switch kind { case opMove: return "Moved" case opCopy: return "Copied" case opDelete: return "Moved to trash" case opPermanentDelete: return "Permanently deleted" case opArchive: return "Archived" default: return "Done" } } func operationVerb(kind fileOpKind) string { switch kind { case opCopy: return "copy" case opMove: return "move" case opDelete: return "trash" case opPermanentDelete: return "permanent delete" case opArchive: return "archive" default: return "operate on" } } func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) { envVars := []string{} if envVar != "" { envVars = append(envVars, envVar) } return externalCommandFromEnv(envVars, fallbacks, path) } func externalCommandFromEnv(envVars []string, fallbacks []string, path string) (*exec.Cmd, string, error) { commandLine := "" source := "fallbacks" for _, envVar := range envVars { commandLine = strings.TrimSpace(os.Getenv(envVar)) if commandLine != "" { source = envVar break } } if commandLine == "" { for _, candidate := range fallbacks { if resolved, err := exec.LookPath(candidate); err == nil { commandLine = resolved source = candidate break } } } if commandLine == "" { if len(envVars) > 0 { return nil, "", fmt.Errorf("no command for %s", strings.Join(envVars, "/")) } return nil, "", fmt.Errorf("no fallback command found") } parts := strings.Fields(commandLine) if len(parts) == 0 { return nil, "", fmt.Errorf("invalid command for %s", source) } args := append(parts[1:], path) return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil } func startExternalOpenCmd(command *exec.Cmd, path string) tea.Cmd { return func() tea.Msg { command.Stdin = nil command.Stdout = io.Discard command.Stderr = io.Discard if err := command.Start(); err != nil { return externalOpenMsg{path: path, err: err} } return externalOpenMsg{path: path} } } func enableMouseCmd() tea.Cmd { return func() tea.Msg { return tea.EnableMouseCellMotion() } } func disableMouseCmd() tea.Cmd { return func() tea.Msg { return tea.DisableMouse() } } func resolveStartPath(raw string, fallback string) (string, error) { value := strings.TrimSpace(raw) if value == "" { return fallback, nil } if strings.HasPrefix(value, "~/") { home, err := os.UserHomeDir() if err != nil { return "", err } value = filepath.Join(home, strings.TrimPrefix(value, "~/")) } abs, err := filepath.Abs(value) if err != nil { return "", err } info, err := os.Stat(abs) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("startup path is not a directory: %s", abs) } return abs, nil } func min(a, b int) int { if a < b { return a } return b } func clamp(n, low, high int) int { if n < low { return low } if n > high { return high } return n } func max(a, b int) int { if a > b { return a } return b } func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) { if m.width <= 0 || m.height <= 0 { return "", 0, false } leftWidth, previewWidth, rightWidth := m.layoutWidths() top := 0 bodyHeight := m.bodyHeight() if y < top || y >= top+bodyHeight { return "", 0, false } gap := m.cfg.UI.PaneGap leftStart := 0 rightStart := leftWidth + gap if m.infoMode && m.active == PaneRight { rightStart = previewWidth + gap } switch { case x >= leftStart && x < leftStart+leftWidth: if m.infoMode && m.active == PaneRight { return "", 0, false } index, ok := paneIndexFromMouse(y-top, bodyHeight, &m.left) if !ok { return "", 0, false } return PaneLeft, index, true case x >= rightStart && x < rightStart+rightWidth: if m.infoMode && m.active == PaneLeft { return "", 0, false } index, ok := paneIndexFromMouse(y-top, bodyHeight, &m.right) if !ok { return "", 0, false } return PaneRight, index, true default: return "", 0, false } } func paneIndexFromMouse(localY int, height int, pane *BrowserPane) (int, bool) { const listStartY = 3 if localY < listStartY || localY >= height-1 { return 0, false } row := localY - listStartY index := pane.Offset + row if index < 0 { index = 0 } if index >= len(pane.Entries) { return 0, false } return index, true } func isEditableEntry(entry vfs.Entry) bool { switch entry.Category() { case "text", "config": return true default: return false } } func isArchiveEntry(entry vfs.Entry) bool { return !entry.IsDir && !entry.IsParent && entry.Category() == "archive" } func (m Model) syncImageOverlay(leftWidth int, previewWidth int, bodyHeight int) { if m.overlay == nil { return } if m.modal.kind != modalNone { m.overlay.hide() return } if m.previewData.Kind != vfs.PreviewKindImage { m.overlay.hide() return } imagePath := strings.TrimSpace(m.previewData.Metadata.Path) if imagePath == "" { m.overlay.hide() return } rect := overlayRect{} if m.viewMode { rect = overlayRect{ x: 1, y: 1, width: max(m.width-2, 1), height: max(bodyHeight-2, 1), } } else if m.infoMode { startX := 0 if m.active == PaneLeft { startX = leftWidth + m.cfg.UI.PaneGap } innerWidth := max(previewWidth-2, 1) metaHeight := 0 if m.cfg.Preview.ShowMetadata { metaHeight = lipgloss.Height(renderMetadata(m.previewData.Metadata, m.palette, innerWidth, m.nerdIcons)) } titleHeight := 1 topInset := 1 contentBorder := 1 safetyGap := 1 contentTop := topInset + titleHeight + metaHeight + contentBorder + safetyGap rect = overlayRect{ x: startX + 3, y: contentTop, width: max(previewWidth-6, 1), height: max(bodyHeight-contentTop-2, 1), } } else { m.overlay.hide() return } if err := m.overlay.show(imagePath, rect); err != nil { m.overlay.hide() } } func (m *Model) cleanupImageOverlay() { if m.overlay == nil { return } m.overlay.stop() } func (m *Model) hoverIndexFor(pane PaneID) int { if m.hover.ok && m.hover.pane == pane { return m.hover.index } return -1 } func (m *Model) mouseOverPreview(x, y int) bool { if !m.infoMode || m.width <= 0 || m.height <= 0 { return false } leftWidth, previewWidth, _ := m.layoutWidths() top := 0 bodyHeight := m.bodyHeight() if y < top || y >= top+bodyHeight { return false } gap := m.cfg.UI.PaneGap if m.active == PaneLeft { startX := leftWidth + gap return x >= startX && x < startX+previewWidth } return x >= 0 && x < previewWidth } func (m *Model) mouseOverPathLine(x, y int) bool { if !m.infoMode || !m.cfg.Preview.ShowMetadata || m.width <= 0 || m.height <= 0 { return false } leftWidth, previewWidth, _ := m.layoutWidths() bodyHeight := m.bodyHeight() if y < 0 || y >= bodyHeight { return false } // Preview pane x-range var startX int if m.active == PaneLeft { startX = leftWidth + m.cfg.UI.PaneGap } else { startX = 0 } if x < startX || x >= startX+previewWidth { return false } // The path line is within the metadata section (approximate Y range 1-7). // Check that Y is in the metadata area and X is in the right half where the icon is. if y < 1 || y > 7 { return false } // The icon is at the far-right end of the preview pane content area iconStartX := startX + previewWidth/2 return x >= iconStartX }