Add keyboard multi-select workflow and project description doc

This commit is contained in:
vrubelroman 2026-04-23 19:57:06 +03:00
parent deeb261b89
commit 3d1c572e16
4 changed files with 458 additions and 120 deletions

128
docs/project-description.md Normal file
View file

@ -0,0 +1,128 @@
# vcom: описание проекта и реализованного функционала
`vcom` — терминальный файловый менеджер в стиле Midnight Commander, написанный на Go на базе Bubble Tea.
## 1. Основная концепция
Приложение работает в двухпанельном режиме:
- левая файловая панель,
- правая файловая панель.
Активная панель управляется с клавиатуры и мыши, неактивная сохраняет своё состояние (путь, позицию курсора).
## 2. Реализованные режимы интерфейса
- Двухпанельный браузер директорий.
- Режим Info/Preview (`i`): неактивная панель временно заменяется превью выбранного элемента из активной панели.
- Режим выделения текста в превью (`Ctrl+t`) для текстовых файлов.
- Модальные окна (подтверждения, прогресс операций, help, уведомления).
## 3. Навигация и просмотр
- Перемещение по списку: `j/k`, `Up/Down`, `PgUp/PgDn`.
- Переключение активной панели: `Tab`, `h`, `l`.
- Вход в директорию: `Enter` / `Right`.
- Переход в родительскую директорию: `Backspace` / `Left`.
- Обновление панелей: `r`.
- Внешний просмотр файла: `F3` (`$PAGER` при наличии).
- Внешнее редактирование файла: `F4` (`$VISUAL/$EDITOR` или fallback-редакторы).
## 4. Операции с файлами и директориями
- `F5` — копирование.
- `F6` — перемещение.
- `F7` — создание директории.
- `F8` — удаление.
Операции copy/move реализованы с:
- предварительным диалогом подтверждения,
- подсчётом объёма и количества файлов,
- прогрессом по байтам и по количеству файлов,
- возможностью отправить операцию в фон (`b`),
- уведомлением о завершении фоновой операции.
Подтверждение overwrite учитывается для существующих целей.
## 5. Мультивыделение
Реализовано выделение элементов с клавиатуры:
- `Shift+Up/Shift+Down` (а также `Shift+K/Shift+J`) добавляют/снимают выделение на текущем проходе.
- Повторный проход по уже выделенному элементу снимает его выделение (toggle).
- `Esc` очищает выделение активной панели.
Если есть выделенные элементы, `F5/F6/F8` применяются ко всему выделенному набору.
Если выделения нет — операция применяется к текущему элементу под курсором.
## 6. Работа мыши
- ЛКМ: выбор элемента и активация панели.
- Двойной ЛКМ: открытие элемента.
- ПКМ: переключение режима Info/Preview для выбранного элемента.
- Колесо мыши: прокрутка списка; в preview-области — прокрутка содержимого превью.
## 7. Help-окно
`F1` или `?` открывает справку по управлению.
Особенности help:
- логические блоки (Navigation, View and Panels, Dialogs and Transfers, Mouse),
- цветовое оформление заголовков и элементов на основе активной темы,
- закрытие по `F1`, `?`, `Esc`, `Enter`, `q`.
## 8. Модальные окна и закрытие
Во всех модальных окнах поддержано закрытие по `q`.
Поведение в прогрессе copy/move:
- `q` не прерывает операцию,
- окно закрывается,
- операция продолжается в фоне.
## 9. Визуальные доработки
- Убрана верхняя title-строка приложения.
- Убраны текстовые лейблы `LEFT/RIGHT` в заголовках панелей.
- Убрана строка `CONTENT` в preview-панели.
- Путь активной панели сделан жирным и в цвете `TextFile` текущей темы.
- Подсветка курсора отображается только в активной панели.
- Выделенные (marked) элементы подсвечиваются цветом `Danger` темы по всей строке.
## 10. Конфигурация
Поддерживается TOML-конфиг (`vcom.toml`), включая:
- стартовые директории,
- визуальные параметры UI,
- набор и видимость колонок,
- сортировку,
- поведение превью,
- поведение подтверждений и операций.
## 11. Технологический стек
- Go
- Bubble Tea (`github.com/charmbracelet/bubbletea`)
- Bubbles
- Lip Gloss
- TOML (`pelletier/go-toml/v2`)
## 12. Сборка и запуск
Локально:
```bash
go run ./cmd/vcom
```
Сборка бинаря:
```bash
go build -o vcom ./cmd/vcom
```
Nix:
```bash
nix run .
```
Для запуска тегов из GitHub-репозитория рекомендуется использовать версии с `v0.1.1` и выше.

View file

@ -13,6 +13,8 @@ type KeyMap struct {
CycleSort key.Binding CycleSort key.Binding
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
SelectUp key.Binding
SelectDown key.Binding
PageUp key.Binding PageUp key.Binding
PageDown key.Binding PageDown key.Binding
Open key.Binding Open key.Binding
@ -42,6 +44,8 @@ func DefaultKeyMap() KeyMap {
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")),
SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")),
PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")), PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")),
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")), PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")),
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
@ -66,7 +70,7 @@ func (k KeyMap) ShortHelp() []key.Binding {
func (k KeyMap) FullHelp() [][]key.Binding { func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Help, k.Up, k.Down, k.Open, k.Back, k.Switch, k.Info}, {k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back},
{k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete}, {k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete},
{k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, {k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
} }

View file

@ -43,11 +43,12 @@ const (
) )
type pendingOperation struct { type pendingOperation struct {
kind fileOpKind kind fileOpKind
sourcePath string sourcePaths []string
targetDir string targetDir string
overwrite bool overwrite bool
stats vfs.TransferStats existingTargets int
stats vfs.TransferStats
} }
type modalState struct { type modalState struct {
@ -78,13 +79,13 @@ type opMsg struct {
} }
type copyPlanMsg struct { type copyPlanMsg struct {
kind fileOpKind kind fileOpKind
sourcePath string sourcePaths []string
targetDir string targetDir string
targetPath string overwrite bool
overwrite bool existingTargets int
stats vfs.TransferStats stats vfs.TransferStats
err error err error
} }
type copyProgressMsg struct { type copyProgressMsg struct {
@ -93,24 +94,23 @@ type copyProgressMsg struct {
} }
type copyDoneMsg struct { type copyDoneMsg struct {
jobID int jobID int
kind fileOpKind kind fileOpKind
sourcePath string sourcePaths []string
targetPath string targetDir string
err error err error
} }
type dismissNoticeMsg struct{} type dismissNoticeMsg struct{}
type copyJobState struct { type copyJobState struct {
id int id int
kind fileOpKind kind fileOpKind
sourcePath string sourcePaths []string
targetDir string targetDir string
targetPath string progress vfs.CopyProgress
progress vfs.CopyProgress overwrite bool
overwrite bool background bool
background bool
} }
type mouseClickState struct { type mouseClickState struct {
@ -245,6 +245,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Moved to %s", msg.targetPath) m.status = fmt.Sprintf("Moved to %s", msg.targetPath)
case opDelete: case opDelete:
m.status = "Deleted" m.status = "Deleted"
m.activePane().ClearMarks()
case opMkdir: case opMkdir:
m.status = fmt.Sprintf("Created %s", msg.targetPath) m.status = fmt.Sprintf("Created %s", msg.targetPath)
case opEdit: case opEdit:
@ -269,23 +270,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
verb := operationVerb(msg.kind) verb := operationVerb(msg.kind)
title := fmt.Sprintf("%s selected entry?", strings.Title(verb)) title := fmt.Sprintf("%s selected entry?", strings.Title(verb))
fromLabel := msg.sourcePaths[0]
if len(msg.sourcePaths) > 1 {
fromLabel = fmt.Sprintf("%d selected entries", len(msg.sourcePaths))
}
body := strings.Join([]string{ body := strings.Join([]string{
fmt.Sprintf("From: %s", msg.sourcePath), fmt.Sprintf("From: %s", fromLabel),
fmt.Sprintf("To: %s", msg.targetPath), fmt.Sprintf("To: %s", msg.targetDir),
"", "",
fmt.Sprintf("Files: %d", msg.stats.FilesTotal), fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)), fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)),
}, "\n") }, "\n")
if msg.overwrite { if msg.existingTargets > 0 {
body += "\n\nTarget exists and will be overwritten." body += fmt.Sprintf("\n\nExisting targets: %d (will be overwritten)", msg.existingTargets)
} }
note := fmt.Sprintf("Enter/y to start %s, Esc/n to cancel", verb) note := fmt.Sprintf("Enter/y to start %s, Esc/n to cancel", verb)
m.openConfirmModal(title, body, note, pendingOperation{ m.openConfirmModal(title, body, note, pendingOperation{
kind: msg.kind, kind: msg.kind,
sourcePath: msg.sourcePath, sourcePaths: append([]string(nil), msg.sourcePaths...),
targetDir: msg.targetDir, targetDir: msg.targetDir,
overwrite: msg.overwrite, overwrite: msg.overwrite,
stats: msg.stats, existingTargets: msg.existingTargets,
stats: msg.stats,
}) })
return m, nil return m, nil
@ -314,13 +320,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.status = fmt.Sprintf("%s to %s", operationDoneLabel(msg.kind), msg.targetPath) m.status = fmt.Sprintf("%s %d entr%s to %s", operationDoneLabel(msg.kind), len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetDir)
activeSelection := selectedName(m.activePane()) activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection) _ = m.reloadPane(PaneRight, activeSelection)
background := m.copyJob.background background := m.copyJob.background
kind := m.copyJob.kind kind := m.copyJob.kind
sourceCount := len(m.copyJob.sourcePaths)
m.copyJob = nil m.copyJob = nil
m.activePane().ClearMarks()
cmd := m.loadPreviewCmd() cmd := m.loadPreviewCmd()
if m.modal.kind == modalCopyProgress { if m.modal.kind == modalCopyProgress {
@ -331,10 +339,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if kind == opMove { if kind == opMove {
doneWord = "moved" doneWord = "moved"
} }
doneBody := fmt.Sprintf("%d entr%s %s successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies"), doneWord)
if sourceCount == 1 && len(msg.sourcePaths) == 1 {
doneBody = filepath.Base(msg.sourcePaths[0]) + " " + doneWord + " successfully."
}
m.modal = modalState{ m.modal = modalState{
kind: modalNotice, kind: modalNotice,
title: strings.Title(operationVerb(kind)) + " complete", title: strings.Title(operationVerb(kind)) + " complete",
body: filepath.Base(msg.sourcePath) + " " + doneWord + " successfully.", body: doneBody,
} }
cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second)) cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second))
} }
@ -357,6 +369,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Help): case key.Matches(msg, m.keys.Help):
m.openHelpModal() m.openHelpModal()
return m, nil return m, nil
case key.Matches(msg, m.keys.Cancel):
if len(m.activePane().MarkedEntries()) > 0 {
m.activePane().ClearMarks()
m.status = "Selection cleared"
return m, m.loadPreviewCmd()
}
return m, nil
case key.Matches(msg, m.keys.View): case key.Matches(msg, m.keys.View):
return m.handleView() return m.handleView()
case key.Matches(msg, m.keys.Edit): case key.Matches(msg, m.keys.Edit):
@ -372,6 +391,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.CycleSort): case key.Matches(msg, m.keys.CycleSort):
return m.cycleSort() return m.cycleSort()
case key.Matches(msg, m.keys.Switch): case key.Matches(msg, m.keys.Switch):
m.left.ClearMarks()
m.right.ClearMarks()
if m.active == PaneLeft { if m.active == PaneLeft {
m.active = PaneRight m.active = PaneRight
} else { } else {
@ -385,6 +406,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Down): case key.Matches(msg, m.keys.Down):
m.moveCursor(1) m.moveCursor(1)
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.SelectUp):
m.selectMoveCursor(-1)
return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.SelectDown):
m.selectMoveCursor(1)
return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.PageUp): case key.Matches(msg, m.keys.PageUp):
m.moveCursor(-max(m.bodyHeight()-6, 5)) m.moveCursor(-max(m.bodyHeight()-6, 5))
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
@ -528,7 +555,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.busy = true m.busy = true
return m, m.startCopyJob(pending.kind, pending.sourcePath, pending.targetDir, pending.overwrite, pending.stats) return m, m.startCopyJob(pending.kind, pending.sourcePaths, pending.targetDir, pending.overwrite, pending.stats)
} }
m.busy = true m.busy = true
return m, pending.cmd() return m, pending.cmd()
@ -614,6 +641,35 @@ func (m *Model) moveCursor(delta int) {
m.hover = hoverState{} m.hover = hoverState{}
} }
func (m *Model) selectMoveCursor(delta int) {
pane := m.activePane()
if selected, ok := pane.Selected(); ok && !selected.IsParent {
pane.ToggleMarked(selected.Path)
}
pane.Move(delta, max(m.bodyHeight()-4, 1))
m.hover = hoverState{}
}
func (m *Model) operationSources() []string {
pane := m.activePane()
marked := pane.MarkedEntries()
if len(marked) > 0 {
paths := make([]string, 0, len(marked))
for _, entry := range marked {
if entry.IsParent {
continue
}
paths = append(paths, entry.Path)
}
return paths
}
selected, ok := pane.Selected()
if !ok || selected.IsParent {
return nil
}
return []string{selected.Path}
}
func (m *Model) enterSelected() error { func (m *Model) enterSelected() error {
m.hover = hoverState{} m.hover = hoverState{}
pane := m.activePane() pane := m.activePane()
@ -725,18 +781,24 @@ func (m *Model) handleDirSize() (tea.Model, tea.Cmd) {
} }
func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) { func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected() sources := m.operationSources()
if !ok || selected.IsParent { if len(sources) == 0 {
m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind)) m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind))
return m, nil return m, nil
} }
targetDir := m.passivePane().Path targetDir := m.passivePane().Path
targetPath := filepath.Join(targetDir, filepath.Base(selected.Path)) existingTargets := 0
exists, err := vfs.PathExists(targetPath) for _, sourcePath := range sources {
if err != nil { targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
m.status = err.Error() exists, err := vfs.PathExists(targetPath)
return m, nil if err != nil {
m.status = err.Error()
return m, nil
}
if exists {
existingTargets++
}
} }
if kind == opCopy || kind == opMove { if kind == opCopy || kind == opMove {
@ -745,52 +807,40 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
overwrite := exists overwrite := existingTargets > 0
if exists && !m.cfg.Behavior.ConfirmOverwrite { if existingTargets > 0 && !m.cfg.Behavior.ConfirmOverwrite {
overwrite = true overwrite = true
} }
m.busy = true m.busy = true
m.status = fmt.Sprintf("Calculating %s size for %s", operationVerb(kind), selected.DisplayName()) m.status = fmt.Sprintf("Calculating %s size", operationVerb(kind))
return m, copyPlanCmd(kind, selected.Path, targetDir, overwrite) return m, copyPlanCmd(kind, sources, targetDir, overwrite, existingTargets)
} }
return m, nil
if exists && m.cfg.Behavior.ConfirmOverwrite {
title := fmt.Sprintf("Overwrite existing target before %s?", operationVerb(kind))
body := fmt.Sprintf("%s\n\n-> %s", selected.Path, targetPath)
note := "Enter/y to overwrite, Esc/n to cancel"
m.openConfirmModal(title, body, note, pendingOperation{
kind: kind,
sourcePath: selected.Path,
targetDir: targetDir,
overwrite: true,
})
return m, nil
}
m.busy = true
m.status = fmt.Sprintf("%s %s", strings.Title(operationVerb(kind)), selected.DisplayName())
return m, operationCmd(kind, selected.Path, targetDir, exists)
} }
func (m *Model) handleDelete() (tea.Model, tea.Cmd) { func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected() sources := m.operationSources()
if !ok || selected.IsParent { if len(sources) == 0 {
m.status = "Nothing to delete" m.status = "Nothing to delete"
return m, nil return m, nil
} }
if !m.cfg.Behavior.ConfirmDelete { if !m.cfg.Behavior.ConfirmDelete {
m.busy = true m.busy = true
m.status = fmt.Sprintf("Deleting %s", selected.DisplayName()) m.status = fmt.Sprintf("Deleting %d entr%s", len(sources), pluralSuffix(len(sources), "y", "ies"))
return m, deleteCmd(selected.Path) return m, deletePathsCmd(sources)
} }
body := sources[0]
if len(sources) > 1 {
body = fmt.Sprintf("%d selected entries", len(sources))
}
m.openConfirmModal( m.openConfirmModal(
"Delete selected entry?", "Delete selected entr"+pluralSuffix(len(sources), "y", "ies")+"?",
selected.Path, body,
"Enter/y to delete permanently, Esc/n to cancel", "Enter/y to delete permanently, Esc/n to cancel",
pendingOperation{ pendingOperation{
kind: opDelete, kind: opDelete,
sourcePath: selected.Path, sourcePaths: append([]string(nil), sources...),
}, },
) )
return m, nil return m, nil
@ -883,6 +933,10 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.hover = hoverState{pane: paneID, index: index, ok: true} m.hover = hoverState{pane: paneID, index: index, ok: true}
if paneID != m.active {
m.left.ClearMarks()
m.right.ClearMarks()
}
m.active = paneID m.active = paneID
pane := m.paneByID(paneID) pane := m.paneByID(paneID)
if index >= 0 && index < len(pane.Entries) { if index >= 0 && index < len(pane.Entries) {
@ -920,6 +974,10 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
m.hover = hoverState{pane: paneID, index: index, ok: true} m.hover = hoverState{pane: paneID, index: index, ok: true}
if paneID != m.active {
m.left.ClearMarks()
m.right.ClearMarks()
}
m.active = paneID m.active = paneID
pane := m.paneByID(paneID) pane := m.paneByID(paneID)
if index >= 0 && index < len(pane.Entries) { if index >= 0 && index < len(pane.Entries) {
@ -1030,6 +1088,8 @@ func (m *Model) openHelpModal() {
"Navigation", "Navigation",
" j / Down move down", " j / Down move down",
" k / Up move up", " k / Up move up",
" Shift+Down/J extend selection down",
" Shift+Up/K extend selection up",
" PgDn / f page down", " PgDn / f page down",
" PgUp / b page up", " PgUp / b page up",
" Enter / Right open selected entry", " Enter / Right open selected entry",
@ -1049,6 +1109,7 @@ func (m *Model) openHelpModal() {
" Enter / y confirm action", " Enter / y confirm action",
" Esc / n cancel action", " Esc / n cancel action",
" b run copy/move in background (progress dialog)", " b run copy/move in background (progress dialog)",
" F5/F6/F8 apply to marked entries when selection exists",
"", "",
"Mouse", "Mouse",
" Left click select entry and activate pane", " Left click select entry and activate pane",
@ -1511,8 +1572,8 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
lines := []string{ lines := []string{
titleStyle.Render(progressTitle(job.kind)), titleStyle.Render(progressTitle(job.kind)),
lineStyle.Render(fmt.Sprintf("From: %s", job.sourcePath)), lineStyle.Render(fmt.Sprintf("From: %s", transferSourceLabel(job.sourcePaths))),
lineStyle.Render(fmt.Sprintf("To: %s", job.targetPath)), lineStyle.Render(fmt.Sprintf("To: %s", job.targetDir)),
spacer, spacer,
lineStyle.Render(renderProgressBar(ratio, max(width-8, 10), palette)), lineStyle.Render(renderProgressBar(ratio, max(width-8, 10), palette)),
lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)), lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)),
@ -1628,9 +1689,9 @@ func previewIcon(preview vfs.Preview) string {
func (p pendingOperation) cmd() tea.Cmd { func (p pendingOperation) cmd() tea.Cmd {
switch p.kind { switch p.kind {
case opMove: case opMove:
return moveCmd(p.sourcePath, p.targetDir, p.overwrite) return nil
case opDelete: case opDelete:
return deleteCmd(p.sourcePath) return deletePathsCmd(p.sourcePaths)
default: default:
return nil return nil
} }
@ -1652,17 +1713,27 @@ func dirSizeCmd(path string) tea.Cmd {
} }
} }
func copyPlanCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd { func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, existingTargets int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
stats, err := vfs.CopyStats(sourcePath) stats := vfs.TransferStats{}
var err error
for _, sourcePath := range sourcePaths {
part, statErr := vfs.CopyStats(sourcePath)
if statErr != nil {
err = statErr
break
}
stats.FilesTotal += part.FilesTotal
stats.BytesTotal += part.BytesTotal
}
return copyPlanMsg{ return copyPlanMsg{
kind: kind, kind: kind,
sourcePath: sourcePath, sourcePaths: append([]string(nil), sourcePaths...),
targetDir: targetDir, targetDir: targetDir,
targetPath: filepath.Join(targetDir, filepath.Base(sourcePath)), overwrite: overwrite,
overwrite: overwrite, existingTargets: existingTargets,
stats: stats, stats: stats,
err: err, err: err,
} }
} }
} }
@ -1679,23 +1750,21 @@ func dismissNoticeCmd(delay time.Duration) tea.Cmd {
}) })
} }
func (m *Model) startCopyJob(kind fileOpKind, sourcePath, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd { func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
m.nextCopyJob++ m.nextCopyJob++
jobID := m.nextCopyJob jobID := m.nextCopyJob
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
m.copyJob = &copyJobState{ m.copyJob = &copyJobState{
id: jobID, id: jobID,
kind: kind, kind: kind,
sourcePath: sourcePath, sourcePaths: append([]string(nil), sourcePaths...),
targetDir: targetDir, targetDir: targetDir,
targetPath: targetPath, overwrite: overwrite,
overwrite: overwrite,
progress: vfs.CopyProgress{ progress: vfs.CopyProgress{
FilesDone: 0, FilesDone: 0,
FilesTotal: stats.FilesTotal, FilesTotal: stats.FilesTotal,
BytesDone: 0, BytesDone: 0,
BytesTotal: stats.BytesTotal, BytesTotal: stats.BytesTotal,
CurrentPath: sourcePath, CurrentPath: sourcePaths[0],
}, },
} }
m.modal = modalState{kind: modalCopyProgress} m.modal = modalState{kind: modalCopyProgress}
@ -1704,25 +1773,56 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePath, targetDir string, over
return tea.Batch( return tea.Batch(
func() tea.Msg { func() tea.Msg {
go func() { go func() {
var ( doneFiles := 0
target string var doneBytes int64
err error for _, sourcePath := range sourcePaths {
) entryStats, statErr := vfs.CopyStats(sourcePath)
progressFn := func(progress vfs.CopyProgress) { if statErr != nil {
m.copyProgress <- copyProgressMsg{jobID: jobID, progress: progress} m.copyProgress <- copyDoneMsg{
} jobID: jobID,
switch kind { kind: kind,
case opMove: sourcePaths: append([]string(nil), sourcePaths...),
target, err = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, stats, progressFn) targetDir: targetDir,
default: err: statErr,
target, err = vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, stats, progressFn) }
return
}
progressFn := func(progress vfs.CopyProgress) {
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles + progress.FilesDone,
FilesTotal: stats.FilesTotal,
BytesDone: doneBytes + progress.BytesDone,
BytesTotal: stats.BytesTotal,
CurrentPath: progress.CurrentPath,
},
}
}
switch kind {
case opMove:
_, statErr = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn)
default:
_, statErr = vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn)
}
if statErr != nil {
m.copyProgress <- copyDoneMsg{
jobID: jobID,
kind: kind,
sourcePaths: append([]string(nil), sourcePaths...),
targetDir: targetDir,
err: statErr,
}
return
}
doneFiles += entryStats.FilesTotal
doneBytes += entryStats.BytesTotal
} }
m.copyProgress <- copyDoneMsg{ m.copyProgress <- copyDoneMsg{
jobID: jobID, jobID: jobID,
kind: kind, kind: kind,
sourcePath: sourcePath, sourcePaths: append([]string(nil), sourcePaths...),
targetPath: target, targetDir: targetDir,
err: err,
} }
}() }()
return nil return nil
@ -1738,10 +1838,14 @@ func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
} }
} }
func deleteCmd(path string) tea.Cmd { func deletePathsCmd(paths []string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err := vfs.DeletePath(path) for _, path := range paths {
return opMsg{kind: opDelete, sourcePath: path, err: err} if err := vfs.DeletePath(path); err != nil {
return opMsg{kind: opDelete, sourcePath: path, err: err}
}
}
return opMsg{kind: opDelete}
} }
} }
@ -1792,6 +1896,23 @@ func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string {
) )
} }
func transferSourceLabel(paths []string) string {
if len(paths) == 0 {
return "n/a"
}
if len(paths) == 1 {
return paths[0]
}
return fmt.Sprintf("%d selected entries", len(paths))
}
func pluralSuffix(count int, singular string, plural string) string {
if count == 1 {
return singular
}
return plural
}
func progressTitle(kind fileOpKind) string { func progressTitle(kind fileOpKind) string {
switch kind { switch kind {
case opMove: case opMove:

View file

@ -24,6 +24,7 @@ type BrowserPane struct {
Entries []vfs.Entry Entries []vfs.Entry
Cursor int Cursor int
Offset int Offset int
Marked map[string]struct{}
} }
func (p *BrowserPane) Selected() (vfs.Entry, bool) { func (p *BrowserPane) Selected() (vfs.Entry, bool) {
@ -35,6 +36,7 @@ func (p *BrowserPane) Selected() (vfs.Entry, bool) {
func (p *BrowserPane) SetEntries(entries []vfs.Entry, preserveKey string) { func (p *BrowserPane) SetEntries(entries []vfs.Entry, preserveKey string) {
p.Entries = entries p.Entries = entries
p.PruneMarks()
if len(entries) == 0 { if len(entries) == 0 {
p.Cursor = 0 p.Cursor = 0
p.Offset = 0 p.Offset = 0
@ -78,6 +80,82 @@ func (p *BrowserPane) Move(delta int, pageSize int) {
} }
} }
func (p *BrowserPane) EnsureMarked(path string) {
if strings.TrimSpace(path) == "" {
return
}
if p.Marked == nil {
p.Marked = map[string]struct{}{}
}
p.Marked[path] = struct{}{}
}
func (p *BrowserPane) ToggleMarked(path string) {
if strings.TrimSpace(path) == "" {
return
}
if p.Marked == nil {
p.Marked = map[string]struct{}{}
}
if _, ok := p.Marked[path]; ok {
delete(p.Marked, path)
if len(p.Marked) == 0 {
p.Marked = nil
}
return
}
p.Marked[path] = struct{}{}
}
func (p *BrowserPane) IsMarked(path string) bool {
if p.Marked == nil {
return false
}
_, ok := p.Marked[path]
return ok
}
func (p *BrowserPane) ClearMarks() {
p.Marked = nil
}
func (p *BrowserPane) PruneMarks() {
if len(p.Marked) == 0 {
return
}
valid := map[string]struct{}{}
for _, entry := range p.Entries {
if entry.IsParent {
continue
}
valid[entry.Path] = struct{}{}
}
for path := range p.Marked {
if _, ok := valid[path]; !ok {
delete(p.Marked, path)
}
}
if len(p.Marked) == 0 {
p.Marked = nil
}
}
func (p *BrowserPane) MarkedEntries() []vfs.Entry {
if len(p.Marked) == 0 {
return nil
}
result := make([]vfs.Entry, 0, len(p.Marked))
for _, entry := range p.Entries {
if entry.IsParent {
continue
}
if p.IsMarked(entry.Path) {
result = append(result, entry)
}
}
return result
}
func (p *BrowserPane) EnsureVisible(pageSize int) { func (p *BrowserPane) EnsureVisible(pageSize int) {
if pageSize <= 0 { if pageSize <= 0 {
return return
@ -195,7 +273,8 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
for idx := pane.Offset; idx < end; idx++ { for idx := pane.Offset; idx < end; idx++ {
entry := pane.Entries[idx] entry := pane.Entries[idx]
isSelected := idx == pane.Cursor && active isSelected := idx == pane.Cursor && active
row := renderEntryRow(entry, cfg, width, isSelected, idx == hoverIndex, active, palette, background) marked := !entry.IsParent && pane.IsMarked(entry.Path)
row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background)
lines = append(lines, row) lines = append(lines, row)
} }
for len(lines) < visibleHeight { for len(lines) < visibleHeight {
@ -208,10 +287,12 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
Render(strings.Join(lines, "\n")) Render(strings.Join(lines, "\n"))
} }
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color) string { func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color) string {
columns := buildColumns(cfg, width) columns := buildColumns(cfg, width)
rowBackground := baseBackground rowBackground := baseBackground
switch { switch {
case marked:
rowBackground = palette.Danger
case selected: case selected:
rowBackground = palette.Selection rowBackground = palette.Selection
case hovered: case hovered:
@ -221,12 +302,16 @@ func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool
parts := make([]string, 0, len(columns)) parts := make([]string, 0, len(columns))
for idx, column := range columns { for idx, column := range columns {
value := column.Value(entry, cfg.Browser.HumanReadableSize) value := column.Value(entry, cfg.Browser.HumanReadableSize)
foreground := entryColor(entry, palette)
if marked {
foreground = palette.Background
}
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Width(column.Width). Width(column.Width).
Foreground(entryColor(entry, palette)). Foreground(foreground).
Background(rowBackground) Background(rowBackground)
if entry.IsHidden { if entry.IsHidden && !marked {
style = style.Foreground(palette.Muted) style = style.Foreground(palette.Muted)
} }
if column.AlignRight { if column.AlignRight {