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

@ -1,6 +1,7 @@
package vfs package vfs
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -25,6 +26,7 @@ type CopyProgress struct {
} }
type copyProgressState struct { type copyProgressState struct {
ctx context.Context
filesDone int filesDone int
bytesDone int64 bytesDone int64
stats TransferStats stats TransferStats
@ -36,6 +38,87 @@ func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
return CopyPathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil) return CopyPathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
} }
func CopyPathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return "", fmt.Errorf("stat %s: %w", srcPath, err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if err := ctx.Err(); err != nil {
return "", err
}
if progress == nil {
progress = func(CopyProgress) {}
}
if stats.FilesTotal == 0 && stats.BytesTotal == 0 {
resolved, err := CopyStats(srcPath)
if err != nil {
return "", err
}
stats = resolved
}
tracker := copyProgressState{ctx: ctx, stats: stats, callback: progress}
tracker.emit(srcPath, true)
cleanupOnErr := func(copyErr error) (string, error) {
if copyErr != nil {
_ = os.RemoveAll(targetPath)
}
return "", copyErr
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(srcPath)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Symlink(target, targetPath); err != nil {
return "", err
}
tracker.finishFile(srcPath)
return targetPath, nil
}
if srcInfo.IsDir() {
if err := copyDir(srcPath, targetPath, &tracker); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
if err := copyFile(srcPath, targetPath, srcInfo.Mode(), &tracker); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
func CopyStats(srcPath string) (TransferStats, error) { func CopyStats(srcPath string) (TransferStats, error) {
srcInfo, err := os.Lstat(srcPath) srcInfo, err := os.Lstat(srcPath)
if err != nil { if err != nil {
@ -77,67 +160,7 @@ func CopyStats(srcPath string) (TransferStats, error) {
} }
func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) { func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
srcInfo, err := os.Lstat(srcPath) return CopyPathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
if err != nil {
return "", fmt.Errorf("stat %s: %w", srcPath, err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if progress == nil {
progress = func(CopyProgress) {}
}
if stats.FilesTotal == 0 && stats.BytesTotal == 0 {
resolved, err := CopyStats(srcPath)
if err != nil {
return "", err
}
stats = resolved
}
tracker := copyProgressState{stats: stats, callback: progress}
tracker.emit(srcPath, true)
if srcInfo.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(srcPath)
if err != nil {
return "", err
}
if err := os.Symlink(target, targetPath); err != nil {
return "", err
}
tracker.finishFile(srcPath)
return targetPath, nil
}
if srcInfo.IsDir() {
if err := copyDir(srcPath, targetPath, &tracker); err != nil {
return "", err
}
tracker.emit(srcPath, true)
return targetPath, nil
}
if err := copyFile(srcPath, targetPath, srcInfo.Mode(), &tracker); err != nil {
return "", err
}
tracker.emit(srcPath, true)
return targetPath, nil
} }
func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) { func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
@ -145,6 +168,14 @@ func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
} }
func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) { func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return MovePathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath)) targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil { if same, err := samePath(srcPath, targetPath); err != nil {
return "", err return "", err
@ -166,6 +197,9 @@ func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats T
if progress == nil { if progress == nil {
progress = func(CopyProgress) {} progress = func(CopyProgress) {}
} }
if err := ctx.Err(); err != nil {
return "", err
}
if stats.FilesTotal == 0 && stats.BytesTotal == 0 { if stats.FilesTotal == 0 && stats.BytesTotal == 0 {
resolved, err := CopyStats(srcPath) resolved, err := CopyStats(srcPath)
if err != nil { if err != nil {
@ -187,10 +221,14 @@ func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats T
return "", err return "", err
} }
targetPath, err := CopyPathWithProgress(srcPath, dstDir, overwrite, stats, progress) targetPath, err := CopyPathWithProgressContext(ctx, srcPath, dstDir, overwrite, stats, progress)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := ctx.Err(); err != nil {
_ = os.RemoveAll(targetPath)
return "", err
}
if err := DeletePath(srcPath); err != nil { if err := DeletePath(srcPath); err != nil {
return "", err return "", err
} }
@ -220,6 +258,11 @@ func MakeDir(parent string, name string) (string, error) {
} }
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error { func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
info, err := os.Lstat(srcDir) info, err := os.Lstat(srcDir)
if err != nil { if err != nil {
return err return err
@ -234,6 +277,11 @@ func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
} }
for _, entry := range entries { for _, entry := range entries {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcPath := filepath.Join(srcDir, entry.Name()) srcPath := filepath.Join(srcDir, entry.Name())
dstPath := filepath.Join(dstDir, entry.Name()) dstPath := filepath.Join(dstDir, entry.Name())
@ -269,6 +317,11 @@ func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
} }
func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyProgressState) error { func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcFile, err := os.Open(srcPath) srcFile, err := os.Open(srcPath)
if err != nil { if err != nil {
return err return err
@ -286,6 +339,8 @@ func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyPro
writer = &progressWriter{base: dstFile, tracker: tracker, path: srcPath} writer = &progressWriter{base: dstFile, tracker: tracker, path: srcPath}
} }
if _, err := io.Copy(writer, srcFile); err != nil { if _, err := io.Copy(writer, srcFile); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err return err
} }
if tracker != nil { if tracker != nil {
@ -301,6 +356,11 @@ type progressWriter struct {
} }
func (w *progressWriter) Write(data []byte) (int, error) { func (w *progressWriter) Write(data []byte) (int, error) {
if w.tracker != nil && w.tracker.ctx != nil {
if err := w.tracker.ctx.Err(); err != nil {
return 0, err
}
}
n, err := w.base.Write(data) n, err := w.base.Write(data)
if n > 0 { if n > 0 {
w.tracker.addBytes(int64(n), w.path) w.tracker.addBytes(int64(n), w.path)

View file

@ -3,64 +3,66 @@ package ui
import "github.com/charmbracelet/bubbles/key" import "github.com/charmbracelet/bubbles/key"
type KeyMap struct { type KeyMap struct {
Help key.Binding Help key.Binding
View key.Binding View key.Binding
Edit key.Binding Edit key.Binding
Info key.Binding Info key.Binding
SelectText key.Binding SelectText key.Binding
ToggleHidden key.Binding ToggleHidden key.Binding
CycleTheme key.Binding CycleTheme key.Binding
CycleSort key.Binding CycleSort key.Binding
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
SelectUp key.Binding SelectUp key.Binding
SelectDown key.Binding SelectDown key.Binding
PageUp key.Binding PageUp key.Binding
PageDown key.Binding PageDown key.Binding
Open key.Binding Open key.Binding
Back key.Binding Back key.Binding
Switch key.Binding Switch key.Binding
Refresh key.Binding Refresh key.Binding
DirSize key.Binding DirSize key.Binding
Copy key.Binding Copy key.Binding
Move key.Binding Move key.Binding
Mkdir key.Binding Mkdir key.Binding
Delete key.Binding Delete key.Binding
Confirm key.Binding Confirm key.Binding
Background key.Binding Background key.Binding
Cancel key.Binding ProgressCancel key.Binding
Quit key.Binding Cancel key.Binding
Quit key.Binding
} }
func DefaultKeyMap() KeyMap { func DefaultKeyMap() KeyMap {
return KeyMap{ return KeyMap{
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")), Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")), Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")), SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
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")), 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")), 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")),
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), 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")), 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")), Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")), DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")), Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")), Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")), 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")), 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")), Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")), Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")),
Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")), ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")), 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 package ui
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -117,6 +118,8 @@ type copyJobState struct {
progress vfs.CopyProgress progress vfs.CopyProgress
overwrite bool overwrite bool
background bool background bool
cancel context.CancelFunc
startedAt time.Time
} }
type mouseClickState struct { type mouseClickState struct {
@ -334,7 +337,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.busy = false m.busy = false
if msg.err != nil { 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 m.copyJob = nil
if m.modal.kind == modalCopyProgress { if m.modal.kind == modalCopyProgress {
m.modal = modalState{} m.modal = modalState{}
@ -584,7 +591,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case modalCopyProgress: case modalCopyProgress:
if isModalCloseKey(msg, m.keys) { if key.Matches(msg, m.keys.Background) {
if m.copyJob == nil { if m.copyJob == nil {
m.modal = modalState{} m.modal = modalState{}
return m, nil return m, nil
@ -594,14 +601,15 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = "Transfer continues in background" m.status = "Transfer continues in background"
return m, nil return m, nil
} }
if key.Matches(msg, m.keys.Background) { if key.Matches(msg, m.keys.ProgressCancel) {
if m.copyJob == nil { if m.copyJob == nil {
m.modal = modalState{} m.modal = modalState{}
return m, nil return m, nil
} }
m.copyJob.background = true if m.copyJob.cancel != nil {
m.modal = modalState{} m.copyJob.cancel()
m.status = "Transfer continues in background" }
m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..."
return m, nil return m, nil
} }
return m, nil return m, nil
@ -1120,6 +1128,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)",
" c cancel active copy/move transfer",
" F5/F6/F8 apply to marked entries when selection exists", " F5/F6/F8 apply to marked entries when selection exists",
"", "",
"Mouse", "Mouse",
@ -1711,19 +1720,19 @@ 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", transferSourceLabel(job.sourcePaths))),
lineStyle.Render(fmt.Sprintf("To: %s", job.targetDir)),
spacer, spacer,
lineStyle.Render(renderProgressBar(ratio, max(contentWidth-8, 10), palette)), lineStyle.Render(renderProgressBar(ratio, max(contentWidth-8, 10), palette)),
lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)), spacer,
lineStyle.Render(fmt.Sprintf("Size: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))), 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 job.background {
if strings.TrimSpace(progress.CurrentPath) != "" { lines = append(lines, mutedStyle.Render("Transfer continues in background"))
lines = append(lines, lineStyle.Render("Current: "+truncateMiddle(progress.CurrentPath, max(contentWidth-10, 16))))
} }
lines = append(lines, spacer)
lines = append(lines, mutedStyle.Render("Press b to continue in background"))
return box.Render(strings.Join(lines, "\n")) 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)) bar := lipgloss.NewStyle().Foreground(palette.ProgressFill).Render(strings.Repeat("█", filled))
rest := lipgloss.NewStyle().Foreground(palette.ProgressEmpty).Render(strings.Repeat("░", width-filled)) rest := lipgloss.NewStyle().Foreground(palette.ProgressEmpty).Render(strings.Repeat("░", width-filled))
percent := fmt.Sprintf(" %3.0f%%", ratio*100) return bar + rest
return bar + rest + percent }
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 { 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 { 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
ctx, cancel := context.WithCancel(context.Background())
m.copyJob = &copyJobState{ m.copyJob = &copyJobState{
id: jobID, id: jobID,
kind: kind, kind: kind,
@ -1926,6 +2025,8 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
BytesTotal: stats.BytesTotal, BytesTotal: stats.BytesTotal,
CurrentPath: sourcePaths[0], CurrentPath: sourcePaths[0],
}, },
cancel: cancel,
startedAt: time.Now(),
} }
m.modal = modalState{kind: modalCopyProgress} m.modal = modalState{kind: modalCopyProgress}
m.status = strings.Title(operationVerb(kind)) + " started" m.status = strings.Title(operationVerb(kind)) + " started"
@ -1961,9 +2062,9 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
} }
switch kind { switch kind {
case opMove: case opMove:
_, statErr = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn) _, statErr = vfs.MovePathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn)
default: default:
_, statErr = vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn) _, statErr = vfs.CopyPathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn)
} }
if statErr != nil { if statErr != nil {
m.copyProgress <- copyDoneMsg{ m.copyProgress <- copyDoneMsg{