2026-04-22 22:10:50 +03:00
package ui
import (
2026-04-23 21:46:55 +03:00
"context"
2026-04-22 22:10:50 +03:00
"fmt"
2026-04-24 22:09:54 +03:00
"io"
2026-04-22 22:10:50 +03:00
"os"
"os/exec"
"path/filepath"
"strings"
2026-04-22 22:50:30 +03:00
"time"
2026-04-22 22:10:50 +03:00
2026-04-25 00:51:51 +03:00
"github.com/atotto/clipboard"
2026-04-22 22:10:50 +03:00
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
2026-04-23 12:30:10 +03:00
"github.com/charmbracelet/x/ansi"
2026-04-22 22:10:50 +03:00
"vcom/internal/config"
vfs "vcom/internal/fs"
"vcom/internal/theme"
)
2026-04-27 23:18:48 +03:00
const version = "v0.2.1"
2026-04-27 16:26:01 +03:00
2026-04-22 22:10:50 +03:00
type modalKind int
const (
modalNone modalKind = iota
modalMkdir
2026-04-24 13:15:04 +03:00
modalRename
2026-04-22 22:10:50 +03:00
modalConfirm
2026-04-23 12:30:10 +03:00
modalCopyProgress
modalNotice
2026-04-23 14:22:16 +03:00
modalHelp
2026-04-27 15:30:39 +03:00
modalArchiveType
modalArchiveProgress
2026-04-22 22:10:50 +03:00
)
type fileOpKind int
const (
opCopy fileOpKind = iota
opMove
opDelete
2026-04-27 18:56:20 +03:00
opPermanentDelete
2026-04-22 22:10:50 +03:00
opMkdir
2026-04-24 13:15:04 +03:00
opRename
2026-04-22 22:10:50 +03:00
opEdit
opView
2026-04-27 15:30:39 +03:00
opArchive
2026-04-27 23:18:48 +03:00
opExecute
2026-04-22 22:10:50 +03:00
)
type pendingOperation struct {
2026-04-23 19:57:06 +03:00
kind fileOpKind
sourcePaths [ ] string
targetDir string
overwrite bool
existingTargets int
stats vfs . TransferStats
2026-04-22 22:10:50 +03:00
}
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
}
2026-04-23 12:30:10 +03:00
type copyPlanMsg struct {
2026-04-23 19:57:06 +03:00
kind fileOpKind
sourcePaths [ ] string
targetDir string
overwrite bool
existingTargets int
stats vfs . TransferStats
err error
2026-04-23 12:30:10 +03:00
}
type copyProgressMsg struct {
jobID int
progress vfs . CopyProgress
}
2026-04-23 20:37:54 +03:00
type deletePlanMsg struct {
2026-04-27 18:56:20 +03:00
kind fileOpKind
2026-04-23 20:37:54 +03:00
sourcePaths [ ] string
stats vfs . TransferStats
err error
}
2026-04-27 15:30:39 +03:00
type archivePlanMsg struct {
sourcePaths [ ] string
targetDir string
stats vfs . TransferStats
err error
}
type archiveProgressMsg struct {
jobID int
progress vfs . CopyProgress
}
type archiveDoneMsg struct {
jobID int
sourcePaths [ ] string
targetPath string
err error
}
2026-04-23 12:30:10 +03:00
type copyDoneMsg struct {
2026-04-23 19:57:06 +03:00
jobID int
kind fileOpKind
sourcePaths [ ] string
targetDir string
err error
2026-04-23 12:30:10 +03:00
}
type dismissNoticeMsg struct { }
2026-04-25 02:21:43 +03:00
type dismissYankFlashMsg struct { }
2026-04-24 22:09:54 +03:00
type externalOpenMsg struct {
path string
err error
}
2026-04-23 12:30:10 +03:00
type copyJobState struct {
2026-04-23 19:57:06 +03:00
id int
kind fileOpKind
sourcePaths [ ] string
targetDir string
progress vfs . CopyProgress
overwrite bool
background bool
2026-04-23 21:46:55 +03:00
cancel context . CancelFunc
startedAt time . Time
2026-04-23 12:30:10 +03:00
}
2026-04-27 15:30:39 +03:00
type archiveJobState struct {
id int
sourcePaths [ ] string
targetPath string
progress vfs . CopyProgress
background bool
cancel context . CancelFunc
startedAt time . Time
}
2026-04-22 22:50:30 +03:00
type mouseClickState struct {
pane PaneID
index int
at time . Time
}
type hoverState struct {
pane PaneID
index int
ok bool
}
2026-04-22 22:10:50 +03:00
type Model struct {
cfg config . Config
configPath string
palette theme . Palette
keys KeyMap
2026-04-24 14:44:49 +03:00
nerdIcons bool
2026-04-24 21:51:56 +03:00
overlay * imageOverlayManager
2026-04-22 22:10:50 +03:00
width int
height int
2026-04-25 00:51:51 +03:00
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
2026-04-22 22:10:50 +03:00
previewModel viewport . Model
previewData vfs . Preview
2026-04-27 18:11:19 +03:00
filterMode bool
filterQuery string
filterInput textinput . Model
filterPaneID PaneID
2026-04-27 17:14:48 +03:00
2026-04-22 22:10:50 +03:00
modal modalState
status string
busy bool
2026-04-22 22:50:30 +03:00
2026-04-25 02:21:43 +03:00
lastClick mouseClickState
hover hoverState
pendingY bool
yankFlashLine int
2026-04-23 12:30:10 +03:00
copyJob * copyJobState
nextCopyJob int
copyProgress chan tea . Msg
2026-04-27 13:49:45 +03:00
copyPath string
2026-04-27 15:30:39 +03:00
archiveJob * archiveJobState
nextArchiveJob int
archiveProgress chan tea . Msg
archiveFormat string
2026-04-22 22:10:50 +03:00
}
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 {
2026-04-27 15:30:39 +03:00
cfg : cfg ,
configPath : configPath ,
palette : palette ,
keys : DefaultKeyMap ( ) ,
overlay : newImageOverlayManager ( ) ,
left : BrowserPane { ID : PaneLeft , Path : leftPath } ,
right : BrowserPane { ID : PaneRight , Path : rightPath } ,
active : PaneLeft ,
status : "Ready" ,
copyProgress : make ( chan tea . Msg , 256 ) ,
archiveProgress : make ( chan tea . Msg , 256 ) ,
2026-04-22 22:10:50 +03:00
}
2026-04-24 14:44:49 +03:00
model . nerdIcons , model . status = resolveIconMode ( cfg . UI . IconMode )
if model . status == "" {
model . status = "Ready"
}
2026-04-22 22:10:50 +03:00
2026-04-27 17:14:48 +03:00
filterInput := textinput . New ( )
filterInput . Placeholder = "filter by name…"
filterInput . CharLimit = 64
filterInput . Width = 40
model . filterInput = filterInput
2026-04-22 22:10:50 +03:00
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 )
}
2026-04-24 10:08:33 +03:00
if m . selectMode && ! m . viewMode && msg . preview . Kind != vfs . PreviewKindText {
2026-04-22 23:03:33 +03:00
m . selectMode = false
return m , enableMouseCmd ( )
}
2026-04-25 00:51:51 +03:00
if ( m . cursorMode || m . visualMode ) && msg . preview . Kind != vfs . PreviewKindText {
m . cursorMode = false
m . visualMode = false
m . status = "Text cursor mode: off"
}
2026-04-22 22:10:50 +03:00
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 :
2026-04-27 18:56:20 +03:00
m . status = "Moved to trash"
m . activePane ( ) . ClearMarks ( )
case opPermanentDelete :
m . status = "Permanently deleted"
2026-04-23 19:57:06 +03:00
m . activePane ( ) . ClearMarks ( )
2026-04-22 22:10:50 +03:00
case opMkdir :
m . status = fmt . Sprintf ( "Created %s" , msg . targetPath )
2026-04-24 13:15:04 +03:00
case opRename :
m . status = fmt . Sprintf ( "Renamed to %s" , filepath . Base ( msg . targetPath ) )
2026-04-22 22:10:50 +03:00
case opEdit :
m . status = "Editor closed"
2026-04-22 22:50:30 +03:00
return m , tea . Batch ( m . loadPreviewCmd ( ) , enableMouseCmd ( ) )
2026-04-22 22:10:50 +03:00
case opView :
m . status = "Viewer closed"
2026-04-22 22:50:30 +03:00
return m , enableMouseCmd ( )
2026-04-27 23:18:48 +03:00
case opExecute :
m . status = "Executable closed"
return m , tea . Batch ( m . loadPreviewCmd ( ) , enableMouseCmd ( ) )
2026-04-22 22:10:50 +03:00
}
2026-04-24 13:15:04 +03:00
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 )
2026-04-22 22:10:50 +03:00
return m , m . loadPreviewCmd ( )
2026-04-23 12:30:10 +03:00
case copyPlanMsg :
m . busy = false
if msg . err != nil {
m . status = msg . err . Error ( )
return m , nil
}
2026-04-23 12:38:19 +03:00
verb := operationVerb ( msg . kind )
title := fmt . Sprintf ( "%s selected entry?" , strings . Title ( verb ) )
2026-04-23 12:30:10 +03:00
body := strings . Join ( [ ] string {
2026-04-23 20:37:54 +03:00
fmt . Sprintf ( "Items: %d" , len ( msg . sourcePaths ) ) ,
2026-04-23 12:30:10 +03:00
fmt . Sprintf ( "Files: %d" , msg . stats . FilesTotal ) ,
2026-04-23 12:38:19 +03:00
fmt . Sprintf ( "Size: %s" , formatSize ( msg . stats . BytesTotal , true ) ) ,
2026-04-23 12:30:10 +03:00
} , "\n" )
2026-04-23 20:37:54 +03:00
note := "confirm-actions"
2026-04-23 12:30:10 +03:00
m . openConfirmModal ( title , body , note , pendingOperation {
2026-04-23 19:57:06 +03:00
kind : msg . kind ,
sourcePaths : append ( [ ] string ( nil ) , msg . sourcePaths ... ) ,
targetDir : msg . targetDir ,
overwrite : msg . overwrite ,
existingTargets : msg . existingTargets ,
stats : msg . stats ,
2026-04-23 12:30:10 +03:00
} )
return m , nil
2026-04-23 20:37:54 +03:00
case deletePlanMsg :
m . busy = false
if msg . err != nil {
m . status = msg . err . Error ( )
return m , nil
}
2026-04-27 18:56:20 +03:00
title := "Move selected entr" + pluralSuffix ( len ( msg . sourcePaths ) , "y" , "ies" ) + " to trash?"
if msg . kind == opPermanentDelete {
title = "Permanently delete selected entr" + pluralSuffix ( len ( msg . sourcePaths ) , "y" , "ies" ) + "?"
}
2026-04-23 20:37:54 +03:00
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 {
2026-04-27 18:56:20 +03:00
kind : msg . kind ,
2026-04-23 20:37:54 +03:00
sourcePaths : append ( [ ] string ( nil ) , msg . sourcePaths ... ) ,
stats : msg . stats ,
} ,
)
return m , nil
2026-04-27 15:30:39 +03:00
case archivePlanMsg :
m . busy = false
if msg . err != nil {
m . status = msg . err . Error ( )
return m , nil
}
m . archiveFormat = "zip"
bodyLines := [ ] string {
fmt . Sprintf ( "Items: %d" , len ( msg . sourcePaths ) ) ,
fmt . Sprintf ( "Files: %d" , msg . stats . FilesTotal ) ,
fmt . Sprintf ( "Size: %s" , formatSize ( msg . stats . BytesTotal , true ) ) ,
}
m . modal = modalState {
kind : modalArchiveType ,
title : "Archive selected files?" ,
body : strings . Join ( bodyLines , "\n" ) ,
note : fmt . Sprintf (
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel" ,
m . archiveFormat ,
) ,
pending : & pendingOperation {
kind : opArchive ,
sourcePaths : append ( [ ] string ( nil ) , msg . sourcePaths ... ) ,
targetDir : msg . targetDir ,
stats : msg . stats ,
} ,
}
return m , nil
case archiveProgressMsg :
if m . archiveJob == nil || msg . jobID != m . archiveJob . id {
return m , nil
}
m . archiveJob . progress = msg . progress
if m . archiveJob . background {
m . status = formatArchiveStatus ( msg . progress )
}
return m , waitArchiveProgressCmd ( m . archiveProgress )
case archiveDoneMsg :
if m . archiveJob == nil || msg . jobID != m . archiveJob . id {
return m , nil
}
m . busy = false
if msg . err != nil {
activeSelection := selectedName ( m . activePane ( ) )
_ = m . reloadPane ( PaneLeft , activeSelection )
_ = m . reloadPane ( PaneRight , activeSelection )
if msg . err == context . Canceled {
m . status = "Archiving cancelled"
} else {
m . status = fmt . Sprintf ( "Archiving failed: %v" , msg . err )
}
m . archiveJob = nil
if m . modal . kind == modalArchiveProgress {
m . modal = modalState { }
}
return m , m . loadPreviewCmd ( )
}
m . status = fmt . Sprintf ( "Archived %d entr%s to %s" , len ( msg . sourcePaths ) , pluralSuffix ( len ( msg . sourcePaths ) , "y" , "ies" ) , msg . targetPath )
activeSelection := selectedName ( m . activePane ( ) )
_ = m . reloadPane ( PaneLeft , activeSelection )
_ = m . reloadPane ( PaneRight , activeSelection )
background := m . archiveJob . background
sourceCount := len ( m . archiveJob . sourcePaths )
m . archiveJob = nil
m . activePane ( ) . ClearMarks ( )
cmd := m . loadPreviewCmd ( )
if m . modal . kind == modalArchiveProgress {
m . modal = modalState { }
}
if background {
doneBody := fmt . Sprintf ( "%d entr%s archived successfully." , sourceCount , pluralSuffix ( sourceCount , "y" , "ies" ) )
if sourceCount == 1 && len ( msg . sourcePaths ) == 1 {
doneBody = filepath . Base ( msg . sourcePaths [ 0 ] ) + " archived successfully."
}
m . modal = modalState {
kind : modalNotice ,
title : "Archive complete" ,
body : doneBody ,
note : "Press Esc to close" ,
}
}
return m , cmd
2026-04-23 12:30:10 +03:00
case copyProgressMsg :
if m . copyJob == nil || msg . jobID != m . copyJob . id {
return m , nil
}
m . copyJob . progress = msg . progress
if m . copyJob . background {
2026-04-23 12:38:19 +03:00
m . status = formatCopyStatus ( m . copyJob . kind , msg . progress )
2026-04-23 12:30:10 +03:00
}
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 {
2026-04-23 22:46:08 +03:00
activeSelection := selectedName ( m . activePane ( ) )
_ = m . reloadPane ( PaneLeft , activeSelection )
_ = m . reloadPane ( PaneRight , activeSelection )
2026-04-23 21:46:55 +03:00
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 )
}
2026-04-23 12:30:10 +03:00
m . copyJob = nil
if m . modal . kind == modalCopyProgress {
m . modal = modalState { }
}
2026-04-23 22:46:08 +03:00
return m , m . loadPreviewCmd ( )
2026-04-23 12:30:10 +03:00
}
2026-04-23 19:57:06 +03:00
m . status = fmt . Sprintf ( "%s %d entr%s to %s" , operationDoneLabel ( msg . kind ) , len ( msg . sourcePaths ) , pluralSuffix ( len ( msg . sourcePaths ) , "y" , "ies" ) , msg . targetDir )
2026-04-23 12:30:10 +03:00
activeSelection := selectedName ( m . activePane ( ) )
_ = m . reloadPane ( PaneLeft , activeSelection )
_ = m . reloadPane ( PaneRight , activeSelection )
background := m . copyJob . background
2026-04-23 12:38:19 +03:00
kind := m . copyJob . kind
2026-04-23 19:57:06 +03:00
sourceCount := len ( m . copyJob . sourcePaths )
2026-04-23 12:30:10 +03:00
m . copyJob = nil
2026-04-23 19:57:06 +03:00
m . activePane ( ) . ClearMarks ( )
2026-04-23 12:30:10 +03:00
cmd := m . loadPreviewCmd ( )
if m . modal . kind == modalCopyProgress {
m . modal = modalState { }
}
if background {
2026-04-23 12:38:19 +03:00
doneWord := "copied"
if kind == opMove {
doneWord = "moved"
}
2026-04-23 19:57:06 +03:00
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."
}
2026-04-23 12:30:10 +03:00
m . modal = modalState {
kind : modalNotice ,
2026-04-23 12:38:19 +03:00
title : strings . Title ( operationVerb ( kind ) ) + " complete" ,
2026-04-23 19:57:06 +03:00
body : doneBody ,
2026-04-23 12:30:10 +03:00
}
cmd = tea . Batch ( cmd , dismissNoticeCmd ( time . Second ) )
}
return m , cmd
case dismissNoticeMsg :
if m . modal . kind == modalNotice {
m . modal = modalState { }
}
return m , nil
2026-04-25 02:21:43 +03:00
case dismissYankFlashMsg :
m . yankFlashLine = - 1
m . syncPreviewContent ( )
return m , nil
2026-04-24 22:09:54 +03:00
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
2026-04-22 22:10:50 +03:00
case tea . KeyMsg :
2026-04-27 20:30:11 +03:00
msg = translateKeyMsg ( msg )
2026-04-25 00:51:51 +03:00
if msg . String ( ) != "y" {
m . pendingY = false
}
2026-04-22 22:10:50 +03:00
if m . modal . kind != modalNone {
return m . handleModalKey ( msg )
}
2026-04-24 10:08:33 +03:00
if m . viewMode {
2026-04-25 00:51:51 +03:00
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 ( )
}
}
2026-04-24 10:08:33 +03:00
switch {
2026-04-25 00:51:51 +03:00
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" :
2026-04-24 10:08:33 +03:00
return m . exitViewMode ( )
case key . Matches ( msg , m . keys . Up ) :
2026-04-25 00:51:51 +03:00
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" )
}
2026-04-25 02:21:43 +03:00
if m . cursorMode {
return m . toggleVisualMode ( )
}
return m , nil
2026-04-25 00:51:51 +03:00
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 )
2026-04-24 10:08:33 +03:00
return m , nil
case key . Matches ( msg , m . keys . Down ) :
2026-04-25 00:51:51 +03:00
m . moveTextCursorLine ( 1 )
return m , nil
case msg . String ( ) == "right" :
m . moveTextCursorCol ( 1 )
return m , nil
case msg . String ( ) == "l" :
m . moveTextCursorCol ( 1 )
2026-04-24 10:08:33 +03:00
return m , nil
case key . Matches ( msg , m . keys . PageUp ) :
2026-04-25 00:51:51 +03:00
m . moveTextCursorLine ( - max ( m . previewModel . Height - 2 , 1 ) )
2026-04-24 10:08:33 +03:00
return m , nil
case key . Matches ( msg , m . keys . PageDown ) :
2026-04-25 00:51:51 +03:00
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 ( )
2026-04-24 10:08:33 +03:00
return m , nil
default :
return m , nil
}
}
2026-04-22 22:10:50 +03:00
2026-04-27 13:49:45 +03:00
// 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
}
}
2026-04-27 17:14:48 +03:00
// Filter mode: route keys to the filter input
if m . filterMode {
switch {
case msg . String ( ) == "esc" :
m . filterQuery = ""
m . filterInput . SetValue ( "" )
m . filterInput . Blur ( )
m . filterMode = false
m . status = "Filter cleared"
return m , nil
case key . Matches ( msg , m . keys . Confirm ) :
m . filterMode = false
m . filterInput . Blur ( )
m . status = fmt . Sprintf ( "Filter: %s" , m . filterQuery )
return m , nil
case key . Matches ( msg , m . keys . Up ) :
2026-04-27 18:11:19 +03:00
m . moveFilteredCursor ( - 1 )
2026-04-27 17:14:48 +03:00
return m , m . loadPreviewCmd ( )
case key . Matches ( msg , m . keys . Down ) :
2026-04-27 18:11:19 +03:00
m . moveFilteredCursor ( 1 )
2026-04-27 17:14:48 +03:00
return m , m . loadPreviewCmd ( )
case key . Matches ( msg , m . keys . PageUp ) :
2026-04-27 18:11:19 +03:00
m . moveFilteredCursor ( - max ( m . bodyHeight ( ) - 6 , 5 ) )
2026-04-27 17:14:48 +03:00
return m , m . loadPreviewCmd ( )
case key . Matches ( msg , m . keys . PageDown ) :
2026-04-27 18:11:19 +03:00
m . moveFilteredCursor ( max ( m . bodyHeight ( ) - 6 , 5 ) )
2026-04-27 17:14:48 +03:00
return m , m . loadPreviewCmd ( )
default :
var cmd tea . Cmd
m . filterInput , cmd = m . filterInput . Update ( msg )
m . filterQuery = m . filterInput . Value ( )
2026-04-27 18:11:19 +03:00
m . snapFilterCursor ( )
2026-04-27 17:14:48 +03:00
return m , cmd
}
}
2026-04-27 18:11:19 +03:00
// Toggle filter mode — attaches filter to the currently active pane
2026-04-27 17:14:48 +03:00
if key . Matches ( msg , m . keys . Filter ) {
m . filterMode = true
2026-04-27 18:11:19 +03:00
m . filterPaneID = m . active
2026-04-27 17:14:48 +03:00
m . filterInput . Focus ( )
m . filterInput . SetValue ( m . filterQuery )
m . status = "Filter: type to filter, Enter to confirm, Esc to clear"
return m , nil
}
2026-04-22 22:10:50 +03:00
switch {
case key . Matches ( msg , m . keys . Quit ) :
2026-04-24 15:14:05 +03:00
m . cleanupArchiveMounts ( )
2026-04-24 21:51:56 +03:00
m . cleanupImageOverlay ( )
2026-04-22 22:10:50 +03:00
return m , tea . Quit
2026-04-23 14:22:16 +03:00
case key . Matches ( msg , m . keys . Help ) :
m . openHelpModal ( )
return m , nil
2026-04-24 13:15:04 +03:00
case key . Matches ( msg , m . keys . Rename ) :
m . openRenameModal ( )
return m , nil
2026-04-25 00:51:51 +03:00
case key . Matches ( msg , m . keys . Cancel ) , msg . String ( ) == "q" :
2026-04-27 18:11:19 +03:00
// Esc on the pane where filter is active clears it.
if m . filterQuery != "" && m . filterPaneID == m . active {
m . clearFilter ( )
m . status = "Filter cleared"
return m , nil
}
2026-04-24 11:05:31 +03:00
if m . infoMode {
m . infoMode = false
m . selectMode = false
2026-04-25 00:51:51 +03:00
m . cursorMode = false
m . visualMode = false
2026-04-27 13:49:45 +03:00
m . copyPath = ""
2026-04-24 11:05:31 +03:00
m . status = "Info pane closed"
return m , nil
}
2026-04-23 19:57:06 +03:00
if len ( m . activePane ( ) . MarkedEntries ( ) ) > 0 {
m . activePane ( ) . ClearMarks ( )
m . status = "Selection cleared"
return m , m . loadPreviewCmd ( )
}
return m , nil
2026-04-22 22:10:50 +03:00
case key . Matches ( msg , m . keys . View ) :
return m . handleView ( )
2026-04-25 00:51:51 +03:00
case key . Matches ( msg , m . keys . Caret ) :
return m . toggleCaretMode ( )
case key . Matches ( msg , m . keys . Visual ) :
2026-04-25 02:21:43 +03:00
if m . cursorMode {
return m . toggleVisualMode ( )
}
return m , nil
2026-04-22 22:10:50 +03:00
case key . Matches ( msg , m . keys . Edit ) :
return m . handleEdit ( )
2026-04-27 15:30:39 +03:00
case key . Matches ( msg , m . keys . Archive ) :
return m . handleArchive ( )
2026-04-22 22:10:50 +03:00
case key . Matches ( msg , m . keys . Info ) :
return m . toggleInfo ( )
2026-04-22 23:03:33 +03:00
case key . Matches ( msg , m . keys . SelectText ) :
return m . toggleSelectMode ( )
2026-04-22 22:10:50 +03:00
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 ) :
2026-04-23 19:57:06 +03:00
m . left . ClearMarks ( )
m . right . ClearMarks ( )
2026-04-22 22:10:50 +03:00
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 ( )
2026-04-23 19:57:06 +03:00
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 ( )
2026-04-22 22:10:50 +03:00
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 ) :
2026-04-22 22:50:30 +03:00
return m . handleOpenSelected ( )
2026-04-22 22:10:50 +03:00
case key . Matches ( msg , m . keys . Back ) :
if err := m . goParent ( ) ; err != nil {
m . status = err . Error ( )
}
return m , m . loadPreviewCmd ( )
2026-04-27 18:11:19 +03:00
case key . Matches ( msg , m . keys . HistoryBack ) :
return m . historyBack ( )
case key . Matches ( msg , m . keys . HistoryForward ) :
return m . historyForward ( )
2026-04-22 22:10:50 +03:00
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 ( )
2026-04-27 18:56:20 +03:00
case key . Matches ( msg , m . keys . PermanentDelete ) :
return m . handlePermanentDelete ( )
2026-04-22 22:10:50 +03:00
}
2026-04-22 22:50:30 +03:00
case tea . MouseMsg :
return m . handleMouse ( msg )
2026-04-22 22:10:50 +03:00
}
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 ( )
2026-04-23 00:10:41 +03:00
gap := lipgloss . NewStyle ( ) .
Width ( m . cfg . UI . PaneGap ) .
Height ( bodyHeight ) .
Background ( m . palette . Panel ) .
Render ( "" )
2026-04-22 22:10:50 +03:00
2026-04-27 18:11:19 +03:00
// Filter is sticky: once activated on a pane it stays on that pane
// even after switching to the other pane with Tab.
leftPane := m . left
rightPane := m . right
if m . filterQuery != "" {
switch m . filterPaneID {
case PaneLeft :
leftPane = m . filteredPane ( m . left )
case PaneRight :
rightPane = m . filteredPane ( m . right )
}
}
2026-04-27 17:14:48 +03:00
2026-04-22 22:10:50 +03:00
var panels string
2026-04-24 21:51:56 +03:00
if m . viewMode && m . previewData . Kind == vfs . PreviewKindImage {
panels = lipgloss . NewStyle ( ) .
Width ( m . width ) .
Height ( bodyHeight ) .
Background ( m . palette . Background ) .
Render ( "" )
2026-04-25 00:51:51 +03:00
} else if m . viewMode && m . previewData . Kind == vfs . PreviewKindText {
panels = renderSelectionPane ( m . previewData , & m . previewModel , m . palette , m . width , bodyHeight )
} else if m . viewMode {
2026-04-27 13:49:45 +03:00
panels = renderPreviewPane ( m . previewData , & m . previewModel , m . cfg , m . palette , m . width , bodyHeight , m . nerdIcons )
2026-04-24 21:51:56 +03:00
} else if m . selectMode && m . infoMode {
2026-04-22 23:26:45 +03:00
panels = renderSelectionPane ( m . previewData , & m . previewModel , m . palette , m . width , bodyHeight )
} else if m . infoMode {
2026-04-22 22:10:50 +03:00
if m . active == PaneLeft {
panels = lipgloss . JoinHorizontal (
lipgloss . Top ,
2026-04-27 17:14:48 +03:00
renderPane ( leftPane , m . cfg , m . palette , leftWidth , bodyHeight , true , m . hoverIndexFor ( PaneLeft ) , m . nerdIcons ) ,
2026-04-22 22:10:50 +03:00
gap ,
2026-04-27 13:49:45 +03:00
renderPreviewPane ( m . previewData , & m . previewModel , m . cfg , m . palette , previewWidth , bodyHeight , m . nerdIcons ) ,
2026-04-22 22:10:50 +03:00
)
} else {
panels = lipgloss . JoinHorizontal (
lipgloss . Top ,
2026-04-27 13:49:45 +03:00
renderPreviewPane ( m . previewData , & m . previewModel , m . cfg , m . palette , previewWidth , bodyHeight , m . nerdIcons ) ,
2026-04-22 22:10:50 +03:00
gap ,
2026-04-27 17:14:48 +03:00
renderPane ( rightPane , m . cfg , m . palette , rightWidth , bodyHeight , true , m . hoverIndexFor ( PaneRight ) , m . nerdIcons ) ,
2026-04-22 22:10:50 +03:00
)
}
} else {
panels = lipgloss . JoinHorizontal (
lipgloss . Top ,
2026-04-27 17:14:48 +03:00
renderPane ( leftPane , m . cfg , m . palette , leftWidth , bodyHeight , m . active == PaneLeft , m . hoverIndexFor ( PaneLeft ) , m . nerdIcons ) ,
2026-04-22 22:10:50 +03:00
gap ,
2026-04-27 17:14:48 +03:00
renderPane ( rightPane , m . cfg , m . palette , rightWidth , bodyHeight , m . active == PaneRight , m . hoverIndexFor ( PaneRight ) , m . nerdIcons ) ,
2026-04-22 22:10:50 +03:00
)
}
2026-04-27 17:14:48 +03:00
parts := make ( [ ] string , 0 , 4 )
2026-04-22 22:10:50 +03:00
parts = append ( parts , panels )
2026-04-27 17:14:48 +03:00
if m . filterMode {
parts = append ( parts , renderFilterBar ( m ) )
}
2026-04-24 10:08:33 +03:00
if m . cfg . UI . ShowFooter && ! m . viewMode {
2026-04-22 22:10:50 +03:00
parts = append ( parts , renderFooter ( m ) )
}
2026-04-23 00:10:41 +03:00
view := lipgloss . NewStyle ( ) .
Width ( m . width ) .
Height ( m . height ) .
Background ( m . palette . Background ) .
Foreground ( m . palette . Text ) .
Render ( lipgloss . JoinVertical ( lipgloss . Left , parts ... ) )
2026-04-22 22:10:50 +03:00
if m . modal . kind != modalNone {
2026-04-24 21:51:56 +03:00
if m . overlay != nil {
m . overlay . hide ( )
}
2026-04-23 14:22:16 +03:00
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 )
2026-04-24 21:51:56 +03:00
return view
2026-04-22 22:10:50 +03:00
}
2026-04-24 21:51:56 +03:00
m . syncImageOverlay ( leftWidth , previewWidth , bodyHeight )
2026-04-22 22:10:50 +03:00
return view
}
func ( m Model ) handleModalKey ( msg tea . KeyMsg ) ( tea . Model , tea . Cmd ) {
switch m . modal . kind {
2026-04-24 13:15:04 +03:00
case modalMkdir , modalRename :
2026-04-22 22:10:50 +03:00
switch {
2026-04-24 13:15:04 +03:00
case msg . String ( ) == "esc" :
2026-04-22 22:10:50 +03:00
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 == "" {
2026-04-24 13:15:04 +03:00
if m . modal . kind == modalMkdir {
m . status = "Directory name must not be empty"
} else {
m . status = "Name must not be empty"
}
2026-04-22 22:10:50 +03:00
return m , nil
}
m . busy = true
2026-04-24 13:15:04 +03:00
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 )
2026-04-22 22:10:50 +03:00
}
var cmd tea . Cmd
m . modal . input , cmd = m . modal . input . Update ( msg )
return m , cmd
case modalConfirm :
switch {
2026-04-23 14:22:16 +03:00
case isModalCloseKey ( msg , m . keys ) :
2026-04-22 22:10:50 +03:00
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
}
2026-04-23 12:30:10 +03:00
pending := * m . modal . pending
m . modal = modalState { }
2026-04-23 12:38:19 +03:00
if pending . kind == opCopy || pending . kind == opMove {
2026-04-23 12:30:10 +03:00
if m . copyJob != nil {
2026-04-23 12:38:19 +03:00
m . status = "Transfer is already running"
2026-04-23 12:30:10 +03:00
return m , nil
}
m . busy = true
2026-04-23 19:57:06 +03:00
return m , m . startCopyJob ( pending . kind , pending . sourcePaths , pending . targetDir , pending . overwrite , pending . stats )
2026-04-23 12:30:10 +03:00
}
2026-04-22 22:10:50 +03:00
m . busy = true
2026-04-23 12:30:10 +03:00
return m , pending . cmd ( )
}
2026-04-27 15:30:39 +03:00
case modalArchiveType :
switch {
case isModalCloseKey ( msg , m . keys ) :
m . modal = modalState { }
m . status = "Cancelled"
return m , nil
case key . Matches ( msg , m . keys . Confirm ) :
if m . modal . pending == nil {
m . modal = modalState { }
m . status = "Nothing to confirm"
return m , nil
}
pending := * m . modal . pending
m . modal = modalState { }
if m . archiveJob != nil {
m . status = "Archive is already running"
return m , nil
}
m . busy = true
return m , m . startArchiveJob ( pending . sourcePaths , pending . targetDir , m . archiveFormat , pending . stats )
case msg . String ( ) == "f" :
switch m . archiveFormat {
case "zip" :
m . archiveFormat = "tar"
case "tar" :
m . archiveFormat = "tar.gz"
default :
m . archiveFormat = "zip"
}
m . modal . note = fmt . Sprintf (
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel" ,
m . archiveFormat ,
)
return m , nil
}
return m , nil
case modalArchiveProgress :
if key . Matches ( msg , m . keys . Background ) {
if m . archiveJob == nil {
m . modal = modalState { }
return m , nil
}
m . archiveJob . background = true
m . modal = modalState { }
m . status = "Archive continues in background"
return m , nil
}
if key . Matches ( msg , m . keys . ProgressCancel ) {
if m . archiveJob == nil {
m . modal = modalState { }
return m , nil
}
if m . archiveJob . cancel != nil {
m . archiveJob . cancel ( )
}
m . status = "Archiving cancelling..."
return m , nil
}
return m , nil
2026-04-23 12:30:10 +03:00
case modalCopyProgress :
2026-04-23 21:46:55 +03:00
if key . Matches ( msg , m . keys . Background ) {
2026-04-23 14:22:16 +03:00
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
}
2026-04-23 21:46:55 +03:00
if key . Matches ( msg , m . keys . ProgressCancel ) {
2026-04-23 12:30:10 +03:00
if m . copyJob == nil {
m . modal = modalState { }
return m , nil
}
2026-04-23 21:46:55 +03:00
if m . copyJob . cancel != nil {
m . copyJob . cancel ( )
}
m . status = strings . Title ( operationVerb ( m . copyJob . kind ) ) + " cancelling..."
2026-04-23 12:30:10 +03:00
return m , nil
}
return m , nil
case modalNotice :
2026-04-23 14:22:16 +03:00
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 ) {
2026-04-22 22:10:50 +03:00
m . modal = modalState { }
2026-04-23 14:22:16 +03:00
m . status = "Help closed"
2026-04-22 22:10:50 +03:00
}
2026-04-23 12:30:10 +03:00
return m , nil
2026-04-22 22:10:50 +03:00
}
return m , nil
}
2026-04-23 14:22:16 +03:00
func isModalCloseKey ( msg tea . KeyMsg , keys KeyMap ) bool {
return key . Matches ( msg , keys . Cancel ) || msg . String ( ) == "q"
}
2026-04-22 22:10:50 +03:00
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 ) {
2026-04-27 18:11:19 +03:00
// When a filter query is active on this pane, move through filtered entries
// only, so the cursor always lands on a matching item.
if m . filterQuery != "" && m . filterPaneID == m . active {
m . moveFilteredCursor ( delta )
return
}
2026-04-22 22:10:50 +03:00
pane := m . activePane ( )
pane . Move ( delta , max ( m . bodyHeight ( ) - 4 , 1 ) )
2026-04-22 22:50:30 +03:00
m . hover = hoverState { }
2026-04-22 22:10:50 +03:00
}
2026-04-27 18:11:19 +03:00
// snapFilterCursor moves the real cursor to the nearest entry matching the
// current filter query. Called after each filter keystroke so the user
// always sees a selected item in the filtered view.
func ( m * Model ) snapFilterCursor ( ) {
2026-04-27 17:14:48 +03:00
pane := m . activePane ( )
2026-04-27 18:11:19 +03:00
if m . filterQuery == "" || len ( pane . Entries ) == 0 {
2026-04-27 17:14:48 +03:00
return
}
2026-04-27 18:11:19 +03:00
query := strings . ToLower ( m . filterQuery )
// If current cursor position already matches, keep it.
if pane . Cursor >= 0 && pane . Cursor < len ( pane . Entries ) {
entry := pane . Entries [ pane . Cursor ]
if entry . IsParent || strings . Contains ( strings . ToLower ( entry . DisplayName ( ) ) , query ) {
return
}
}
// Search forward from cursor.
for i := pane . Cursor + 1 ; i < len ( pane . Entries ) ; i ++ {
entry := pane . Entries [ i ]
if entry . IsParent || strings . Contains ( strings . ToLower ( entry . DisplayName ( ) ) , query ) {
pane . Cursor = i
return
}
2026-04-27 17:14:48 +03:00
}
2026-04-27 18:11:19 +03:00
// Search backward from cursor.
for i := pane . Cursor - 1 ; i >= 0 ; i -- {
entry := pane . Entries [ i ]
if entry . IsParent || strings . Contains ( strings . ToLower ( entry . DisplayName ( ) ) , query ) {
pane . Cursor = i
return
}
}
}
// moveFilteredCursor moves the real cursor to the next/prev entry matching
// the current filter query. Used for Up/Down navigation in filter mode.
func ( m * Model ) moveFilteredCursor ( delta int ) {
pane := m . activePane ( )
if m . filterQuery == "" || len ( pane . Entries ) == 0 {
m . moveCursor ( delta )
return
}
query := strings . ToLower ( m . filterQuery )
maxIdx := len ( pane . Entries ) - 1
idx := pane . Cursor + delta
for idx >= 0 && idx <= maxIdx {
entry := pane . Entries [ idx ]
if entry . IsParent || strings . Contains ( strings . ToLower ( entry . DisplayName ( ) ) , query ) {
pane . Cursor = idx
pageSize := max ( m . bodyHeight ( ) - 4 , 1 )
if pane . Cursor < pane . Offset {
pane . Offset = pane . Cursor
}
if pageSize > 0 && pane . Cursor >= pane . Offset + pageSize {
pane . Offset = pane . Cursor - pageSize + 1
}
if pane . Offset < 0 {
pane . Offset = 0
}
m . hover = hoverState { }
return
}
idx += delta
2026-04-27 17:14:48 +03:00
}
}
// filteredCount returns the number of entries matching the current filter.
func ( m * Model ) filteredCount ( pane * BrowserPane ) int {
if m . filterQuery == "" {
return len ( pane . Entries )
}
query := strings . ToLower ( m . filterQuery )
count := 0
for _ , entry := range pane . Entries {
if strings . Contains ( strings . ToLower ( entry . DisplayName ( ) ) , query ) {
count ++
}
}
return count
}
// filteredPane returns a copy of the pane with entries filtered by the current query.
2026-04-27 18:11:19 +03:00
// The cursor in the returned copy reflects the position of the real cursor
// within the filtered subset, so Selected() on the original pane still returns
// the correct entry. Offset is recomputed in filtered-entry space so the
// viewport does not inherit the real-list offset (which would be out of range).
2026-04-27 17:14:48 +03:00
func ( m Model ) filteredPane ( pane BrowserPane ) BrowserPane {
if m . filterQuery == "" {
return pane
}
query := strings . ToLower ( m . filterQuery )
filtered := make ( [ ] vfs . Entry , 0 , len ( pane . Entries ) )
2026-04-27 18:11:19 +03:00
filteredCursor := 0
for i , entry := range pane . Entries {
2026-04-27 17:14:48 +03:00
if entry . IsParent || strings . Contains ( strings . ToLower ( entry . DisplayName ( ) ) , query ) {
2026-04-27 18:11:19 +03:00
if i == pane . Cursor {
filteredCursor = len ( filtered )
}
2026-04-27 17:14:48 +03:00
filtered = append ( filtered , entry )
}
}
pane . Entries = filtered
2026-04-27 18:11:19 +03:00
pane . Cursor = filteredCursor
2026-04-27 17:14:48 +03:00
if pane . Cursor >= len ( filtered ) {
pane . Cursor = max ( len ( filtered ) - 1 , 0 )
}
2026-04-27 18:11:19 +03:00
// Recompute offset in filtered-entry space. The source offset is in
// real-entry-index space and is meaningless for the shorter filtered list.
pageSize := max ( m . bodyHeight ( ) - 4 , 1 )
offset := 0
if pane . Cursor >= pageSize {
offset = pane . Cursor - pageSize + 1
2026-04-27 17:14:48 +03:00
}
2026-04-27 18:11:19 +03:00
pane . Offset = offset
2026-04-27 17:14:48 +03:00
return pane
}
2026-04-23 19:57:06 +03:00
func ( m * Model ) selectMoveCursor ( delta int ) {
pane := m . activePane ( )
if selected , ok := pane . Selected ( ) ; ok && ! selected . IsParent {
pane . ToggleMarked ( selected . Path )
}
2026-04-27 18:11:19 +03:00
if m . filterQuery != "" && m . filterPaneID == m . active {
m . moveFilteredCursor ( delta )
} else {
pane . Move ( delta , max ( m . bodyHeight ( ) - 4 , 1 ) )
}
2026-04-23 19:57:06 +03:00
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 }
}
2026-04-22 22:10:50 +03:00
func ( m * Model ) enterSelected ( ) error {
2026-04-22 22:50:30 +03:00
m . hover = hoverState { }
2026-04-22 22:10:50 +03:00
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
}
2026-04-27 18:11:19 +03:00
// Save current directory to history before navigating.
pane . PushHistory ( pane . Path )
2026-04-22 22:10:50 +03:00
pane . Path = selected . Path
2026-04-27 18:56:20 +03:00
if err := m . reloadPane ( pane . ID , selected . Name ) ; err != nil {
2026-04-22 22:10:50 +03:00
return err
}
m . status = fmt . Sprintf ( "Entered %s" , pane . Path )
return nil
}
2026-04-27 17:14:48 +03:00
func ( m * Model ) clearFilter ( ) {
m . filterQuery = ""
m . filterInput . SetValue ( "" )
m . filterInput . Blur ( )
m . filterMode = false
2026-04-27 18:11:19 +03:00
m . filterPaneID = ""
2026-04-27 17:14:48 +03:00
}
2026-04-22 22:50:30 +03:00
func ( m * Model ) handleOpenSelected ( ) ( tea . Model , tea . Cmd ) {
selected , ok := m . activePane ( ) . Selected ( )
if ! ok {
return m , nil
}
2026-04-27 18:56:20 +03:00
// Navigating up via ".." — use goParent which preserves the cursor
// position on the directory/archive we came from (by finding its name
// in the parent listing via FindSelected). This applies both inside
// archive mounts (where pane.Path must stay within the temp mount)
// and regular directories (for consistent cursor placement).
if selected . IsParent {
if err := m . goParent ( ) ; err != nil {
m . status = err . Error ( )
return m , nil
}
return m , m . loadPreviewCmd ( )
}
2026-04-22 22:50:30 +03:00
if selected . IsDir {
if err := m . enterSelected ( ) ; err != nil {
m . status = err . Error ( )
return m , nil
}
return m , m . loadPreviewCmd ( )
}
2026-04-24 15:14:05 +03:00
if isArchiveEntry ( selected ) {
if err := m . enterArchive ( selected ) ; err != nil {
m . status = err . Error ( )
return m , nil
}
return m , m . loadPreviewCmd ( )
}
2026-04-27 23:18:48 +03:00
switch selected . Category ( ) {
case "text" , "config" :
2026-04-22 22:50:30 +03:00
return m . handleEdit ( )
2026-04-27 23:18:48 +03:00
case "executable" :
return m . handleExecute ( selected )
default :
return m . handleOpenExternal ( )
2026-04-22 22:50:30 +03:00
}
}
2026-04-22 22:10:50 +03:00
func ( m * Model ) goParent ( ) error {
2026-04-22 22:50:30 +03:00
m . hover = hoverState { }
2026-04-22 22:10:50 +03:00
pane := m . activePane ( )
2026-04-24 15:14:05 +03:00
2026-04-27 15:30:39 +03:00
if mount , ok := pane . CurrentArchive ( ) ; ok {
root := filepath . Clean ( mount . RootPath )
current := filepath . Clean ( pane . Path )
if current == root {
// At archive root — pop archive and return to the directory containing it
2026-04-27 18:11:19 +03:00
// Save current path to history before leaving so forward can restore it.
pane . PushHistory ( pane . Path )
2026-04-27 15:30:39 +03:00
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
2026-04-24 15:14:05 +03:00
}
2026-04-27 15:30:39 +03:00
// Inside archive subdirectory — go up one level within the archive
2026-04-27 18:11:19 +03:00
pane . PushHistory ( pane . Path )
2026-04-27 15:30:39 +03:00
parent := filepath . Dir ( current )
pane . Path = parent
if err := m . reloadPane ( pane . ID , filepath . Base ( current ) ) ; err != nil {
2026-04-24 15:14:05 +03:00
return err
}
2026-04-27 15:30:39 +03:00
m . status = fmt . Sprintf ( "Moved to %s" , parent )
2026-04-24 15:14:05 +03:00
return nil
}
2026-04-22 22:10:50 +03:00
parent := filepath . Dir ( pane . Path )
if parent == pane . Path {
return nil
}
2026-04-27 18:11:19 +03:00
pane . PushHistory ( pane . Path )
2026-04-22 22:10:50 +03:00
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
}
2026-04-27 18:11:19 +03:00
// historyBack navigates the active pane to the previous directory in its history.
func ( m * Model ) historyBack ( ) ( tea . Model , tea . Cmd ) {
pane := m . activePane ( )
prevPath , ok := pane . PopHistory ( )
if ! ok {
m . status = "No directory history"
return m , nil
}
// Save current path to forward-stack so forward navigation can restore it.
pane . PushFuture ( pane . Path )
pane . Path = prevPath
pane . Cursor = 0
pane . Offset = 0
if err := m . reloadPane ( pane . ID , "" ) ; err != nil {
m . status = err . Error ( )
return m , nil
}
m . status = fmt . Sprintf ( "History back to %s" , prevPath )
return m , m . loadPreviewCmd ( )
}
// historyForward navigates the active pane to the next directory in its forward-stack.
func ( m * Model ) historyForward ( ) ( tea . Model , tea . Cmd ) {
pane := m . activePane ( )
nextPath , ok := pane . PopFuture ( )
if ! ok {
m . status = "No forward history"
return m , nil
}
// Save current path to back-stack so back navigation can restore it.
pane . PushHistory ( pane . Path )
pane . Path = nextPath
pane . Cursor = 0
pane . Offset = 0
if err := m . reloadPane ( pane . ID , "" ) ; err != nil {
m . status = err . Error ( )
return m , nil
}
m . status = fmt . Sprintf ( "History forward to %s" , nextPath )
return m , m . loadPreviewCmd ( )
}
2026-04-22 22:10:50 +03:00
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 ,
2026-04-22 23:35:42 +03:00
ThemeName : m . cfg . UI . Theme ,
2026-04-24 14:44:49 +03:00
UseNerdIcons : m . nerdIcons ,
2026-04-22 22:10:50 +03:00
}
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 ) {
2026-04-24 15:14:05 +03:00
if m . activePane ( ) . InArchive ( ) && kind != opCopy {
m . status = "Archive mode is read-only; only copy is allowed"
return m , nil
}
2026-04-23 19:57:06 +03:00
sources := m . operationSources ( )
if len ( sources ) == 0 {
2026-04-22 22:10:50 +03:00
m . status = fmt . Sprintf ( "Nothing to %s" , operationVerb ( kind ) )
return m , nil
}
targetDir := m . passivePane ( ) . Path
2026-04-23 19:57:06 +03:00
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 ++
}
2026-04-22 22:10:50 +03:00
}
2026-04-23 12:38:19 +03:00
if kind == opCopy || kind == opMove {
2026-04-23 12:30:10 +03:00
if m . copyJob != nil {
2026-04-23 12:38:19 +03:00
m . status = "Transfer is already running"
2026-04-23 12:30:10 +03:00
return m , nil
}
2026-04-23 19:57:06 +03:00
overwrite := existingTargets > 0
if existingTargets > 0 && ! m . cfg . Behavior . ConfirmOverwrite {
2026-04-23 12:30:10 +03:00
overwrite = true
}
m . busy = true
2026-04-23 19:57:06 +03:00
m . status = fmt . Sprintf ( "Calculating %s size" , operationVerb ( kind ) )
return m , copyPlanCmd ( kind , sources , targetDir , overwrite , existingTargets )
2026-04-23 12:30:10 +03:00
}
2026-04-23 19:57:06 +03:00
return m , nil
2026-04-22 22:10:50 +03:00
}
2026-04-27 15:30:39 +03:00
func ( m * Model ) handleArchive ( ) ( tea . Model , tea . Cmd ) {
sources := m . operationSources ( )
if len ( sources ) == 0 {
m . status = "Nothing to archive"
return m , nil
}
if m . archiveJob != nil {
m . status = "Archive is already running"
return m , nil
}
m . busy = true
m . status = "Calculating archive size"
return m , archivePlanCmd ( sources , m . passivePane ( ) . Path )
}
2026-04-22 22:10:50 +03:00
func ( m * Model ) handleDelete ( ) ( tea . Model , tea . Cmd ) {
2026-04-24 15:14:05 +03:00
if m . activePane ( ) . InArchive ( ) {
m . status = "Archive mode is read-only; delete is disabled"
return m , nil
}
2026-04-23 19:57:06 +03:00
sources := m . operationSources ( )
if len ( sources ) == 0 {
2026-04-22 22:10:50 +03:00
m . status = "Nothing to delete"
return m , nil
}
if ! m . cfg . Behavior . ConfirmDelete {
m . busy = true
2026-04-27 18:56:20 +03:00
m . status = fmt . Sprintf ( "Moving %d entr%s to trash" , len ( sources ) , pluralSuffix ( len ( sources ) , "y" , "ies" ) )
return m , trashPathsCmd ( sources )
}
m . busy = true
m . status = "Calculating trash size"
return m , trashPlanCmd ( sources )
}
func ( m * Model ) handlePermanentDelete ( ) ( tea . Model , tea . Cmd ) {
if m . activePane ( ) . InArchive ( ) {
m . status = "Archive mode is read-only; permanent delete is disabled"
return m , nil
}
sources := m . operationSources ( )
if len ( sources ) == 0 {
m . status = "Nothing to delete"
return m , nil
}
if ! m . cfg . Behavior . ConfirmDelete {
m . busy = true
m . status = fmt . Sprintf ( "Permanently deleting %d entr%s" , len ( sources ) , pluralSuffix ( len ( sources ) , "y" , "ies" ) )
return m , deletePathsPermanentCmd ( sources )
2026-04-22 22:10:50 +03:00
}
2026-04-23 20:37:54 +03:00
m . busy = true
m . status = "Calculating delete size"
2026-04-27 18:56:20 +03:00
return m , deletePlanPermanentCmd ( sources )
2026-04-22 22:10:50 +03:00
}
func ( m * Model ) handleView ( ) ( tea . Model , tea . Cmd ) {
selected , ok := m . activePane ( ) . Selected ( )
if ! ok || selected . IsParent || selected . IsDir {
2026-04-24 10:08:33 +03:00
m . status = "Select a file to view"
return m , nil
2026-04-22 22:10:50 +03:00
}
2026-04-24 10:08:33 +03:00
if m . viewMode {
return m . exitViewMode ( )
2026-04-22 22:10:50 +03:00
}
2026-04-24 10:08:33 +03:00
m . viewPrevInfo = m . infoMode
m . infoMode = true
2026-04-25 00:51:51 +03:00
m . selectMode = m . previewData . Kind == vfs . PreviewKindText
m . visualMode = false
2026-04-24 10:08:33 +03:00
m . viewMode = true
m . resizePreview ( )
m . syncPreviewContent ( )
m . status = "View mode: F3/Esc/q to close"
2026-04-25 00:51:51 +03:00
if m . selectMode {
return m , tea . Batch ( m . loadPreviewCmd ( ) , disableMouseCmd ( ) )
}
return m , tea . Batch ( m . loadPreviewCmd ( ) , enableMouseCmd ( ) )
2026-04-24 10:08:33 +03:00
}
func ( m * Model ) exitViewMode ( ) ( tea . Model , tea . Cmd ) {
if ! m . viewMode {
return m , nil
}
m . viewMode = false
m . selectMode = false
2026-04-25 00:51:51 +03:00
m . visualMode = false
2026-04-24 10:08:33 +03:00
m . infoMode = m . viewPrevInfo
m . resizePreview ( )
m . syncPreviewContent ( )
m . status = "View mode: off"
return m , tea . Batch ( m . loadPreviewCmd ( ) , enableMouseCmd ( ) )
2026-04-22 22:10:50 +03:00
}
2026-04-22 22:50:30 +03:00
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
}
2026-04-24 21:51:56 +03:00
m . cleanupImageOverlay ( )
2026-04-22 22:50:30 +03:00
m . status = fmt . Sprintf ( "Opening %s with %s" , selected . DisplayName ( ) , name )
2026-04-24 22:09:54 +03:00
return m , startExternalOpenCmd ( command , selected . Path )
2026-04-22 22:50:30 +03:00
}
2026-04-22 22:10:50 +03:00
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
}
2026-04-22 22:50:30 +03:00
command , name , err := externalCommandFromEnv ( [ ] string { "VISUAL" , "EDITOR" } , [ ] string { "nvim" , "vim" , "vi" , "nano" } , selected . Path )
2026-04-22 22:10:50 +03:00
if err != nil {
2026-04-22 22:50:30 +03:00
m . status = "Set $VISUAL/$EDITOR or install nvim/vim/vi/nano to enable F4 editing"
2026-04-22 22:10:50 +03:00
return m , nil
}
2026-04-24 21:51:56 +03:00
m . cleanupImageOverlay ( )
2026-04-22 22:10:50 +03:00
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 }
} )
}
2026-04-27 23:18:48 +03:00
func ( m * Model ) handleExecute ( entry vfs . Entry ) ( tea . Model , tea . Cmd ) {
m . cleanupImageOverlay ( )
cmd := exec . Command ( entry . Path )
cmd . Dir = filepath . Dir ( entry . Path )
m . status = fmt . Sprintf ( "Executing %s" , entry . DisplayName ( ) )
return m , tea . ExecProcess ( cmd , func ( err error ) tea . Msg {
return opMsg { kind : opExecute , sourcePath : entry . Path , err : err }
} )
}
2026-04-22 22:50:30 +03:00
func ( m * Model ) handleMouse ( msg tea . MouseMsg ) ( tea . Model , tea . Cmd ) {
2026-04-24 10:08:33 +03:00
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
}
}
2026-04-22 22:50:30 +03:00
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 :
2026-04-27 13:49:45 +03:00
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
}
2026-04-22 22:50:30 +03:00
paneID , index , ok := m . mouseTarget ( msg . X , msg . Y )
if ! ok {
return m , nil
}
m . hover = hoverState { pane : paneID , index : index , ok : true }
2026-04-23 19:57:06 +03:00
if paneID != m . active {
m . left . ClearMarks ( )
m . right . ClearMarks ( )
}
2026-04-22 22:50:30 +03:00
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
2026-04-25 00:51:51 +03:00
m . cursorMode = false
m . visualMode = false
2026-04-22 22:50:30 +03:00
m . resizePreview ( )
m . syncPreviewContent ( )
2026-04-27 13:49:45 +03:00
m . copyPath = ""
2026-04-22 22:50:30 +03:00
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
2026-04-25 00:51:51 +03:00
m . cursorMode = false
m . visualMode = false
2026-04-22 22:50:30 +03:00
m . hover = hoverState { pane : paneID , index : index , ok : true }
2026-04-27 13:49:45 +03:00
m . copyPath = ""
2026-04-22 22:50:30 +03:00
m . status = "Info mode: off"
return m , nil
}
m . hover = hoverState { pane : paneID , index : index , ok : true }
2026-04-23 19:57:06 +03:00
if paneID != m . active {
m . left . ClearMarks ( )
m . right . ClearMarks ( )
}
2026-04-22 22:50:30 +03:00
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
}
}
2026-04-22 22:10:50 +03:00
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 ( )
}
2026-04-25 00:51:51 +03:00
wasSelect := m . selectMode
2026-04-22 23:03:33 +03:00
if m . selectMode {
m . selectMode = false
2026-04-25 00:51:51 +03:00
}
if m . cursorMode {
m . cursorMode = false
}
if m . visualMode {
m . visualMode = false
}
if wasSelect {
2026-04-22 23:03:33 +03:00
return m , enableMouseCmd ( )
}
2026-04-22 22:10:50 +03:00
m . status = "Info mode: off"
return m , nil
}
2026-04-22 23:03:33 +03:00
func ( m * Model ) toggleSelectMode ( ) ( tea . Model , tea . Cmd ) {
2026-04-24 10:08:33 +03:00
if m . viewMode {
2026-04-25 00:51:51 +03:00
m . status = "Use v/y in F3 view for keyboard selection"
2026-04-24 10:08:33 +03:00
return m , nil
}
2026-04-22 23:03:33 +03:00
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 ( )
}
2026-04-25 00:51:51 +03:00
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
}
2026-04-22 22:10:50 +03:00
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
2026-04-24 14:44:49 +03:00
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 )
2026-04-22 22:10:50 +03:00
return m , nil
}
2026-04-25 00:51:51 +03:00
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
}
2026-04-25 02:21:43 +03:00
m . yankFlashLine = line
m . syncPreviewContent ( )
2026-04-25 00:51:51 +03:00
m . status = "Copied current line"
2026-04-25 02:21:43 +03:00
return m , dismissYankFlashCmd ( 140 * time . Millisecond )
2026-04-25 00:51:51 +03:00
}
2026-04-22 22:10:50 +03:00
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 ( ) {
2026-04-24 15:14:05 +03:00
if m . activePane ( ) . InArchive ( ) {
m . status = "Archive mode is read-only; create directory is disabled"
return
}
2026-04-22 22:10:50 +03:00
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 ,
}
}
2026-04-24 13:15:04 +03:00
func ( m * Model ) openRenameModal ( ) {
2026-04-24 15:14:05 +03:00
if m . activePane ( ) . InArchive ( ) {
m . status = "Archive mode is read-only; rename is disabled"
return
}
2026-04-24 13:15:04 +03:00
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 ,
}
}
2026-04-22 22:10:50 +03:00
func ( m * Model ) openConfirmModal ( title , body , note string , pending pendingOperation ) {
m . modal = modalState {
kind : modalConfirm ,
title : title ,
body : body ,
note : note ,
pending : & pending ,
}
}
2026-04-23 14:22:16 +03:00
func ( m * Model ) openHelpModal ( ) {
sections := [ ] string {
"Navigation" ,
" j / Down move down" ,
" k / Up move up" ,
2026-04-23 19:57:06 +03:00
" Shift+Down/J extend selection down" ,
" Shift+Up/K extend selection up" ,
2026-04-23 14:22:16 +03:00
" PgUp / b page up" ,
" Enter / Right open selected entry" ,
" Backspace/Left go to parent directory" ,
" Tab / h / l switch active pane" ,
2026-04-27 18:11:19 +03:00
" Alt+Left directory history back" ,
" Alt+Right directory history forward" ,
" / filter entries by name in current pane" ,
2026-04-24 13:15:04 +03:00
" Ctrl+r refresh both panes" ,
2026-04-23 14:22:16 +03:00
"" ,
"View and Panels" ,
2026-04-27 18:11:19 +03:00
" o toggle preview/info pane" ,
2026-04-25 00:51:51 +03:00
" i show text caret in preview pane" ,
2026-04-25 02:21:43 +03:00
" v start visual selection from caret" ,
2026-04-27 18:11:19 +03:00
" Esc / q close view/info/caret mode" ,
2026-04-25 02:21:43 +03:00
" yy copy current line in caret mode" ,
2026-04-25 00:51:51 +03:00
" y copy visual selection to clipboard" ,
2026-04-25 02:21:43 +03:00
" h / l move caret left/right" ,
" w / b move caret by word" ,
2026-04-25 00:51:51 +03:00
" Ctrl+t mouse selection mode in text preview pane" ,
2026-04-23 14:22:16 +03:00
" Space calculate selected directory size" ,
" s cycle sort mode" ,
" . toggle hidden files" ,
" t cycle theme" ,
"" ,
"Dialogs and Transfers" ,
2026-04-27 18:56:20 +03:00
" F8 / x move selected entry to trash" ,
" F11 / d permanently delete selected entry" ,
2026-04-27 18:11:19 +03:00
" r rename selected entry" ,
2026-04-23 14:22:16 +03:00
" Enter / y confirm action" ,
" Esc / n cancel action" ,
" b run copy/move in background (progress dialog)" ,
2026-04-23 21:46:55 +03:00
" c cancel active copy/move transfer" ,
2026-04-23 14:22:16 +03:00
"" ,
"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" ,
"" ,
2026-04-27 18:11:19 +03:00
"F1– F10 actions are shown in the footer." ,
2026-04-23 14:22:16 +03:00
}
m . modal = modalState {
kind : modalHelp ,
title : "Keyboard and Mouse Help" ,
body : strings . Join ( sections , "\n" ) ,
2026-04-27 16:26:01 +03:00
note : version + " — F1/? or Esc to close" ,
2026-04-23 14:22:16 +03:00
}
m . status = "Help opened"
}
2026-04-22 22:10:50 +03:00
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 ( )
2026-04-27 13:49:45 +03:00
if m . infoMode {
m . copyPath = preview . Metadata . Path
}
2026-04-22 22:10:50 +03:00
}
func ( m * Model ) syncPreviewContent ( ) {
content := m . previewData . Body
2026-04-25 00:51:51 +03:00
if ( m . cursorMode || m . visualMode ) && m . previewData . Kind == vfs . PreviewKindText {
content = m . renderTextCursorContent ( )
}
2026-04-22 22:10:50 +03:00
if m . cfg . Preview . WrapText && m . previewModel . Width > 0 {
content = lipgloss . NewStyle ( ) . Width ( m . previewModel . Width ) . Render ( content )
}
m . previewModel . SetContent ( content )
}
2026-04-25 00:51:51 +03:00
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 )
2026-04-25 02:21:43 +03:00
flashed := lipgloss . NewStyle ( ) .
Background ( m . palette . Accent ) .
Foreground ( m . palette . Background ) .
Bold ( true )
2026-04-25 00:51:51 +03:00
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
}
2026-04-25 02:21:43 +03:00
if idx == m . yankFlashLine {
lines [ idx ] = flashed . Render ( marker + line )
continue
}
2026-04-25 00:51:51 +03:00
lines [ idx ] = marker + line
}
2026-04-27 14:15:20 +03:00
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
2026-04-25 00:51:51 +03:00
}
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
}
}
2026-04-22 22:10:50 +03:00
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 {
2026-04-22 23:03:33 +03:00
height := m . height
2026-04-24 10:23:16 +03:00
if m . cfg . UI . ShowFooter && ! m . viewMode {
2026-04-22 22:10:50 +03:00
height --
}
return max ( height , 8 )
}
func ( m * Model ) resizePreview ( ) {
_ , previewWidth , _ := m . layoutWidths ( )
metaHeight := 0
if m . cfg . Preview . ShowMetadata {
2026-04-22 23:16:29 +03:00
metaHeight = 7
2026-04-22 22:10:50 +03:00
}
2026-04-22 23:16:29 +03:00
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 )
2026-04-22 22:10:50 +03:00
}
2026-04-25 00:51:51 +03:00
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 )
}
}
2026-04-27 13:49:45 +03:00
func renderPreviewPane ( preview vfs . Preview , viewportModel * viewport . Model , cfg config . Config , palette theme . Palette , width int , height int , useNerdfont bool ) string {
2026-04-22 23:03:33 +03:00
innerWidth := max ( width - 2 , 1 )
innerHeight := max ( height - 2 , 1 )
2026-04-23 00:10:41 +03:00
contentWidth := max ( innerWidth - 2 , 1 )
2026-04-22 23:03:33 +03:00
2026-04-22 22:10:50 +03:00
box := lipgloss . NewStyle ( ) .
2026-04-22 23:03:33 +03:00
Width ( innerWidth ) .
Height ( innerHeight ) .
2026-04-22 22:10:50 +03:00
Background ( palette . Panel ) .
Foreground ( palette . Text ) .
BorderStyle ( borderStyle ( cfg . UI . Border ) ) .
2026-04-23 00:10:41 +03:00
BorderForeground ( palette . BorderActive ) .
BorderBackground ( palette . Panel )
2026-04-22 22:10:50 +03:00
title := lipgloss . NewStyle ( ) .
2026-04-23 00:10:41 +03:00
Width ( contentWidth ) .
2026-04-22 22:10:50 +03:00
Padding ( 0 , 1 ) .
2026-04-24 22:15:54 +03:00
Background ( palette . Info ) .
2026-04-22 22:10:50 +03:00
Foreground ( palette . Background ) .
Bold ( true ) .
Render ( "PREVIEW " + previewIcon ( preview ) + " " + preview . Title )
parts := [ ] string { title }
2026-04-22 23:16:29 +03:00
usedHeight := lipgloss . Height ( title )
2026-04-22 22:10:50 +03:00
if cfg . Preview . ShowMetadata {
2026-04-27 13:49:45 +03:00
metaView := renderMetadata ( preview . Metadata , palette , innerWidth , useNerdfont )
2026-04-22 23:16:29 +03:00
parts = append ( parts , metaView )
usedHeight += lipgloss . Height ( metaView )
2026-04-22 22:10:50 +03:00
}
2026-04-22 23:16:29 +03:00
contentHeight := max ( innerHeight - usedHeight , 3 )
viewportModel . Width = max ( innerWidth - 2 , 10 )
viewportModel . Height = max ( contentHeight - 3 , 1 )
2026-04-27 16:52:44 +03:00
// Directory previews: borrow the column layout from the browser pane
// (renderPaneRows + renderColumnsHeader at the same innerWidth),
// but non-interactive (no cursor, no selection).
if preview . Kind == vfs . PreviewKindDirectory && len ( preview . Entries ) > 0 {
dirPane := BrowserPane { Entries : preview . Entries }
headerRow := renderColumnsHeader ( cfg , innerWidth , palette , palette . Panel , useNerdfont )
rows := renderPaneRows ( dirPane , cfg , palette , innerWidth , contentHeight , false , - 1 , palette . Panel , useNerdfont )
parts = append ( parts , lipgloss . JoinVertical ( lipgloss . Left , headerRow , rows ) )
} else {
parts = append ( parts , renderPreviewContent ( viewportModel , palette , innerWidth , contentHeight ) )
}
2026-04-22 22:10:50 +03:00
2026-04-23 00:10:41 +03:00
content := lipgloss . NewStyle ( ) .
Width ( innerWidth ) .
MaxHeight ( innerHeight ) .
Background ( palette . Panel ) .
Render ( lipgloss . JoinVertical ( lipgloss . Left , parts ... ) )
return box . Render ( content )
2026-04-22 22:10:50 +03:00
}
2026-04-22 23:26:45 +03:00
func renderSelectionPane ( preview vfs . Preview , viewportModel * viewport . Model , palette theme . Palette , width int , height int ) string {
2026-04-22 23:35:42 +03:00
content := preview . PlainBody
if strings . TrimSpace ( content ) == "" {
content = preview . Body
}
2026-04-22 23:26:45 +03:00
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 ( ) )
}
2026-04-27 13:49:45 +03:00
func renderMetadata ( meta vfs . Metadata , palette theme . Palette , width int , useNerdfont bool ) string {
2026-04-23 00:10:41 +03:00
outerWidth := max ( width - 2 , 1 )
innerWidth := max ( outerWidth - 2 , 1 )
2026-04-22 22:50:30 +03:00
leftRows := [ ] string {
2026-04-22 22:10:50 +03:00
fmt . Sprintf ( "kind: %s" , fallback ( meta . Kind , "n/a" ) ) ,
fmt . Sprintf ( "size: %s" , metaSize ( meta ) ) ,
fmt . Sprintf ( "created: %s" , fallback ( meta . CreatedAt , "n/a" ) ) ,
2026-04-22 22:50:30 +03:00
}
rightRows := [ ] string {
2026-04-22 22:10:50 +03:00
fmt . Sprintf ( "modified: %s" , fallback ( meta . ModifiedAt , "n/a" ) ) ,
fmt . Sprintf ( "mode: %s" , fallback ( meta . Permissions , "n/a" ) ) ,
}
if meta . ImageFormat != "" {
2026-04-22 22:50:30 +03:00
rightRows = append ( rightRows , fmt . Sprintf ( "image: %s %s" , meta . ImageFormat , meta . ImageSize ) )
2026-04-22 22:10:50 +03:00
}
feat: extended preview for PDF, audio, video via external utilities
Add rich preview support for three new file categories by leveraging
external CLI tools with graceful fallback when tools are missing.
- PDF: text extraction via pdftotext, page count via pdfinfo
- Audio: metadata via ffprobe (duration, bitrate, codec, sample rate, channels)
- Video: metadata via ffprobe (duration, bitrate, video/audio codec, resolution)
- New PreviewKind constants: PDF, Audio, Video
- New Metadata fields for extended preview data
- New extension maps and Category() entries for pdf/audio/video
- Icons: PDF (), audio (), video () in preview header
Closes #5
2026-04-27 19:25:03 +03:00
if meta . Duration != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "duration: %s" , meta . Duration ) )
}
if meta . Bitrate != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "bitrate: %s" , meta . Bitrate ) )
}
if meta . AudioCodec != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "audio: %s" , meta . AudioCodec ) )
}
if meta . VideoCodec != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "video: %s" , meta . VideoCodec ) )
}
if meta . Dimensions != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "resolution: %s" , meta . Dimensions ) )
}
if meta . SampleRate != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "rate: %s" , meta . SampleRate ) )
}
if meta . Channels != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "channels: %s" , meta . Channels ) )
}
if meta . PageCount != "" {
rightRows = append ( rightRows , fmt . Sprintf ( "pages: %s" , meta . PageCount ) )
}
2026-04-22 22:10:50 +03:00
2026-04-23 00:10:41 +03:00
leftWidth := max ( innerWidth / 2 , 18 )
if leftWidth > innerWidth {
leftWidth = innerWidth
}
rightWidth := max ( innerWidth - leftWidth , 0 )
2026-04-24 11:11:03 +03:00
columnHeight := max ( len ( leftRows ) , len ( rightRows ) )
2026-04-22 22:10:50 +03:00
left := lipgloss . NewStyle ( ) .
Width ( leftWidth ) .
2026-04-24 11:11:03 +03:00
Height ( columnHeight ) .
2026-04-23 00:10:41 +03:00
Background ( palette . PanelElevated ) .
2026-04-22 22:10:50 +03:00
Foreground ( palette . Muted ) .
2026-04-22 22:50:30 +03:00
Render ( strings . Join ( leftRows , "\n" ) )
2026-04-22 22:10:50 +03:00
right := lipgloss . NewStyle ( ) .
2026-04-22 22:50:30 +03:00
Width ( rightWidth ) .
2026-04-24 11:11:03 +03:00
Height ( columnHeight ) .
2026-04-23 00:10:41 +03:00
Background ( palette . PanelElevated ) .
2026-04-22 22:50:30 +03:00
Foreground ( palette . Text ) .
Render ( strings . Join ( rightRows , "\n" ) )
2026-04-27 13:49:45 +03:00
copyIcon := "📋"
if useNerdfont {
copyIcon = ""
}
iconWidth := lipgloss . Width ( copyIcon )
pathAvailable := max ( innerWidth - 6 - iconWidth - 3 , 10 ) // "path: "=6, icon, spacing
2026-04-22 22:50:30 +03:00
pathLine := lipgloss . NewStyle ( ) .
2026-04-23 00:10:41 +03:00
Width ( innerWidth ) .
Background ( palette . PanelElevated ) .
2026-04-22 22:10:50 +03:00
Foreground ( palette . Text ) .
2026-04-27 13:49:45 +03:00
Render ( fmt . Sprintf ( "path: %s %s" , truncateMiddle ( meta . Path , pathAvailable ) , copyIcon ) )
2026-04-22 22:10:50 +03:00
return lipgloss . NewStyle ( ) .
2026-04-23 00:10:41 +03:00
Width ( outerWidth ) .
2026-04-22 22:10:50 +03:00
Padding ( 0 , 1 ) .
Background ( palette . PanelElevated ) .
BorderStyle ( lipgloss . NormalBorder ( ) ) .
BorderBottom ( true ) .
BorderForeground ( palette . Border ) .
2026-04-23 00:10:41 +03:00
BorderBackground ( palette . PanelElevated ) .
2026-04-22 22:50:30 +03:00
Render ( lipgloss . JoinVertical (
lipgloss . Left ,
lipgloss . JoinHorizontal ( lipgloss . Top , left , right ) ,
"" ,
pathLine ,
) )
2026-04-22 22:10:50 +03:00
}
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 ) .
2026-04-23 21:18:15 +03:00
Background ( m . palette . StatusBar ) .
2026-04-22 22:10:50 +03:00
Foreground ( m . palette . Text ) .
Render ( summary + " :: " + m . status )
}
func renderFooter ( m Model ) string {
2026-04-23 00:10:41 +03:00
parts := make ( [ ] string , 0 , 8 )
2026-04-24 11:47:05 +03:00
sep := lipgloss . NewStyle ( ) . Background ( m . palette . Footer ) . Render ( " " )
prefix := lipgloss . NewStyle ( ) . Background ( m . palette . Footer ) . Render ( " " )
2026-04-23 00:10:41 +03:00
for _ , binding := range m . keys . ShortHelp ( ) {
help := binding . Help ( )
if help . Key == "" || help . Desc == "" {
continue
}
keyView := lipgloss . NewStyle ( ) .
2026-04-23 21:18:15 +03:00
Background ( m . palette . Footer ) .
2026-04-23 00:10:41 +03:00
Foreground ( m . palette . FooterKey ) .
Bold ( true ) .
Render ( help . Key )
descView := lipgloss . NewStyle ( ) .
2026-04-23 21:18:15 +03:00
Background ( m . palette . Footer ) .
2026-04-23 00:10:41 +03:00
Foreground ( m . palette . Text ) .
Render ( " " + help . Desc )
parts = append ( parts , keyView + descView )
}
2026-04-24 11:47:05 +03:00
line := strings . Join ( parts , sep )
2026-04-22 23:03:33 +03:00
if m . selectMode {
2026-04-23 00:10:41 +03:00
modeLabel := lipgloss . NewStyle ( ) .
2026-04-24 11:47:05 +03:00
Background ( m . palette . Footer ) .
2026-04-23 21:18:15 +03:00
Foreground ( m . palette . Info ) .
2026-04-22 23:03:33 +03:00
Bold ( true ) .
2026-04-23 00:10:41 +03:00
Render ( "SELECT TEXT MODE" )
if line != "" {
2026-04-24 11:47:05 +03:00
line += sep
2026-04-23 00:10:41 +03:00
}
line += modeLabel
}
2026-04-25 00:51:51 +03:00
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
}
2026-04-24 11:47:05 +03:00
line = prefix + line
2026-04-24 11:19:02 +03:00
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
2026-04-22 22:10:50 +03:00
}
2026-04-27 17:14:48 +03:00
func renderFilterBar ( m Model ) string {
prompt := lipgloss . NewStyle ( ) .
Background ( m . palette . Footer ) .
Foreground ( m . palette . FooterKey ) .
Bold ( true ) .
Render ( " / " )
inputView := m . filterInput . View ( )
inputStyle := lipgloss . NewStyle ( ) .
Background ( m . palette . Footer ) .
Foreground ( m . palette . Text )
line := prompt + inputStyle . Render ( inputView )
filtered := m . filteredCount ( m . activePane ( ) )
if m . filterQuery != "" {
countStyle := lipgloss . NewStyle ( ) .
Background ( m . palette . Footer ) .
Foreground ( m . palette . Muted )
line += countStyle . Render ( fmt . Sprintf ( " (%d)" , filtered ) )
}
line = ansi . Truncate ( line , m . width , "" )
fill := m . width - ansi . StringWidth ( line )
if fill > 0 {
line += lipgloss . NewStyle ( ) .
Background ( m . palette . Footer ) .
Render ( strings . Repeat ( " " , fill ) )
}
return line
}
2026-04-23 12:30:10 +03:00
func renderModal ( m Model , palette theme . Palette , width int ) string {
if m . modal . kind == modalCopyProgress && m . copyJob != nil {
return renderCopyProgressModal ( * m . copyJob , palette , width )
}
2026-04-27 15:30:39 +03:00
if m . modal . kind == modalArchiveProgress && m . archiveJob != nil {
return renderArchiveProgressModal ( * m . archiveJob , palette , width )
}
2026-04-23 14:22:16 +03:00
if m . modal . kind == modalHelp {
return renderHelpModal ( m . modal , palette , width )
}
2026-04-23 12:30:10 +03:00
modal := m . modal
2026-04-23 21:10:15 +03:00
outerWidth := max ( width , 8 )
contentWidth := max ( outerWidth - 6 , 1 )
2026-04-23 12:30:10 +03:00
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 ( " " )
2026-04-22 22:10:50 +03:00
box := lipgloss . NewStyle ( ) .
2026-04-23 21:10:15 +03:00
Width ( contentWidth ) .
2026-04-22 22:10:50 +03:00
Padding ( 1 , 2 ) .
Background ( palette . Panel ) .
Foreground ( palette . Text ) .
BorderStyle ( lipgloss . DoubleBorder ( ) ) .
2026-04-23 21:10:15 +03:00
BorderForeground ( palette . BorderActive ) .
BorderBackground ( palette . Panel )
2026-04-22 22:10:50 +03:00
2026-04-23 20:37:54 +03:00
lines := [ ] string { titleStyle . Render ( modal . title ) , spacer }
for _ , raw := range strings . Split ( modal . body , "\n" ) {
lines = append ( lines , renderModalBodyLine ( raw , contentWidth , palette ) )
}
2026-04-22 22:10:50 +03:00
2026-04-24 13:15:04 +03:00
if modal . kind == modalMkdir || modal . kind == modalRename {
2026-04-23 12:30:10 +03:00
lines = append ( lines , spacer , lipgloss . NewStyle ( ) . Width ( contentWidth ) . Background ( palette . Panel ) . Render ( modal . input . View ( ) ) )
2026-04-22 22:10:50 +03:00
}
if modal . note != "" {
2026-04-23 20:37:54 +03:00
lines = append ( lines , spacer )
if modal . note == "confirm-actions" {
2026-04-24 13:24:57 +03:00
lines = append ( lines , renderModalNoteLine ( "Enter to confirm, Esc to cancel" , contentWidth , palette , noteStyle ) )
2026-04-23 20:37:54 +03:00
} else {
for _ , raw := range strings . Split ( modal . note , "\n" ) {
lines = append ( lines , renderModalNoteLine ( raw , contentWidth , palette , noteStyle ) )
}
}
2026-04-23 12:30:10 +03:00
}
return box . Render ( strings . Join ( lines , "\n" ) )
}
2026-04-23 20:37:54 +03:00
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 ( "" )
}
2026-04-24 13:24:57 +03:00
if highlighted , ok := renderModalHintTokens ( raw , width , palette , palette . Muted ) ; ok {
return highlighted
2026-04-24 13:15:04 +03:00
}
2026-04-23 20:37:54 +03:00
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 )
}
2026-04-24 13:24:57 +03:00
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
}
2026-04-23 20:37:54 +03:00
func renderConfirmActions ( width int , palette theme . Palette ) string {
2026-04-23 21:10:15 +03:00
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 ) )
2026-04-23 20:37:54 +03:00
confirm := lipgloss . NewStyle ( ) .
Width ( buttonWidth ) .
Align ( lipgloss . Center ) .
2026-04-23 21:18:15 +03:00
Background ( palette . ConfirmButton ) .
2026-04-23 20:37:54 +03:00
Foreground ( palette . Background ) .
Bold ( true ) .
Render ( "Enter / y" )
2026-04-23 21:10:15 +03:00
2026-04-23 20:37:54 +03:00
cancel := lipgloss . NewStyle ( ) .
Width ( buttonWidth ) .
Align ( lipgloss . Center ) .
2026-04-23 21:18:15 +03:00
Background ( palette . CancelButton ) .
2026-04-23 20:37:54 +03:00
Foreground ( palette . Background ) .
Bold ( true ) .
Render ( "Esc / n" )
2026-04-23 21:10:15 +03:00
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 ) ,
)
2026-04-23 20:37:54 +03:00
return lipgloss . NewStyle ( ) .
Width ( width ) .
Background ( palette . Panel ) .
Render ( row )
}
2026-04-23 14:22:16 +03:00
func renderHelpModal ( modal modalState , palette theme . Palette , width int ) string {
2026-04-23 21:10:15 +03:00
outerWidth := max ( width , 8 )
contentWidth := max ( outerWidth - 6 , 1 )
2026-04-23 14:22:16 +03:00
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 ( ) .
2026-04-23 21:10:15 +03:00
Width ( contentWidth ) .
2026-04-23 14:22:16 +03:00
Padding ( 1 , 2 ) .
Background ( palette . Panel ) .
Foreground ( palette . Text ) .
BorderStyle ( lipgloss . DoubleBorder ( ) ) .
2026-04-23 21:10:15 +03:00
BorderForeground ( palette . BorderActive ) .
BorderBackground ( palette . Panel )
2026-04-23 14:22:16 +03:00
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
}
2026-04-23 21:18:15 +03:00
sectionColor := sectionColorForHeader ( trimmed , palette )
2026-04-23 14:22:16 +03:00
header := lipgloss . NewStyle ( ) .
Width ( contentWidth ) .
Background ( palette . Panel ) .
Foreground ( sectionColor ) .
Bold ( true ) .
Render ( trimmed )
lines = append ( lines , header )
}
if modal . note != "" {
2026-04-24 13:24:57 +03:00
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 ) )
}
2026-04-23 14:22:16 +03:00
}
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 , ""
}
2026-04-23 21:18:15 +03:00
func sectionColorForHeader ( header string , palette theme . Palette ) lipgloss . Color {
2026-04-23 14:22:16 +03:00
switch header {
case "Navigation" :
2026-04-23 21:18:15 +03:00
return palette . HelpNav
2026-04-23 14:22:16 +03:00
case "View and Panels" :
2026-04-23 21:18:15 +03:00
return palette . HelpPanels
2026-04-23 14:22:16 +03:00
case "Dialogs and Transfers" :
2026-04-23 21:18:15 +03:00
return palette . HelpDialogs
2026-04-23 14:22:16 +03:00
case "Mouse" :
2026-04-23 21:18:15 +03:00
return palette . HelpMouse
2026-04-23 14:22:16 +03:00
default :
2026-04-23 21:18:15 +03:00
return palette . Accent
2026-04-23 14:22:16 +03:00
}
}
2026-04-23 12:30:10 +03:00
func renderCopyProgressModal ( job copyJobState , palette theme . Palette , width int ) string {
2026-04-23 21:10:15 +03:00
outerWidth := max ( width , 8 )
contentWidth := max ( outerWidth - 6 , 1 )
2026-04-23 12:30:10 +03:00
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 ( ) .
2026-04-23 21:10:15 +03:00
Width ( contentWidth ) .
2026-04-23 12:30:10 +03:00
Padding ( 1 , 2 ) .
Background ( palette . Panel ) .
Foreground ( palette . Text ) .
BorderStyle ( lipgloss . DoubleBorder ( ) ) .
2026-04-23 21:10:15 +03:00
BorderForeground ( palette . BorderActive ) .
BorderBackground ( palette . Panel )
2026-04-23 12:30:10 +03:00
progress := job . progress
ratio := 0.0
if progress . BytesTotal > 0 {
ratio = float64 ( progress . BytesDone ) / float64 ( progress . BytesTotal )
}
lines := [ ] string {
2026-04-23 12:38:19 +03:00
titleStyle . Render ( progressTitle ( job . kind ) ) ,
2026-04-23 12:30:10 +03:00
spacer ,
2026-04-23 22:46:08 +03:00
renderProgressBarLine ( ratio , contentWidth , palette ) ,
2026-04-23 21:46:55 +03:00
spacer ,
renderProgressPercentLine ( ratio , contentWidth , palette ) ,
2026-04-23 22:46:08 +03:00
renderProgressStatLine ( "Stage:" , progressStageLabel ( progress , job . kind ) , contentWidth , palette ) ,
spacer ,
2026-04-23 21:46:55 +03:00
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 ,
2026-04-24 13:24:57 +03:00
renderModalNoteLine ( "Background / b, Cancel / c" , contentWidth , palette , mutedStyle ) ,
2026-04-22 22:10:50 +03:00
}
2026-04-23 21:46:55 +03:00
if job . background {
lines = append ( lines , mutedStyle . Render ( "Transfer continues in background" ) )
2026-04-23 12:30:10 +03:00
}
return box . Render ( strings . Join ( lines , "\n" ) )
}
2026-04-27 15:30:39 +03:00
func renderArchiveProgressModal ( job archiveJobState , palette theme . Palette , width int ) string {
outerWidth := max ( width , 8 )
contentWidth := max ( outerWidth - 6 , 1 )
titleStyle := lipgloss . NewStyle ( ) . Width ( contentWidth ) . Background ( palette . Panel ) . Bold ( true ) . Foreground ( palette . Accent )
mutedStyle := lipgloss . NewStyle ( ) . Width ( contentWidth ) . Background ( palette . Panel ) . Foreground ( palette . Muted )
spacer := lipgloss . NewStyle ( ) . Width ( contentWidth ) . Background ( palette . Panel ) . Render ( " " )
box := lipgloss . NewStyle ( ) .
Width ( contentWidth ) .
Padding ( 1 , 2 ) .
Background ( palette . Panel ) .
Foreground ( palette . Text ) .
BorderStyle ( lipgloss . DoubleBorder ( ) ) .
BorderForeground ( palette . BorderActive ) .
BorderBackground ( palette . Panel )
progress := job . progress
ratio := 0.0
if progress . BytesTotal > 0 {
ratio = float64 ( progress . BytesDone ) / float64 ( progress . BytesTotal )
}
stage := progress . Stage
if stage == "" {
stage = "Archiving data"
}
lines := [ ] string {
titleStyle . Render ( "Archiving" ) ,
spacer ,
renderProgressBarLine ( ratio , contentWidth , palette ) ,
spacer ,
renderProgressPercentLine ( ratio , contentWidth , palette ) ,
renderProgressStatLine ( "Stage:" , stage , contentWidth , palette ) ,
spacer ,
renderProgressStatLine ( "Files:" , fmt . Sprintf ( "%d / %d" , progress . FilesDone , progress . FilesTotal ) , contentWidth , palette ) ,
renderProgressStatLine ( "Size:" , fmt . Sprintf ( "%s / %s" , formatSize ( progress . BytesDone , true ) , formatSize ( progress . BytesTotal , true ) ) , contentWidth , palette ) ,
renderProgressStatLine ( "Speed:" , transferSpeed ( progress . BytesDone , job . startedAt ) , contentWidth , palette ) ,
spacer ,
renderModalNoteLine ( "Background / b, Cancel / c" , contentWidth , palette , mutedStyle ) ,
}
if job . background {
lines = append ( lines , mutedStyle . Render ( "Archive continues in background" ) )
}
return box . Render ( strings . Join ( lines , "\n" ) )
}
2026-04-23 12:30:10 +03:00
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
}
2026-04-23 21:18:15 +03:00
bar := lipgloss . NewStyle ( ) . Foreground ( palette . ProgressFill ) . Render ( strings . Repeat ( "█" , filled ) )
rest := lipgloss . NewStyle ( ) . Foreground ( palette . ProgressEmpty ) . Render ( strings . Repeat ( "░" , width - filled ) )
2026-04-23 21:46:55 +03:00
return bar + rest
}
2026-04-23 22:46:08 +03:00
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 )
}
2026-04-23 21:46:55 +03:00
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 {
2026-04-24 09:45:27 +03:00
const minButtonWidth = 10
const maxButtonWidth = 14
const gapWidth = 4
2026-04-23 21:46:55 +03:00
labelWidth := max ( lipgloss . Width ( "Background / b" ) , lipgloss . Width ( "Cancel / c" ) )
buttonWidth := min ( max ( labelWidth + 2 , minButtonWidth ) , maxButtonWidth )
2026-04-24 09:45:27 +03:00
buttonWidth = min ( buttonWidth , max ( ( width - gapWidth ) / 2 , labelWidth ) )
2026-04-23 21:46:55 +03:00
backgroundBtn := lipgloss . NewStyle ( ) .
Width ( buttonWidth ) .
Align ( lipgloss . Center ) .
Background ( palette . Info ) .
Foreground ( palette . Background ) .
Bold ( true ) .
Render ( "Background / b" )
2026-04-24 09:45:27 +03:00
2026-04-23 21:46:55 +03:00
cancelBtn := lipgloss . NewStyle ( ) .
Width ( buttonWidth ) .
Align ( lipgloss . Center ) .
Background ( palette . CancelButton ) .
Foreground ( palette . Background ) .
Bold ( true ) .
Render ( "Cancel / c" )
2026-04-24 09:45:27 +03:00
gap := lipgloss . NewStyle ( ) .
Width ( gapWidth ) .
2026-04-23 21:46:55 +03:00
Background ( palette . Panel ) .
Render ( "" )
2026-04-24 09:45:27 +03:00
enterBias := lipgloss . NewStyle ( ) .
Width ( 9 ) .
2026-04-23 21:46:55 +03:00
Background ( palette . Panel ) .
Render ( "" )
2026-04-24 09:45:27 +03:00
cancelTail := lipgloss . NewStyle ( ) .
Width ( 5 ) .
2026-04-23 21:46:55 +03:00
Background ( palette . Panel ) .
Render ( "" )
2026-04-24 09:45:27 +03:00
group := lipgloss . JoinHorizontal ( lipgloss . Top , backgroundBtn , enterBias , gap , cancelBtn , cancelTail )
row := lipgloss . PlaceHorizontal (
width ,
lipgloss . Center ,
group ,
lipgloss . WithWhitespaceBackground ( palette . Panel ) ,
)
2026-04-23 21:46:55 +03:00
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 ) .
2026-04-23 22:46:08 +03:00
Foreground ( palette . Info ) .
Bold ( true ) .
Render ( fmt . Sprintf ( "%.0f%%" , ratio * 100 ) )
return percent
2026-04-23 21:46:55 +03:00
}
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 ) )
2026-04-22 22:10:50 +03:00
}
2026-04-23 22:46:08 +03:00
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"
}
2026-04-22 22:10:50 +03:00
func overlayCenter ( base string , overlay string , width int ) string {
if width <= 0 {
2026-04-23 12:30:10 +03:00
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
2026-04-22 22:10:50 +03:00
}
2026-04-23 12:30:10 +03:00
return strings . Join ( baseLines , "\n" )
2026-04-22 22:10:50 +03:00
}
2026-04-22 23:16:29 +03:00
func renderPreviewContent ( viewportModel * viewport . Model , palette theme . Palette , width int , height int ) string {
2026-04-23 00:10:41 +03:00
outerWidth := max ( width - 2 , 1 )
innerWidth := max ( outerWidth - 2 , 1 )
2026-04-23 14:41:17 +03:00
innerHeight := max ( height , 1 )
2026-04-22 22:10:50 +03:00
body := lipgloss . NewStyle ( ) .
2026-04-23 00:10:41 +03:00
Width ( innerWidth ) .
2026-04-23 14:41:17 +03:00
Height ( max ( innerHeight , 1 ) ) .
2026-04-22 22:10:50 +03:00
Padding ( 0 , 1 ) .
Background ( palette . Panel ) .
Render ( viewportModel . View ( ) )
return lipgloss . NewStyle ( ) .
2026-04-23 00:10:41 +03:00
Width ( outerWidth ) .
2026-04-22 23:16:29 +03:00
Height ( innerHeight ) .
2026-04-23 00:10:41 +03:00
Background ( palette . Panel ) .
2026-04-22 22:10:50 +03:00
BorderStyle ( lipgloss . NormalBorder ( ) ) .
BorderTop ( true ) .
BorderForeground ( palette . Border ) .
2026-04-23 00:10:41 +03:00
BorderBackground ( palette . Panel ) .
2026-04-23 14:41:17 +03:00
Render ( body )
2026-04-22 22:10:50 +03:00
}
func previewIcon ( preview vfs . Preview ) string {
switch preview . Kind {
case vfs . PreviewKindDirectory :
return ""
case vfs . PreviewKindImage :
return ""
case vfs . PreviewKindText :
return ""
feat: extended preview for PDF, audio, video via external utilities
Add rich preview support for three new file categories by leveraging
external CLI tools with graceful fallback when tools are missing.
- PDF: text extraction via pdftotext, page count via pdfinfo
- Audio: metadata via ffprobe (duration, bitrate, codec, sample rate, channels)
- Video: metadata via ffprobe (duration, bitrate, video/audio codec, resolution)
- New PreviewKind constants: PDF, Audio, Video
- New Metadata fields for extended preview data
- New extension maps and Category() entries for pdf/audio/video
- Icons: PDF (), audio (), video () in preview header
Closes #5
2026-04-27 19:25:03 +03:00
case vfs . PreviewKindPDF :
return ""
case vfs . PreviewKindAudio :
return ""
case vfs . PreviewKindVideo :
return ""
2026-04-22 22:10:50 +03:00
case vfs . PreviewKindBinary :
return ""
case vfs . PreviewKindError :
return ""
default :
return ""
}
}
func ( p pendingOperation ) cmd ( ) tea . Cmd {
switch p . kind {
case opMove :
2026-04-23 19:57:06 +03:00
return nil
2026-04-22 22:10:50 +03:00
case opDelete :
2026-04-27 18:56:20 +03:00
return trashPathsCmd ( p . sourcePaths )
case opPermanentDelete :
return deletePathsPermanentCmd ( p . sourcePaths )
2026-04-22 22:10:50 +03:00
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 }
}
}
2026-04-23 19:57:06 +03:00
func copyPlanCmd ( kind fileOpKind , sourcePaths [ ] string , targetDir string , overwrite bool , existingTargets int ) tea . Cmd {
2026-04-22 22:10:50 +03:00
return func ( ) tea . Msg {
2026-04-23 19:57:06 +03:00
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
}
2026-04-23 12:30:10 +03:00
return copyPlanMsg {
2026-04-23 19:57:06 +03:00
kind : kind ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetDir : targetDir ,
overwrite : overwrite ,
existingTargets : existingTargets ,
stats : stats ,
err : err ,
2026-04-23 12:30:10 +03:00
}
}
}
2026-04-24 15:14:05 +03:00
func ( m * Model ) enterArchive ( selected vfs . Entry ) error {
pane := m . activePane ( )
2026-04-27 18:11:19 +03:00
// Save current path to history before opening the archive.
pane . PushHistory ( pane . Path )
2026-04-24 15:14:05 +03:00
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
2026-04-27 15:30:39 +03:00
pane . Cursor = 0
pane . Offset = 0
2026-04-24 15:14:05 +03:00
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 )
}
}
}
2026-04-27 15:30:39 +03:00
func archivePlanCmd ( sourcePaths [ ] string , targetDir string ) tea . Cmd {
return func ( ) tea . Msg {
stats := vfs . TransferStats { }
var err error
for _ , sourcePath := range sourcePaths {
part , statErr := vfs . CopyStats ( sourcePath )
if statErr != nil {
err = statErr
break
}
stats . FilesTotal += part . FilesTotal
stats . BytesTotal += part . BytesTotal
}
return archivePlanMsg {
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetDir : targetDir ,
stats : stats ,
err : err ,
}
}
}
2026-04-23 12:30:10 +03:00
func waitCopyProgressCmd ( ch <- chan tea . Msg ) tea . Cmd {
return func ( ) tea . Msg {
return <- ch
2026-04-22 22:10:50 +03:00
}
}
2026-04-27 15:30:39 +03:00
func waitArchiveProgressCmd ( ch <- chan tea . Msg ) tea . Cmd {
return func ( ) tea . Msg {
return <- ch
}
}
2026-04-23 12:30:10 +03:00
func dismissNoticeCmd ( delay time . Duration ) tea . Cmd {
return tea . Tick ( delay , func ( time . Time ) tea . Msg {
return dismissNoticeMsg { }
} )
}
2026-04-25 02:21:43 +03:00
func dismissYankFlashCmd ( delay time . Duration ) tea . Cmd {
return tea . Tick ( delay , func ( time . Time ) tea . Msg {
return dismissYankFlashMsg { }
} )
}
2026-04-23 19:57:06 +03:00
func ( m * Model ) startCopyJob ( kind fileOpKind , sourcePaths [ ] string , targetDir string , overwrite bool , stats vfs . TransferStats ) tea . Cmd {
2026-04-23 12:30:10 +03:00
m . nextCopyJob ++
jobID := m . nextCopyJob
2026-04-23 21:46:55 +03:00
ctx , cancel := context . WithCancel ( context . Background ( ) )
2026-04-23 12:30:10 +03:00
m . copyJob = & copyJobState {
2026-04-23 19:57:06 +03:00
id : jobID ,
kind : kind ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetDir : targetDir ,
overwrite : overwrite ,
2026-04-23 12:30:10 +03:00
progress : vfs . CopyProgress {
FilesDone : 0 ,
FilesTotal : stats . FilesTotal ,
BytesDone : 0 ,
BytesTotal : stats . BytesTotal ,
2026-04-23 19:57:06 +03:00
CurrentPath : sourcePaths [ 0 ] ,
2026-04-23 12:30:10 +03:00
} ,
2026-04-23 21:46:55 +03:00
cancel : cancel ,
startedAt : time . Now ( ) ,
2026-04-23 12:30:10 +03:00
}
m . modal = modalState { kind : modalCopyProgress }
2026-04-23 12:38:19 +03:00
m . status = strings . Title ( operationVerb ( kind ) ) + " started"
2026-04-23 12:30:10 +03:00
return tea . Batch (
func ( ) tea . Msg {
go func ( ) {
2026-04-23 19:57:06 +03:00
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 ,
2026-04-23 22:46:08 +03:00
Stage : progress . Stage ,
2026-04-23 19:57:06 +03:00
} ,
}
}
switch kind {
case opMove :
2026-04-23 21:46:55 +03:00
_ , statErr = vfs . MovePathWithProgressContext ( ctx , sourcePath , targetDir , overwrite , entryStats , progressFn )
2026-04-23 19:57:06 +03:00
default :
2026-04-23 21:46:55 +03:00
_ , statErr = vfs . CopyPathWithProgressContext ( ctx , sourcePath , targetDir , overwrite , entryStats , progressFn )
2026-04-23 19:57:06 +03:00
}
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
2026-04-23 12:38:19 +03:00
}
2026-04-23 12:30:10 +03:00
m . copyProgress <- copyDoneMsg {
2026-04-23 19:57:06 +03:00
jobID : jobID ,
kind : kind ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetDir : targetDir ,
2026-04-23 12:30:10 +03:00
}
} ( )
return nil
} ,
waitCopyProgressCmd ( m . copyProgress ) ,
)
}
2026-04-27 15:30:39 +03:00
func ( m * Model ) startArchiveJob ( sourcePaths [ ] string , targetDir string , format string , stats vfs . TransferStats ) tea . Cmd {
m . nextArchiveJob ++
jobID := m . nextArchiveJob
ctx , cancel := context . WithCancel ( context . Background ( ) )
archiveName := vfs . ArchiveName ( sourcePaths , format )
archivePath := filepath . Join ( targetDir , archiveName )
m . archiveJob = & archiveJobState {
id : jobID ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetPath : archivePath ,
progress : vfs . CopyProgress {
FilesDone : 0 ,
FilesTotal : stats . FilesTotal ,
BytesDone : 0 ,
BytesTotal : stats . BytesTotal ,
CurrentPath : sourcePaths [ 0 ] ,
} ,
cancel : cancel ,
startedAt : time . Now ( ) ,
}
m . modal = modalState { kind : modalArchiveProgress }
m . status = "Archiving started"
return tea . Batch (
func ( ) tea . Msg {
go func ( ) {
emitProgress := func ( p vfs . CopyProgress ) {
m . archiveProgress <- archiveProgressMsg {
jobID : jobID ,
progress : p ,
}
}
err := vfs . CreateArchive ( ctx , sourcePaths , archivePath , emitProgress )
if err != nil {
m . archiveProgress <- archiveDoneMsg {
jobID : jobID ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetPath : archivePath ,
err : err ,
}
return
}
m . archiveProgress <- archiveDoneMsg {
jobID : jobID ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
targetPath : archivePath ,
}
} ( )
return nil
} ,
waitArchiveProgressCmd ( m . archiveProgress ) ,
)
}
2026-04-22 22:10:50 +03:00
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 }
}
}
2026-04-27 18:56:20 +03:00
func trashPathsCmd ( paths [ ] string ) tea . Cmd {
2026-04-22 22:10:50 +03:00
return func ( ) tea . Msg {
2026-04-23 19:57:06 +03:00
for _ , path := range paths {
2026-04-27 18:56:20 +03:00
if err := vfs . MoveToTrash ( path ) ; err != nil {
2026-04-23 19:57:06 +03:00
return opMsg { kind : opDelete , sourcePath : path , err : err }
}
}
return opMsg { kind : opDelete }
2026-04-22 22:10:50 +03:00
}
}
2026-04-27 18:56:20 +03:00
func deletePathsPermanentCmd ( paths [ ] string ) tea . Cmd {
return func ( ) tea . Msg {
for _ , path := range paths {
if err := vfs . DeletePath ( path ) ; err != nil {
return opMsg { kind : opPermanentDelete , sourcePath : path , err : err }
}
}
return opMsg { kind : opPermanentDelete }
}
}
func trashPlanCmd ( sourcePaths [ ] string ) tea . Cmd {
return func ( ) tea . Msg {
stats := vfs . TransferStats { }
var err error
for _ , sourcePath := range sourcePaths {
part , statErr := vfs . CopyStats ( sourcePath )
if statErr != nil {
err = statErr
break
}
stats . FilesTotal += part . FilesTotal
stats . BytesTotal += part . BytesTotal
}
return deletePlanMsg {
kind : opDelete ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
stats : stats ,
err : err ,
}
}
}
func deletePlanPermanentCmd ( sourcePaths [ ] string ) tea . Cmd {
return func ( ) tea . Msg {
stats := vfs . TransferStats { }
var err error
for _ , sourcePath := range sourcePaths {
part , statErr := vfs . CopyStats ( sourcePath )
if statErr != nil {
err = statErr
break
}
stats . FilesTotal += part . FilesTotal
stats . BytesTotal += part . BytesTotal
}
return deletePlanMsg {
kind : opPermanentDelete ,
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
stats : stats ,
err : err ,
}
}
}
2026-04-22 22:10:50 +03:00
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 }
}
}
2026-04-24 13:15:04 +03:00
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 }
}
}
2026-04-22 22:10:50 +03:00
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 )
}
2026-04-23 12:38:19 +03:00
func formatCopyStatus ( kind fileOpKind , progress vfs . CopyProgress ) string {
2026-04-23 12:30:10 +03:00
return fmt . Sprintf (
2026-04-23 12:38:19 +03:00
"%s in background: %d/%d files, %s/%s" ,
strings . Title ( operationVerb ( kind ) ) ,
2026-04-23 12:30:10 +03:00
progress . FilesDone ,
progress . FilesTotal ,
formatSize ( progress . BytesDone , true ) ,
formatSize ( progress . BytesTotal , true ) ,
)
}
2026-04-27 15:30:39 +03:00
func formatArchiveStatus ( progress vfs . CopyProgress ) string {
return fmt . Sprintf (
"Archiving in background: %d/%d files, %s/%s" ,
progress . FilesDone ,
progress . FilesTotal ,
formatSize ( progress . BytesDone , true ) ,
formatSize ( progress . BytesTotal , true ) ,
)
}
2026-04-23 19:57:06 +03:00
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
}
2026-04-23 12:38:19 +03:00
func progressTitle ( kind fileOpKind ) string {
switch kind {
case opMove :
return "Moving"
2026-04-27 15:30:39 +03:00
case opArchive :
return "Archiving"
2026-04-23 12:38:19 +03:00
default :
return "Copying"
}
}
func operationDoneLabel ( kind fileOpKind ) string {
switch kind {
case opMove :
return "Moved"
case opCopy :
return "Copied"
case opDelete :
2026-04-27 18:56:20 +03:00
return "Moved to trash"
case opPermanentDelete :
return "Permanently deleted"
2026-04-27 15:30:39 +03:00
case opArchive :
return "Archived"
2026-04-23 12:38:19 +03:00
default :
return "Done"
}
}
2026-04-22 22:10:50 +03:00
func operationVerb ( kind fileOpKind ) string {
switch kind {
case opCopy :
return "copy"
case opMove :
return "move"
case opDelete :
2026-04-27 18:56:20 +03:00
return "trash"
case opPermanentDelete :
return "permanent delete"
2026-04-27 15:30:39 +03:00
case opArchive :
return "archive"
2026-04-22 22:10:50 +03:00
default :
return "operate on"
}
}
func externalCommand ( envVar string , fallbacks [ ] string , path string ) ( * exec . Cmd , string , error ) {
2026-04-22 22:50:30 +03:00
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
}
}
2026-04-22 22:10:50 +03:00
if commandLine == "" {
for _ , candidate := range fallbacks {
if resolved , err := exec . LookPath ( candidate ) ; err == nil {
commandLine = resolved
2026-04-22 22:50:30 +03:00
source = candidate
2026-04-22 22:10:50 +03:00
break
}
}
}
if commandLine == "" {
2026-04-22 22:50:30 +03:00
if len ( envVars ) > 0 {
return nil , "" , fmt . Errorf ( "no command for %s" , strings . Join ( envVars , "/" ) )
}
return nil , "" , fmt . Errorf ( "no fallback command found" )
2026-04-22 22:10:50 +03:00
}
parts := strings . Fields ( commandLine )
if len ( parts ) == 0 {
2026-04-22 22:50:30 +03:00
return nil , "" , fmt . Errorf ( "invalid command for %s" , source )
2026-04-22 22:10:50 +03:00
}
args := append ( parts [ 1 : ] , path )
return exec . Command ( parts [ 0 ] , args ... ) , filepath . Base ( parts [ 0 ] ) , nil
}
2026-04-24 22:09:54 +03:00
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 }
}
}
2026-04-22 22:50:30 +03:00
func enableMouseCmd ( ) tea . Cmd {
return func ( ) tea . Msg {
return tea . EnableMouseCellMotion ( )
}
}
2026-04-22 23:03:33 +03:00
func disableMouseCmd ( ) tea . Cmd {
return func ( ) tea . Msg {
return tea . DisableMouse ( )
}
}
2026-04-22 22:10:50 +03:00
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
}
2026-04-25 00:51:51 +03:00
func clamp ( n , low , high int ) int {
if n < low {
return low
}
if n > high {
return high
}
return n
}
2026-04-22 22:10:50 +03:00
func max ( a , b int ) int {
if a > b {
return a
}
return b
}
2026-04-22 22:50:30 +03:00
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
}
2026-04-22 23:03:33 +03:00
index , ok := paneIndexFromMouse ( y - top , bodyHeight , & m . left )
if ! ok {
return "" , 0 , false
}
return PaneLeft , index , true
2026-04-22 22:50:30 +03:00
case x >= rightStart && x < rightStart + rightWidth :
if m . infoMode && m . active == PaneLeft {
return "" , 0 , false
}
2026-04-22 23:03:33 +03:00
index , ok := paneIndexFromMouse ( y - top , bodyHeight , & m . right )
if ! ok {
return "" , 0 , false
}
return PaneRight , index , true
2026-04-22 22:50:30 +03:00
default :
return "" , 0 , false
}
}
2026-04-22 23:03:33 +03:00
func paneIndexFromMouse ( localY int , height int , pane * BrowserPane ) ( int , bool ) {
2026-04-22 23:16:29 +03:00
const listStartY = 3
if localY < listStartY || localY >= height - 1 {
2026-04-22 23:03:33 +03:00
return 0 , false
2026-04-22 22:50:30 +03:00
}
2026-04-22 23:16:29 +03:00
row := localY - listStartY
2026-04-22 22:50:30 +03:00
index := pane . Offset + row
if index < 0 {
index = 0
}
if index >= len ( pane . Entries ) {
2026-04-22 23:03:33 +03:00
return 0 , false
2026-04-22 22:50:30 +03:00
}
2026-04-22 23:03:33 +03:00
return index , true
2026-04-22 22:50:30 +03:00
}
func isEditableEntry ( entry vfs . Entry ) bool {
switch entry . Category ( ) {
2026-04-27 23:18:48 +03:00
case "text" , "config" :
2026-04-22 22:50:30 +03:00
return true
default :
return false
}
}
2026-04-24 15:14:05 +03:00
func isArchiveEntry ( entry vfs . Entry ) bool {
return ! entry . IsDir && ! entry . IsParent && entry . Category ( ) == "archive"
}
2026-04-24 21:51:56 +03:00
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
}
2026-04-24 22:09:54 +03:00
innerWidth := max ( previewWidth - 2 , 1 )
metaHeight := 0
if m . cfg . Preview . ShowMetadata {
2026-04-27 13:49:45 +03:00
metaHeight = lipgloss . Height ( renderMetadata ( m . previewData . Metadata , m . palette , innerWidth , m . nerdIcons ) )
2026-04-24 22:09:54 +03:00
}
titleHeight := 1
topInset := 1
contentBorder := 1
safetyGap := 1
contentTop := topInset + titleHeight + metaHeight + contentBorder + safetyGap
2026-04-24 21:51:56 +03:00
rect = overlayRect {
2026-04-24 22:09:54 +03:00
x : startX + 3 ,
y : contentTop ,
width : max ( previewWidth - 6 , 1 ) ,
height : max ( bodyHeight - contentTop - 2 , 1 ) ,
2026-04-24 21:51:56 +03:00
}
} 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 ( )
}
2026-04-22 22:50:30 +03:00
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
}
2026-04-27 13:49:45 +03:00
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
}