Replace full ANSI reset sequences (\x1b[0m) added by lipgloss.Render() for gutter markers, cursor highlights, and selection highlights with background-preserving resets (\x1b[39;22;23;24;59;48;2;R;G;Bm) that restore the panel background color. Without this fix, every \x1b[0m from lipgloss-styled elements in renderTextCursorContent resets the panel background (set by the outer renderPreviewContent wrapper), causing some letters to have incorrect or missing background color in caret mode.
3573 lines
91 KiB
Go
3573 lines
91 KiB
Go
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"
|
||
)
|
||
|
||
type modalKind int
|
||
|
||
const (
|
||
modalNone modalKind = iota
|
||
modalMkdir
|
||
modalRename
|
||
modalConfirm
|
||
modalCopyProgress
|
||
modalNotice
|
||
modalHelp
|
||
)
|
||
|
||
type fileOpKind int
|
||
|
||
const (
|
||
opCopy fileOpKind = iota
|
||
opMove
|
||
opDelete
|
||
opMkdir
|
||
opRename
|
||
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 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 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
|
||
|
||
modal modalState
|
||
status string
|
||
busy bool
|
||
|
||
lastClick mouseClickState
|
||
hover hoverState
|
||
pendingY bool
|
||
yankFlashLine int
|
||
|
||
copyJob *copyJobState
|
||
nextCopyJob int
|
||
copyProgress chan tea.Msg
|
||
copyPath 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),
|
||
}
|
||
model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode)
|
||
if model.status == "" {
|
||
model.status = "Ready"
|
||
}
|
||
|
||
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 = "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()
|
||
}
|
||
|
||
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 := "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 {
|
||
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:
|
||
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
|
||
}
|
||
}
|
||
|
||
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":
|
||
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.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.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(m.left, 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(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons),
|
||
)
|
||
}
|
||
} else {
|
||
panels = lipgloss.JoinHorizontal(
|
||
lipgloss.Top,
|
||
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft), m.nerdIcons),
|
||
gap,
|
||
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight), m.nerdIcons),
|
||
)
|
||
}
|
||
|
||
parts := make([]string, 0, 3)
|
||
parts = append(parts, panels)
|
||
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 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) {
|
||
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 isArchiveEntry(selected) {
|
||
if err := m.enterArchive(selected); 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()
|
||
|
||
if mount, ok := pane.CurrentArchive(); ok && pane.Path == mount.RootPath {
|
||
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
|
||
}
|
||
|
||
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,
|
||
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) 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("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 = "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) 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",
|
||
" F2 / r rename selected entry",
|
||
" Ctrl+r refresh both panes",
|
||
"",
|
||
"View and Panels",
|
||
" F9 / o toggle preview/info pane",
|
||
" F3 plain text view or fullscreen image viewer",
|
||
" i show text caret in preview pane",
|
||
" v start visual selection from caret",
|
||
" F3 / Esc / q close view 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",
|
||
" q / Esc close caret/info mode",
|
||
" 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",
|
||
" Enter / y confirm action",
|
||
" Esc / n cancel action",
|
||
" b run copy/move in background (progress dialog)",
|
||
" c cancel active copy/move transfer",
|
||
" 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()
|
||
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)
|
||
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))
|
||
}
|
||
|
||
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 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 || 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 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.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 (m *Model) enterArchive(selected vfs.Entry) error {
|
||
pane := m.activePane()
|
||
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
|
||
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 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 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 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 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 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 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", "executable":
|
||
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
|
||
}
|