Add keyboard multi-select workflow and project description doc
This commit is contained in:
parent
deeb261b89
commit
3d1c572e16
4 changed files with 458 additions and 120 deletions
128
docs/project-description.md
Normal file
128
docs/project-description.md
Normal 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` и выше.
|
||||||
|
|
@ -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},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = ©JobState{
|
m.copyJob = ©JobState{
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue