vcom/internal/ui/model.go
2026-04-23 21:18:15 +03:00

2300 lines
58 KiB
Go

package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"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"
)
type modalKind int
const (
modalNone modalKind = iota
modalMkdir
modalConfirm
modalCopyProgress
modalNotice
modalHelp
)
type fileOpKind int
const (
opCopy fileOpKind = iota
opMove
opDelete
opMkdir
opEdit
opView
)
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 {
sourcePaths []string
stats vfs.TransferStats
err error
}
type copyDoneMsg struct {
jobID int
kind fileOpKind
sourcePaths []string
targetDir string
err error
}
type dismissNoticeMsg struct{}
type copyJobState struct {
id int
kind fileOpKind
sourcePaths []string
targetDir string
progress vfs.CopyProgress
overwrite bool
background bool
}
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
width int
height int
left BrowserPane
right BrowserPane
active PaneID
infoMode bool
selectMode bool
previewModel viewport.Model
previewData vfs.Preview
modal modalState
status string
busy bool
lastClick mouseClickState
hover hoverState
copyJob *copyJobState
nextCopyJob int
copyProgress chan tea.Msg
}
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(),
left: BrowserPane{ID: PaneLeft, Path: leftPath},
right: BrowserPane{ID: PaneRight, Path: rightPath},
active: PaneLeft,
status: "Ready",
copyProgress: make(chan tea.Msg, 256),
}
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 && msg.preview.Kind != vfs.PreviewKindText {
m.selectMode = false
return m, enableMouseCmd()
}
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 = "Deleted"
m.activePane().ClearMarks()
case opMkdir:
m.status = fmt.Sprintf("Created %s", msg.targetPath)
case opEdit:
m.status = "Editor closed"
return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
case opView:
m.status = "Viewer closed"
return m, enableMouseCmd()
}
activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection)
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 := "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: opDelete,
sourcePaths: append([]string(nil), msg.sourcePaths...),
stats: msg.stats,
},
)
return m, nil
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 {
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, nil
}
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 tea.KeyMsg:
if m.modal.kind != modalNone {
return m.handleModalKey(msg)
}
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.openHelpModal()
return m, nil
case key.Matches(msg, m.keys.Cancel):
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.Edit):
return m.handleEdit()
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.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 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("")
var panels string
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(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft)),
gap,
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
)
} else {
panels = lipgloss.JoinHorizontal(
lipgloss.Top,
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
gap,
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight)),
)
}
} else {
panels = lipgloss.JoinHorizontal(
lipgloss.Top,
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft)),
gap,
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight)),
)
}
parts := make([]string, 0, 3)
parts = append(parts, panels)
if m.cfg.UI.ShowFooter {
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 {
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
}
func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.modal.kind {
case modalMkdir:
switch {
case isModalCloseKey(msg, m.keys):
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 == "" {
m.status = "Directory name must not be empty"
return m, nil
}
m.busy = true
return m, mkdirCmd(m.activePane().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 modalCopyProgress:
if isModalCloseKey(msg, m.keys) {
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.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
}
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) {
pane := m.activePane()
pane.Move(delta, max(m.bodyHeight()-4, 1))
m.hover = hoverState{}
}
func (m *Model) selectMoveCursor(delta int) {
pane := m.activePane()
if selected, ok := pane.Selected(); ok && !selected.IsParent {
pane.ToggleMarked(selected.Path)
}
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
}
currentName := selected.Name
pane.Path = selected.Path
if err := m.reloadPane(pane.ID, currentName); err != nil {
return err
}
m.status = fmt.Sprintf("Entered %s", pane.Path)
return nil
}
func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected()
if !ok {
return m, nil
}
if selected.IsDir {
if err := m.enterSelected(); err != nil {
m.status = err.Error()
return m, nil
}
return m, m.loadPreviewCmd()
}
if isEditableEntry(selected) {
return m.handleEdit()
}
return m.handleOpenExternal()
}
func (m *Model) goParent() error {
m.hover = hoverState{}
pane := m.activePane()
parent := filepath.Dir(pane.Path)
if parent == pane.Path {
return nil
}
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
}
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,
}
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) {
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) handleDelete() (tea.Model, tea.Cmd) {
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("Deleting %d entr%s", len(sources), pluralSuffix(len(sources), "y", "ies"))
return m, deletePathsCmd(sources)
}
m.busy = true
m.status = "Calculating delete size"
return m, deletePlanCmd(sources)
}
func (m *Model) handleView() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected()
if !ok || selected.IsParent || selected.IsDir {
m.status = "Preview refreshed"
return m, m.loadPreviewCmd()
}
command, name, err := externalCommand("PAGER", []string{"less", "more"}, selected.Path)
if err != nil {
m.status = "Preview refreshed in center pane"
return m, m.loadPreviewCmd()
}
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
return m, tea.ExecProcess(command, func(err error) tea.Msg {
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
})
}
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.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
return m, tea.ExecProcess(command, func(err error) tea.Msg {
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
})
}
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.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) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
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:
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.resizePreview()
m.syncPreviewContent()
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.hover = hoverState{pane: paneID, index: index, ok: true}
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()
}
if m.selectMode {
m.selectMode = false
return m, enableMouseCmd()
}
m.status = "Info mode: off"
return m, nil
}
func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) {
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) 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
m.status = fmt.Sprintf("Theme: %s", next)
return m, nil
}
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() {
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) 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",
" PgDn / f page down",
" PgUp / b page up",
" Enter / Right open selected entry",
" Backspace/Left go to parent directory",
" Tab / h / l switch active pane",
" r refresh both panes",
"",
"View and Panels",
" i toggle preview/info pane",
" Ctrl+t toggle text selection mode in text preview",
" Space calculate selected directory size",
" s cycle sort mode",
" . toggle hidden files",
" t cycle theme",
"",
"Dialogs and Transfers",
" Enter / y confirm action",
" Esc / n cancel action",
" b run copy/move in background (progress dialog)",
" F5/F6/F8 apply to marked entries when selection exists",
"",
"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",
"",
"F-key actions are shown in the footer.",
}
m.modal = modalState{
kind: modalHelp,
title: "Keyboard and Mouse Help",
body: strings.Join(sections, "\n"),
note: "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()
}
func (m *Model) syncPreviewContent() {
content := m.previewData.Body
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) 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 {
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 renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int) 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.Accent).
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)
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)
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) 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))
}
leftWidth := max(innerWidth/2, 18)
if leftWidth > innerWidth {
leftWidth = innerWidth
}
rightWidth := max(innerWidth-leftWidth, 0)
left := lipgloss.NewStyle().
Width(leftWidth).
Background(palette.PanelElevated).
Foreground(palette.Muted).
Render(strings.Join(leftRows, "\n"))
right := lipgloss.NewStyle().
Width(rightWidth).
Background(palette.PanelElevated).
Foreground(palette.Text).
Render(strings.Join(rightRows, "\n"))
pathLine := lipgloss.NewStyle().
Width(innerWidth).
Background(palette.PanelElevated).
Foreground(palette.Text).
Render(fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(innerWidth-8, 16))))
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)
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, " ")
if m.selectMode {
modeLabel := lipgloss.NewStyle().
Foreground(m.palette.Info).
Bold(true).
Render("SELECT TEXT MODE")
if line != "" {
line += " "
}
line += modeLabel
}
line = " " + line
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
line,
lipgloss.WithWhitespaceBackground(m.palette.Footer),
)
}
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 == 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 {
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, renderConfirmActions(contentWidth, palette))
} 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("")
}
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 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, 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)
lineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Text)
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)),
lineStyle.Render(fmt.Sprintf("From: %s", transferSourceLabel(job.sourcePaths))),
lineStyle.Render(fmt.Sprintf("To: %s", job.targetDir)),
spacer,
lineStyle.Render(renderProgressBar(ratio, max(contentWidth-8, 10), palette)),
lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)),
lineStyle.Render(fmt.Sprintf("Size: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))),
}
if strings.TrimSpace(progress.CurrentPath) != "" {
lines = append(lines, lineStyle.Render("Current: "+truncateMiddle(progress.CurrentPath, max(contentWidth-10, 16))))
}
lines = append(lines, spacer)
lines = append(lines, mutedStyle.Render("Press b to continue 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))
percent := fmt.Sprintf(" %3.0f%%", ratio*100)
return bar + rest + percent
}
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.PreviewKindBinary:
return "󰈔"
case vfs.PreviewKindError:
return ""
default:
return "󰇙"
}
}
func (p pendingOperation) cmd() tea.Cmd {
switch p.kind {
case opMove:
return nil
case opDelete:
return deletePathsCmd(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 deletePlanCmd(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{
sourcePaths: append([]string(nil), sourcePaths...),
stats: stats,
err: err,
}
}
}
func waitCopyProgressCmd(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 (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
m.nextCopyJob++
jobID := m.nextCopyJob
m.copyJob = &copyJobState{
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],
},
}
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,
},
}
}
switch kind {
case opMove:
_, statErr = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn)
default:
_, statErr = vfs.CopyPathWithProgress(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 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 deletePathsCmd(paths []string) tea.Cmd {
return func() tea.Msg {
for _, path := range paths {
if err := vfs.DeletePath(path); err != nil {
return opMsg{kind: opDelete, sourcePath: path, err: err}
}
}
return opMsg{kind: opDelete}
}
}
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 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 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"
default:
return "Copying"
}
}
func operationDoneLabel(kind fileOpKind) string {
switch kind {
case opMove:
return "Moved"
case opCopy:
return "Copied"
case opDelete:
return "Deleted"
default:
return "Done"
}
}
func operationVerb(kind fileOpKind) string {
switch kind {
case opCopy:
return "copy"
case opMove:
return "move"
case opDelete:
return "delete"
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 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 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", "executable":
return true
default:
return false
}
}
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
}