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"
"os"
"os/exec"
"path/filepath"
"strings"
2026-04-22 22:50:30 +03:00
"time"
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"
)
type modalKind int
const (
modalNone modalKind = iota
modalMkdir
modalConfirm
2026-04-23 12:30:10 +03:00
modalCopyProgress
modalNotice
2026-04-23 14:22:16 +03:00
modalHelp
2026-04-22 22:10:50 +03:00
)
type fileOpKind int
const (
opCopy fileOpKind = iota
opMove
opDelete
opMkdir
opEdit
opView
)
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 {
sourcePaths [ ] string
stats vfs . TransferStats
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 { }
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-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
width int
height int
2026-04-22 23:03:33 +03:00
left BrowserPane
right BrowserPane
active PaneID
infoMode bool
selectMode bool
2026-04-22 22:10:50 +03:00
previewModel viewport . Model
previewData vfs . Preview
modal modalState
status string
busy bool
2026-04-22 22:50:30 +03:00
lastClick mouseClickState
hover hoverState
2026-04-23 12:30:10 +03:00
copyJob * copyJobState
nextCopyJob int
copyProgress chan tea . Msg
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-23 12:30:10 +03:00
cfg : cfg ,
configPath : configPath ,
palette : palette ,
keys : DefaultKeyMap ( ) ,
left : BrowserPane { ID : PaneLeft , Path : leftPath } ,
right : BrowserPane { ID : PaneRight , Path : rightPath } ,
active : PaneLeft ,
status : "Ready" ,
copyProgress : make ( chan tea . Msg , 256 ) ,
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-22 23:03:33 +03:00
if m . selectMode && msg . preview . Kind != vfs . PreviewKindText {
m . selectMode = false
return m , enableMouseCmd ( )
}
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 :
m . status = "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 )
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-22 22:10:50 +03:00
}
activeSelection := selectedName ( m . activePane ( ) )
_ = m . reloadPane ( PaneLeft , activeSelection )
_ = m . reloadPane ( PaneRight , activeSelection )
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
}
title := "Delete selected entr" + pluralSuffix ( len ( msg . sourcePaths ) , "y" , "ies" ) + "?"
bodyLines := [ ] string {
fmt . Sprintf ( "Items: %d" , len ( msg . sourcePaths ) ) ,
fmt . Sprintf ( "Files: %d" , msg . stats . FilesTotal ) ,
fmt . Sprintf ( "Size: %s" , formatSize ( msg . stats . BytesTotal , true ) ) ,
}
m . openConfirmModal (
title ,
strings . Join ( bodyLines , "\n" ) ,
"confirm-actions" ,
pendingOperation {
kind : opDelete ,
sourcePaths : append ( [ ] string ( nil ) , msg . sourcePaths ... ) ,
stats : msg . stats ,
} ,
)
return m , nil
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-22 22:10:50 +03:00
case tea . KeyMsg :
if m . modal . kind != modalNone {
return m . handleModalKey ( msg )
}
switch {
case key . Matches ( msg , m . keys . Quit ) :
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-23 19:57:06 +03:00
case key . Matches ( msg , m . keys . Cancel ) :
if len ( m . activePane ( ) . MarkedEntries ( ) ) > 0 {
m . activePane ( ) . ClearMarks ( )
m . status = "Selection cleared"
return m , m . loadPreviewCmd ( )
}
return m , nil
2026-04-22 22:10:50 +03:00
case key . Matches ( msg , m . keys . View ) :
return m . handleView ( )
case key . Matches ( msg , m . keys . Edit ) :
return m . handleEdit ( )
case key . Matches ( msg , m . keys . Info ) :
return m . toggleInfo ( )
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 ( )
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-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
var panels string
2026-04-22 23:26:45 +03:00
if m . selectMode && m . infoMode {
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-22 22:50:30 +03:00
renderPane ( m . left , m . cfg , m . palette , leftWidth , bodyHeight , true , m . hoverIndexFor ( PaneLeft ) ) ,
2026-04-22 22:10:50 +03:00
gap ,
renderPreviewPane ( m . previewData , & m . previewModel , m . cfg , m . palette , previewWidth , bodyHeight ) ,
)
} else {
panels = lipgloss . JoinHorizontal (
lipgloss . Top ,
renderPreviewPane ( m . previewData , & m . previewModel , m . cfg , m . palette , previewWidth , bodyHeight ) ,
gap ,
2026-04-22 22:50:30 +03:00
renderPane ( m . right , m . cfg , m . palette , rightWidth , bodyHeight , true , m . hoverIndexFor ( PaneRight ) ) ,
2026-04-22 22:10:50 +03:00
)
}
} else {
panels = lipgloss . JoinHorizontal (
lipgloss . Top ,
2026-04-22 22:50:30 +03:00
renderPane ( m . left , m . cfg , m . palette , leftWidth , bodyHeight , m . active == PaneLeft , m . hoverIndexFor ( PaneLeft ) ) ,
2026-04-22 22:10:50 +03:00
gap ,
2026-04-22 22:50:30 +03:00
renderPane ( m . right , m . cfg , m . palette , rightWidth , bodyHeight , m . active == PaneRight , m . hoverIndexFor ( PaneRight ) ) ,
2026-04-22 22:10:50 +03:00
)
}
2026-04-22 23:03:33 +03:00
parts := make ( [ ] string , 0 , 3 )
2026-04-22 22:10:50 +03:00
parts = append ( parts , panels )
if m . cfg . UI . ShowFooter {
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-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-22 22:10:50 +03:00
}
return view
}
func ( m Model ) handleModalKey ( msg tea . KeyMsg ) ( tea . Model , tea . Cmd ) {
switch m . modal . kind {
case modalMkdir :
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 ) :
value := strings . TrimSpace ( m . modal . input . Value ( ) )
if value == "" {
m . status = "Directory name must not be empty"
return m , nil
}
m . busy = true
return m , mkdirCmd ( m . activePane ( ) . Path , value )
}
var cmd tea . Cmd
m . modal . input , cmd = m . modal . input . Update ( msg )
return m , cmd
case modalConfirm :
switch {
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 ( )
}
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 ) {
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-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 )
}
pane . Move ( delta , max ( m . bodyHeight ( ) - 4 , 1 ) )
m . hover = hoverState { }
}
func ( m * Model ) operationSources ( ) [ ] string {
pane := m . activePane ( )
marked := pane . MarkedEntries ( )
if len ( marked ) > 0 {
paths := make ( [ ] string , 0 , len ( marked ) )
for _ , entry := range marked {
if entry . IsParent {
continue
}
paths = append ( paths , entry . Path )
}
return paths
}
selected , ok := pane . Selected ( )
if ! ok || selected . IsParent {
return nil
}
return [ ] string { selected . Path }
}
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
}
currentName := selected . Name
pane . Path = selected . Path
if err := m . reloadPane ( pane . ID , currentName ) ; err != nil {
return err
}
m . status = fmt . Sprintf ( "Entered %s" , pane . Path )
return nil
}
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
}
if selected . IsDir {
if err := m . enterSelected ( ) ; err != nil {
m . status = err . Error ( )
return m , nil
}
return m , m . loadPreviewCmd ( )
}
if isEditableEntry ( selected ) {
return m . handleEdit ( )
}
return m . handleOpenExternal ( )
}
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 ( )
parent := filepath . Dir ( pane . Path )
if parent == pane . Path {
return nil
}
currentName := filepath . Base ( pane . Path )
pane . Path = parent
if err := m . reloadPane ( pane . ID , currentName ) ; err != nil {
return err
}
m . status = fmt . Sprintf ( "Moved to %s" , parent )
return nil
}
func ( m Model ) loadPreviewCmd ( ) tea . Cmd {
selected , ok := m . activePane ( ) . Selected ( )
if ! ok {
return func ( ) tea . Msg {
return previewMsg {
entryPath : "" ,
preview : vfs . Preview {
Kind : vfs . PreviewKindEmpty ,
Title : "Nothing selected" ,
Body : "No entry selected." ,
} ,
}
}
}
options := vfs . PreviewOptions {
ShowHidden : m . cfg . Browser . ShowHidden ,
DirsFirst : m . cfg . Browser . DirsFirst ,
SortBy : m . cfg . Browser . Sort . By ,
SortReverse : m . cfg . Browser . Sort . Reverse ,
MaxPreviewBytes : m . cfg . Preview . MaxPreviewBytes ,
DirectoryPreviewLimit : m . cfg . Preview . DirectoryPreviewLimit ,
HumanReadableSize : m . cfg . Browser . HumanReadableSize ,
2026-04-22 23:35:42 +03:00
ThemeName : m . cfg . UI . Theme ,
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-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
}
func ( m * Model ) handleDelete ( ) ( tea . Model , tea . Cmd ) {
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-23 19:57:06 +03:00
m . status = fmt . Sprintf ( "Deleting %d entr%s" , len ( sources ) , pluralSuffix ( len ( sources ) , "y" , "ies" ) )
return m , deletePathsCmd ( 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"
return m , deletePlanCmd ( 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 {
m . status = "Preview refreshed"
return m , m . loadPreviewCmd ( )
}
command , name , err := externalCommand ( "PAGER" , [ ] string { "less" , "more" } , selected . Path )
if err != nil {
m . status = "Preview refreshed in center pane"
return m , m . loadPreviewCmd ( )
}
m . status = fmt . Sprintf ( "Opening %s with %s" , selected . DisplayName ( ) , name )
return m , tea . ExecProcess ( command , func ( err error ) tea . Msg {
return opMsg { kind : opView , sourcePath : selected . Path , err : err }
} )
}
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
}
m . status = fmt . Sprintf ( "Opening %s with %s" , selected . DisplayName ( ) , name )
return m , tea . ExecProcess ( command , func ( err error ) tea . Msg {
return opMsg { kind : opView , sourcePath : selected . Path , err : err }
} )
}
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
}
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-22 22:50:30 +03:00
func ( m * Model ) handleMouse ( msg tea . MouseMsg ) ( tea . Model , tea . Cmd ) {
switch {
case msg . Action == tea . MouseActionMotion :
paneID , index , ok := m . mouseTarget ( msg . X , msg . Y )
if ok {
m . hover = hoverState { pane : paneID , index : index , ok : true }
} else {
m . hover = hoverState { }
}
return m , nil
case msg . Action == tea . MouseActionPress && msg . Button == tea . MouseButtonWheelUp :
if m . infoMode && m . mouseOverPreview ( msg . X , msg . Y ) {
m . previewModel . LineUp ( 3 )
return m , nil
}
m . moveCursor ( - 1 )
return m , m . loadPreviewCmd ( )
case msg . Action == tea . MouseActionPress && msg . Button == tea . MouseButtonWheelDown :
if m . infoMode && m . mouseOverPreview ( msg . X , msg . Y ) {
m . previewModel . LineDown ( 3 )
return m , nil
}
m . moveCursor ( 1 )
return m , m . loadPreviewCmd ( )
case msg . Action == tea . MouseActionPress && msg . Button == tea . MouseButtonLeft :
paneID , index , ok := m . mouseTarget ( msg . X , msg . Y )
if ! ok {
return m , nil
}
m . hover = hoverState { pane : paneID , index : index , ok : true }
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
m . resizePreview ( )
m . syncPreviewContent ( )
m . status = "Info mode: off"
return m , nil
}
paneID , index , ok := m . mouseTarget ( msg . X , msg . Y )
if ! ok {
return m , nil
}
if m . infoMode && paneID == m . active && index == m . activePane ( ) . Cursor {
m . infoMode = false
m . hover = hoverState { pane : paneID , index : index , ok : true }
m . status = "Info mode: off"
return m , nil
}
m . hover = hoverState { pane : paneID , index : index , ok : true }
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-22 23:03:33 +03:00
if m . selectMode {
m . selectMode = false
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 ) {
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-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
m . status = fmt . Sprintf ( "Theme: %s" , next )
return m , nil
}
func ( m * Model ) cycleSort ( ) ( tea . Model , tea . Cmd ) {
order := [ ] string { "name" , "modified" , "size" , "created" , "extension" }
current := strings . ToLower ( strings . TrimSpace ( m . cfg . Browser . Sort . By ) )
next := order [ 0 ]
for idx , value := range order {
if value == current {
next = order [ ( idx + 1 ) % len ( order ) ]
break
}
}
m . cfg . Browser . Sort . By = next
return m . refreshAllPanes ( fmt . Sprintf ( "Sort: %s" , next ) )
}
func ( m * Model ) openMkdirModal ( ) {
input := textinput . New ( )
input . Placeholder = "new-directory"
input . Focus ( )
input . CharLimit = 128
input . Width = 42
m . modal = modalState {
kind : modalMkdir ,
title : "Create directory" ,
body : fmt . Sprintf ( "Active pane: %s" , m . activePane ( ) . Path ) ,
note : "Enter to confirm, Esc to cancel" ,
input : input ,
}
}
func ( m * Model ) openConfirmModal ( title , body , note string , pending pendingOperation ) {
m . modal = modalState {
kind : modalConfirm ,
title : title ,
body : body ,
note : note ,
pending : & pending ,
}
}
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
" PgDn / f page down" ,
" PgUp / b page up" ,
" Enter / Right open selected entry" ,
" Backspace/Left go to parent directory" ,
" Tab / h / l switch active pane" ,
" r refresh both panes" ,
"" ,
"View and Panels" ,
" i toggle preview/info pane" ,
" Ctrl+t toggle text selection mode in text preview" ,
" Space calculate selected directory size" ,
" s cycle sort mode" ,
" . toggle hidden files" ,
" t cycle theme" ,
"" ,
"Dialogs and Transfers" ,
" Enter / y confirm action" ,
" Esc / n cancel action" ,
" b run copy/move in background (progress dialog)" ,
2026-04-23 21:46:55 +03:00
" c cancel active copy/move transfer" ,
2026-04-23 19:57:06 +03:00
" F5/F6/F8 apply to marked entries when selection exists" ,
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" ,
"" ,
"F-key actions are shown in the footer." ,
}
m . modal = modalState {
kind : modalHelp ,
title : "Keyboard and Mouse Help" ,
body : strings . Join ( sections , "\n" ) ,
note : "F1/? or Esc to close" ,
}
m . status = "Help opened"
}
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 ( )
}
func ( m * Model ) syncPreviewContent ( ) {
content := m . previewData . Body
if m . cfg . Preview . WrapText && m . previewModel . Width > 0 {
content = lipgloss . NewStyle ( ) . Width ( m . previewModel . Width ) . Render ( content )
}
m . previewModel . SetContent ( content )
}
func ( m * Model ) activePane ( ) * BrowserPane {
if m . active == PaneLeft {
return & m . left
}
return & m . right
}
func ( m * Model ) passivePane ( ) * BrowserPane {
if m . active == PaneLeft {
return & m . right
}
return & m . left
}
func ( m * Model ) paneByID ( id PaneID ) * BrowserPane {
if id == PaneLeft {
return & m . left
}
return & m . right
}
func ( m * Model ) layoutWidths ( ) ( int , int , int ) {
total := m . width
gaps := m . cfg . UI . PaneGap
usable := max ( total - gaps , 60 )
left := usable / 2
right := usable - left
if m . active == PaneLeft {
if m . infoMode {
return left , right , 0
}
return left , 0 , right
}
if m . infoMode {
return 0 , left , right
}
return left , 0 , right
}
func ( m * Model ) bodyHeight ( ) int {
2026-04-22 23:03:33 +03:00
height := m . height
2026-04-22 22:10:50 +03:00
if m . cfg . UI . ShowFooter {
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
}
func renderPreviewPane ( preview vfs . Preview , viewportModel * viewport . Model , cfg config . Config , palette theme . Palette , width int , height int ) 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 ) .
Background ( palette . Accent ) .
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-22 23:16:29 +03:00
metaView := renderMetadata ( preview . Metadata , palette , innerWidth )
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 )
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-22 22:10:50 +03:00
func renderMetadata ( meta vfs . Metadata , palette theme . Palette , width int ) 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
}
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-22 22:10:50 +03:00
left := lipgloss . NewStyle ( ) .
Width ( leftWidth ) .
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-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" ) )
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-23 00:10:41 +03:00
Render ( fmt . Sprintf ( "path: %s" , truncateMiddle ( meta . Path , max ( innerWidth - 8 , 16 ) ) ) )
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 )
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 )
}
line := strings . Join ( parts , " " )
2026-04-22 23:03:33 +03:00
if m . selectMode {
2026-04-23 00:10:41 +03:00
modeLabel := lipgloss . NewStyle ( ) .
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 != "" {
line += " "
}
line += modeLabel
}
line = " " + line
return lipgloss . PlaceHorizontal (
m . width ,
lipgloss . Left ,
line ,
2026-04-23 21:18:15 +03:00
lipgloss . WithWhitespaceBackground ( m . palette . Footer ) ,
2026-04-23 00:10:41 +03:00
)
2026-04-22 22:10:50 +03:00
}
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-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
if modal . kind == modalMkdir {
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" {
lines = append ( lines , renderConfirmActions ( contentWidth , palette ) )
} 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 ( "" )
}
for _ , sep := range [ ] string { " to " , " (" , "," } {
if idx := strings . Index ( raw , sep ) ; idx > 0 {
keyLabel := strings . TrimSpace ( raw [ : idx ] )
action := strings . TrimLeft ( raw [ idx : ] , " " )
keyWidth := min ( max ( lipgloss . Width ( keyLabel ) + 2 , 10 ) , width )
actionWidth := max ( width - keyWidth , 0 )
keyStyle := lipgloss . NewStyle ( ) .
Width ( keyWidth ) .
Background ( palette . Panel ) .
Foreground ( palette . FooterKey ) .
Bold ( true )
actionStyle := lipgloss . NewStyle ( ) .
Width ( actionWidth ) .
Background ( palette . Panel ) .
Foreground ( palette . Muted )
return keyStyle . Render ( keyLabel ) + actionStyle . Render ( action )
}
}
return fallback . Render ( raw )
}
func renderConfirmActions ( width int , palette theme . Palette ) string {
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 != "" {
lines = append ( lines , spacer , noteStyle . Render ( modal . note ) )
}
return box . Render ( strings . Join ( lines , "\n" ) )
}
func splitHelpItem ( raw string ) ( string , string ) {
value := strings . TrimSpace ( raw )
for idx := 0 ; idx < len ( value ) - 1 ; idx ++ {
if value [ idx ] == ' ' && value [ idx + 1 ] == ' ' {
keyLabel := strings . TrimSpace ( value [ : idx ] )
action := strings . TrimSpace ( value [ idx : ] )
if keyLabel != "" && action != "" {
return keyLabel , action
}
}
}
return value , ""
}
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 ,
renderProgressActions ( contentWidth , palette ) ,
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" ) )
}
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 ""
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-23 19:57:06 +03:00
return deletePathsCmd ( 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-23 20:37:54 +03:00
func deletePlanCmd ( sourcePaths [ ] string ) tea . Cmd {
return func ( ) tea . Msg {
stats := vfs . TransferStats { }
var err error
for _ , sourcePath := range sourcePaths {
part , statErr := vfs . CopyStats ( sourcePath )
if statErr != nil {
err = statErr
break
}
stats . FilesTotal += part . FilesTotal
stats . BytesTotal += part . BytesTotal
}
return deletePlanMsg {
sourcePaths : append ( [ ] string ( nil ) , sourcePaths ... ) ,
stats : stats ,
err : err ,
}
}
}
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-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-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-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-23 19:57:06 +03:00
func deletePathsCmd ( 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 {
if err := vfs . DeletePath ( path ) ; err != nil {
return opMsg { kind : opDelete , sourcePath : path , err : err }
}
}
return opMsg { kind : opDelete }
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 }
}
}
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-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"
default :
return "Copying"
}
}
func operationDoneLabel ( kind fileOpKind ) string {
switch kind {
case opMove :
return "Moved"
case opCopy :
return "Copied"
case opDelete :
return "Deleted"
default :
return "Done"
}
}
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 :
return "delete"
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-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
}
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 ( ) {
case "text" , "config" , "executable" :
return true
default :
return false
}
}
func ( m * Model ) hoverIndexFor ( pane PaneID ) int {
if m . hover . ok && m . hover . pane == pane {
return m . hover . index
}
return - 1
}
func ( m * Model ) mouseOverPreview ( x , y int ) bool {
if ! m . infoMode || m . width <= 0 || m . height <= 0 {
return false
}
leftWidth , previewWidth , _ := m . layoutWidths ( )
top := 0
bodyHeight := m . bodyHeight ( )
if y < top || y >= top + bodyHeight {
return false
}
gap := m . cfg . UI . PaneGap
if m . active == PaneLeft {
startX := leftWidth + gap
return x >= startX && x < startX + previewWidth
}
return x >= 0 && x < previewWidth
}