Polish transfer progress dialog controls

This commit is contained in:
vrubelroman 2026-04-23 21:46:55 +03:00
parent ce84789edb
commit 95847ad231
3 changed files with 298 additions and 135 deletions

View file

@ -3,64 +3,66 @@ package ui
import "github.com/charmbracelet/bubbles/key"
type KeyMap struct {
Help key.Binding
View key.Binding
Edit key.Binding
Info key.Binding
SelectText key.Binding
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
Up key.Binding
Down key.Binding
SelectUp key.Binding
SelectDown key.Binding
PageUp key.Binding
PageDown key.Binding
Open key.Binding
Back key.Binding
Switch key.Binding
Refresh key.Binding
DirSize key.Binding
Copy key.Binding
Move key.Binding
Mkdir key.Binding
Delete key.Binding
Confirm key.Binding
Background key.Binding
Cancel key.Binding
Quit key.Binding
Help key.Binding
View key.Binding
Edit key.Binding
Info key.Binding
SelectText key.Binding
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
Up key.Binding
Down key.Binding
SelectUp key.Binding
SelectDown key.Binding
PageUp key.Binding
PageDown key.Binding
Open key.Binding
Back key.Binding
Switch key.Binding
Refresh key.Binding
DirSize key.Binding
Copy key.Binding
Move key.Binding
Mkdir key.Binding
Delete key.Binding
Confirm key.Binding
Background key.Binding
ProgressCancel key.Binding
Cancel key.Binding
Quit key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
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")),
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")),
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")),
Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")),
Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")),
Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")),
Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
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")),
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")),
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")),
Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")),
Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")),
Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")),
ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")),
Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
}
}

View file

@ -1,6 +1,7 @@
package ui
import (
"context"
"fmt"
"os"
"os/exec"
@ -117,6 +118,8 @@ type copyJobState struct {
progress vfs.CopyProgress
overwrite bool
background bool
cancel context.CancelFunc
startedAt time.Time
}
type mouseClickState struct {
@ -334,7 +337,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.busy = false
if msg.err != nil {
m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err)
if msg.err == context.Canceled {
m.status = strings.Title(operationVerb(msg.kind)) + " cancelled"
} else {
m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err)
}
m.copyJob = nil
if m.modal.kind == modalCopyProgress {
m.modal = modalState{}
@ -584,7 +591,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case modalCopyProgress:
if isModalCloseKey(msg, m.keys) {
if key.Matches(msg, m.keys.Background) {
if m.copyJob == nil {
m.modal = modalState{}
return m, nil
@ -594,14 +601,15 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = "Transfer continues in background"
return m, nil
}
if key.Matches(msg, m.keys.Background) {
if key.Matches(msg, m.keys.ProgressCancel) {
if m.copyJob == nil {
m.modal = modalState{}
return m, nil
}
m.copyJob.background = true
m.modal = modalState{}
m.status = "Transfer continues in background"
if m.copyJob.cancel != nil {
m.copyJob.cancel()
}
m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..."
return m, nil
}
return m, nil
@ -1120,6 +1128,7 @@ func (m *Model) openHelpModal() {
" Enter / y confirm action",
" Esc / n cancel action",
" b run copy/move in background (progress dialog)",
" c cancel active copy/move transfer",
" F5/F6/F8 apply to marked entries when selection exists",
"",
"Mouse",
@ -1711,19 +1720,19 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
lines := []string{
titleStyle.Render(progressTitle(job.kind)),
lineStyle.Render(fmt.Sprintf("From: %s", transferSourceLabel(job.sourcePaths))),
lineStyle.Render(fmt.Sprintf("To: %s", job.targetDir)),
spacer,
lineStyle.Render(renderProgressBar(ratio, max(contentWidth-8, 10), palette)),
lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)),
lineStyle.Render(fmt.Sprintf("Size: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))),
spacer,
renderProgressPercentLine(ratio, contentWidth, palette),
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),
}
if strings.TrimSpace(progress.CurrentPath) != "" {
lines = append(lines, lineStyle.Render("Current: "+truncateMiddle(progress.CurrentPath, max(contentWidth-10, 16))))
if job.background {
lines = append(lines, mutedStyle.Render("Transfer continues in background"))
}
lines = append(lines, spacer)
lines = append(lines, mutedStyle.Render("Press b to continue in background"))
return box.Render(strings.Join(lines, "\n"))
}
@ -1748,8 +1757,97 @@ func renderProgressBar(ratio float64, width int, palette theme.Palette) string {
bar := lipgloss.NewStyle().Foreground(palette.ProgressFill).Render(strings.Repeat("█", filled))
rest := lipgloss.NewStyle().Foreground(palette.ProgressEmpty).Render(strings.Repeat("░", width-filled))
percent := fmt.Sprintf(" %3.0f%%", ratio*100)
return bar + rest + percent
return bar + rest
}
func renderProgressStatLine(label string, value string, width int, palette theme.Palette) string {
keyWidth := min(max(lipgloss.Width(label)+1, 8), width)
valueWidth := max(width-keyWidth, 0)
keyStyle := lipgloss.NewStyle().
Width(keyWidth).
Background(palette.Panel).
Foreground(palette.FooterKey).
Bold(true)
valueStyle := lipgloss.NewStyle().
Width(valueWidth).
Background(palette.Panel).
Foreground(palette.Text)
return keyStyle.Render(label) + valueStyle.Render(value)
}
func renderProgressActions(width int, palette theme.Palette) string {
const minButtonWidth = 14
const maxButtonWidth = 18
const gapWidth = 4
labelWidth := max(lipgloss.Width("Background / b"), lipgloss.Width("Cancel / c"))
buttonWidth := min(max(labelWidth+2, minButtonWidth), maxButtonWidth)
buttonWidth = min(buttonWidth, max((width-gapWidth)/2, labelWidth))
backgroundBtn := lipgloss.NewStyle().
Width(buttonWidth).
Align(lipgloss.Center).
Background(palette.Info).
Foreground(palette.Background).
Bold(true).
Render("Background / b")
cancelBtn := lipgloss.NewStyle().
Width(buttonWidth).
Align(lipgloss.Center).
Background(palette.CancelButton).
Foreground(palette.Background).
Bold(true).
Render("Cancel / c")
gap := lipgloss.NewStyle().
Width(gapWidth).
Background(palette.Panel).
Render("")
backgroundBias := lipgloss.NewStyle().
Width(10).
Background(palette.Panel).
Render("")
cancelBias := lipgloss.NewStyle().
Width(4).
Background(palette.Panel).
Render("")
group := lipgloss.JoinHorizontal(lipgloss.Top, backgroundBtn, backgroundBias, gap, cancelBtn, cancelBias)
row := lipgloss.PlaceHorizontal(
width,
lipgloss.Center,
group,
lipgloss.WithWhitespaceBackground(palette.Panel),
)
return lipgloss.NewStyle().
Width(width).
Background(palette.Panel).
Render(row)
}
func renderProgressPercentLine(ratio float64, width int, palette theme.Palette) string {
percent := lipgloss.NewStyle().
Foreground(palette.Info).
Bold(true).
Render(fmt.Sprintf("%3.0f%%", ratio*100))
return lipgloss.NewStyle().
Width(width).
Background(palette.Panel).
Align(lipgloss.Right).
Render(percent)
}
func transferSpeed(bytesDone int64, startedAt time.Time) string {
if startedAt.IsZero() || bytesDone <= 0 {
return "calculating..."
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
return "calculating..."
}
perSecond := int64(float64(bytesDone) / elapsed.Seconds())
if perSecond <= 0 {
return "calculating..."
}
return fmt.Sprintf("%s/s", vfs.HumanSize(perSecond))
}
func overlayCenter(base string, overlay string, width int) string {
@ -1913,6 +2011,7 @@ func dismissNoticeCmd(delay time.Duration) tea.Cmd {
func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
m.nextCopyJob++
jobID := m.nextCopyJob
ctx, cancel := context.WithCancel(context.Background())
m.copyJob = &copyJobState{
id: jobID,
kind: kind,
@ -1926,6 +2025,8 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
BytesTotal: stats.BytesTotal,
CurrentPath: sourcePaths[0],
},
cancel: cancel,
startedAt: time.Now(),
}
m.modal = modalState{kind: modalCopyProgress}
m.status = strings.Title(operationVerb(kind)) + " started"
@ -1961,9 +2062,9 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
}
switch kind {
case opMove:
_, statErr = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn)
_, statErr = vfs.MovePathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn)
default:
_, statErr = vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn)
_, statErr = vfs.CopyPathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn)
}
if statErr != nil {
m.copyProgress <- copyDoneMsg{