Add copy confirmation and background progress modal

This commit is contained in:
vrubelroman 2026-04-23 12:30:10 +03:00
parent c4fdc41edf
commit a196a16c6f
3 changed files with 509 additions and 38 deletions

View file

@ -4,12 +4,79 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
"time"
)
type TransferStats struct {
FilesTotal int
BytesTotal int64
}
type CopyProgress struct {
FilesDone int
FilesTotal int
BytesDone int64
BytesTotal int64
CurrentPath string
}
type copyProgressState struct {
filesDone int
bytesDone int64
stats TransferStats
callback func(CopyProgress)
lastEmit time.Time
}
func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
return CopyPathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func CopyStats(srcPath string) (TransferStats, error) {
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return TransferStats{}, fmt.Errorf("stat %s: %w", srcPath, err)
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
return TransferStats{FilesTotal: 1, BytesTotal: 0}, nil
}
if !srcInfo.IsDir() {
return TransferStats{FilesTotal: 1, BytesTotal: srcInfo.Size()}, nil
}
stats := TransferStats{}
err = filepath.WalkDir(srcPath, func(current string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := os.Lstat(current)
if err != nil {
return err
}
stats.FilesTotal++
if info.Mode()&os.ModeSymlink == 0 {
stats.BytesTotal += info.Size()
}
return nil
})
if err != nil {
return TransferStats{}, err
}
return stats, nil
}
func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return "", fmt.Errorf("stat %s: %w", srcPath, err)
@ -33,19 +100,44 @@ func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
}
}
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
}
return targetPath, os.Symlink(target, targetPath)
if err := os.Symlink(target, targetPath); err != nil {
return "", err
}
tracker.finishFile(srcPath)
return targetPath, nil
}
if srcInfo.IsDir() {
return targetPath, copyDir(srcPath, targetPath)
if err := copyDir(srcPath, targetPath, &tracker); err != nil {
return "", err
}
tracker.emit(srcPath, true)
return targetPath, nil
}
return targetPath, copyFile(srcPath, targetPath, srcInfo.Mode())
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) {
@ -105,7 +197,7 @@ func MakeDir(parent string, name string) (string, error) {
return target, nil
}
func copyDir(srcDir string, dstDir string) error {
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
info, err := os.Lstat(srcDir)
if err != nil {
return err
@ -137,12 +229,15 @@ func copyDir(srcDir string, dstDir string) error {
if err := os.Symlink(target, dstPath); err != nil {
return err
}
if tracker != nil {
tracker.finishFile(srcPath)
}
case info.IsDir():
if err := copyDir(srcPath, dstPath); err != nil {
if err := copyDir(srcPath, dstPath, tracker); err != nil {
return err
}
default:
if err := copyFile(srcPath, dstPath, info.Mode()); err != nil {
if err := copyFile(srcPath, dstPath, info.Mode(), tracker); err != nil {
return err
}
}
@ -151,7 +246,7 @@ func copyDir(srcDir string, dstDir string) error {
return nil
}
func copyFile(srcPath string, dstPath string, mode os.FileMode) error {
func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyProgressState) error {
srcFile, err := os.Open(srcPath)
if err != nil {
return err
@ -164,12 +259,60 @@ func copyFile(srcPath string, dstPath string, mode os.FileMode) error {
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
writer := io.Writer(dstFile)
if tracker != nil {
writer = &progressWriter{base: dstFile, tracker: tracker, path: srcPath}
}
if _, err := io.Copy(writer, srcFile); err != nil {
return err
}
if tracker != nil {
tracker.finishFile(srcPath)
}
return nil
}
type progressWriter struct {
base io.Writer
tracker *copyProgressState
path string
}
func (w *progressWriter) Write(data []byte) (int, error) {
n, err := w.base.Write(data)
if n > 0 {
w.tracker.addBytes(int64(n), w.path)
}
return n, err
}
func (s *copyProgressState) addBytes(delta int64, currentPath string) {
s.bytesDone += delta
s.emit(currentPath, false)
}
func (s *copyProgressState) finishFile(currentPath string) {
s.filesDone++
s.emit(currentPath, true)
}
func (s *copyProgressState) emit(currentPath string, force bool) {
if s.callback == nil {
return
}
if !force && time.Since(s.lastEmit) < 75*time.Millisecond {
return
}
s.lastEmit = time.Now()
s.callback(CopyProgress{
FilesDone: s.filesDone,
FilesTotal: s.stats.FilesTotal,
BytesDone: s.bytesDone,
BytesTotal: s.stats.BytesTotal,
CurrentPath: currentPath,
})
}
func samePath(left string, right string) (bool, error) {
leftAbs, err := filepath.Abs(left)
if err != nil {

View file

@ -24,6 +24,7 @@ type KeyMap struct {
Mkdir key.Binding
Delete key.Binding
Confirm key.Binding
Background key.Binding
Cancel key.Binding
Quit key.Binding
}
@ -51,6 +52,7 @@ func DefaultKeyMap() KeyMap {
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")),
}

View file

@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"vcom/internal/config"
vfs "vcom/internal/fs"
@ -25,6 +26,8 @@ const (
modalNone modalKind = iota
modalMkdir
modalConfirm
modalCopyProgress
modalNotice
)
type fileOpKind int
@ -43,6 +46,7 @@ type pendingOperation struct {
sourcePath string
targetDir string
overwrite bool
stats vfs.TransferStats
}
type modalState struct {
@ -72,6 +76,39 @@ type opMsg struct {
err error
}
type copyPlanMsg struct {
sourcePath string
targetDir string
targetPath string
overwrite bool
stats vfs.TransferStats
err error
}
type copyProgressMsg struct {
jobID int
progress vfs.CopyProgress
}
type copyDoneMsg struct {
jobID int
sourcePath string
targetPath string
err error
}
type dismissNoticeMsg struct{}
type copyJobState struct {
id int
sourcePath string
targetDir string
targetPath string
progress vfs.CopyProgress
overwrite bool
background bool
}
type mouseClickState struct {
pane PaneID
index int
@ -108,6 +145,10 @@ type Model struct {
lastClick mouseClickState
hover hoverState
copyJob *copyJobState
nextCopyJob int
copyProgress chan tea.Msg
}
func NewModel(cfg config.Config, configPath string) (Model, error) {
@ -139,6 +180,7 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
right: BrowserPane{ID: PaneRight, Path: rightPath},
active: PaneLeft,
status: "Ready",
copyProgress: make(chan tea.Msg, 256),
}
model.previewModel = viewport.New(0, 0)
@ -214,6 +256,86 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_ = m.reloadPane(PaneRight, activeSelection)
return m, m.loadPreviewCmd()
case copyPlanMsg:
m.busy = false
if msg.err != nil {
m.status = msg.err.Error()
return m, nil
}
title := "Copy selected entry?"
body := strings.Join([]string{
fmt.Sprintf("From: %s", msg.sourcePath),
fmt.Sprintf("To: %s", msg.targetPath),
"",
fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
fmt.Sprintf("Data: %s", formatSize(msg.stats.BytesTotal, true)),
}, "\n")
if msg.overwrite {
body += "\n\nTarget exists and will be overwritten."
}
note := "Enter/y to start copy, Esc/n to cancel"
m.openConfirmModal(title, body, note, pendingOperation{
kind: opCopy,
sourcePath: msg.sourcePath,
targetDir: msg.targetDir,
overwrite: msg.overwrite,
stats: msg.stats,
})
return m, nil
case copyProgressMsg:
if m.copyJob == nil || msg.jobID != m.copyJob.id {
return m, nil
}
m.copyJob.progress = msg.progress
if m.copyJob.background {
m.status = formatCopyStatus(msg.progress)
}
return m, waitCopyProgressCmd(m.copyProgress)
case copyDoneMsg:
if m.copyJob == nil || msg.jobID != m.copyJob.id {
return m, nil
}
m.busy = false
if msg.err != nil {
m.status = fmt.Sprintf("Copy failed: %v", msg.err)
m.copyJob = nil
if m.modal.kind == modalCopyProgress {
m.modal = modalState{}
}
return m, nil
}
m.status = fmt.Sprintf("Copied to %s", msg.targetPath)
activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection)
background := m.copyJob.background
m.copyJob = nil
cmd := m.loadPreviewCmd()
if m.modal.kind == modalCopyProgress {
m.modal = modalState{}
}
if background {
m.modal = modalState{
kind: modalNotice,
title: "Copy complete",
body: filepath.Base(msg.sourcePath) + " copied successfully.",
}
cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second))
}
return m, cmd
case dismissNoticeMsg:
if m.modal.kind == modalNotice {
m.modal = modalState{}
}
return m, nil
case tea.KeyMsg:
if m.modal.kind != modalNone {
return m.handleModalKey(msg)
@ -345,7 +467,7 @@ func (m Model) View() string {
Foreground(m.palette.Text).
Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
if m.modal.kind != modalNone {
view = overlayCenter(view, renderModal(m.modal, m.palette, min(64, m.width-8)), m.width)
view = overlayCenter(view, renderModal(m, m.palette, min(72, m.width-8)), m.width)
}
return view
}
@ -384,11 +506,38 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = "Nothing to confirm"
return m, nil
}
m.busy = true
cmd := m.modal.pending.cmd()
pending := *m.modal.pending
m.modal = modalState{}
return m, cmd
if pending.kind == opCopy {
if m.copyJob != nil {
m.status = "Copy is already running"
return m, nil
}
m.busy = true
return m, m.startCopyJob(pending.sourcePath, pending.targetDir, pending.overwrite, pending.stats)
}
m.busy = true
return m, pending.cmd()
}
case modalCopyProgress:
if key.Matches(msg, m.keys.Background) {
if m.copyJob == nil {
m.modal = modalState{}
return m, nil
}
m.copyJob.background = true
m.modal = modalState{}
m.status = "Copy continues in background"
return m, nil
}
return m, nil
case modalNotice:
if key.Matches(msg, m.keys.Confirm) || key.Matches(msg, m.keys.Cancel) {
m.modal = modalState{}
}
return m, nil
}
return m, nil
@ -555,6 +704,21 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
return m, nil
}
if kind == opCopy {
if m.copyJob != nil {
m.status = "Copy is already running"
return m, nil
}
overwrite := exists
if exists && !m.cfg.Behavior.ConfirmOverwrite {
overwrite = true
}
m.busy = true
m.status = fmt.Sprintf("Calculating copy size for %s", selected.DisplayName())
return m, copyPlanCmd(selected.Path, targetDir, overwrite)
}
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)
@ -1122,7 +1286,18 @@ func renderFooter(m Model) string {
)
}
func renderModal(modal modalState, palette theme.Palette, width int) string {
func renderModal(m Model, palette theme.Palette, width int) string {
if m.modal.kind == modalCopyProgress && m.copyJob != nil {
return renderCopyProgressModal(*m.copyJob, palette, width)
}
modal := m.modal
contentWidth := max(width-4, 1)
titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
bodyStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
noteStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")
box := lipgloss.NewStyle().
Width(width).
Padding(1, 2).
@ -1131,27 +1306,113 @@ func renderModal(modal modalState, palette theme.Palette, width int) string {
BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(palette.BorderActive)
lines := []string{
lipgloss.NewStyle().Bold(true).Foreground(palette.Accent).Render(modal.title),
lipgloss.NewStyle().Foreground(palette.Muted).Render(modal.body),
}
lines := []string{titleStyle.Render(modal.title), spacer, bodyStyle.Render(modal.body)}
if modal.kind == modalMkdir {
lines = append(lines, modal.input.View())
lines = append(lines, spacer, lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(modal.input.View()))
}
if modal.note != "" {
lines = append(lines, lipgloss.NewStyle().Foreground(palette.Muted).Render(modal.note))
lines = append(lines, spacer, noteStyle.Render(modal.note))
}
return box.Render(strings.Join(lines, "\n\n"))
return box.Render(strings.Join(lines, "\n"))
}
func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) string {
contentWidth := max(width-4, 1)
titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
lineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Text)
mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")
box := lipgloss.NewStyle().
Width(width).
Padding(1, 2).
Background(palette.Panel).
Foreground(palette.Text).
BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(palette.BorderActive)
progress := job.progress
ratio := 0.0
if progress.BytesTotal > 0 {
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
}
lines := []string{
titleStyle.Render("Copying"),
lineStyle.Render(fmt.Sprintf("From: %s", job.sourcePath)),
lineStyle.Render(fmt.Sprintf("To: %s", job.targetPath)),
spacer,
lineStyle.Render(renderProgressBar(ratio, max(width-8, 10), palette)),
lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)),
lineStyle.Render(fmt.Sprintf("Data: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))),
}
if strings.TrimSpace(progress.CurrentPath) != "" {
lines = append(lines, lineStyle.Render("Current: "+truncateMiddle(progress.CurrentPath, max(width-18, 16))))
}
lines = append(lines, spacer)
lines = append(lines, mutedStyle.Render("Press b to continue in background"))
return box.Render(strings.Join(lines, "\n"))
}
func renderProgressBar(ratio float64, width int, palette theme.Palette) string {
if width < 10 {
width = 10
}
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
filled := int(float64(width) * ratio)
if filled > width {
filled = width
}
if filled < 0 {
filled = 0
}
bar := lipgloss.NewStyle().Foreground(palette.Accent).Render(strings.Repeat("█", filled))
rest := lipgloss.NewStyle().Foreground(palette.Border).Render(strings.Repeat("░", width-filled))
percent := fmt.Sprintf(" %3.0f%%", ratio*100)
return bar + rest + percent
}
func overlayCenter(base string, overlay string, width int) string {
if width <= 0 {
return base + "\n" + overlay
return base
}
centered := lipgloss.Place(width, lipgloss.Height(overlay), lipgloss.Center, lipgloss.Top, overlay)
return base + "\n" + centered
baseLines := strings.Split(base, "\n")
overlayLines := strings.Split(overlay, "\n")
if len(baseLines) == 0 || len(overlayLines) == 0 {
return base
}
overlayWidth := 0
for _, line := range overlayLines {
overlayWidth = max(overlayWidth, ansi.StringWidth(line))
}
startY := max((len(baseLines)-len(overlayLines))/2, 0)
startX := max((width-overlayWidth)/2, 0)
endX := startX + overlayWidth
for idx, line := range overlayLines {
targetY := startY + idx
if targetY >= len(baseLines) {
break
}
baseLine := baseLines[targetY]
left := ansi.Cut(baseLine, 0, startX)
right := ansi.Cut(baseLine, endX, width)
baseLines[targetY] = left + line + right
}
return strings.Join(baseLines, "\n")
}
func renderPreviewContent(viewportModel *viewport.Model, palette theme.Palette, width int, height int) string {
@ -1204,8 +1465,6 @@ func previewIcon(preview vfs.Preview) string {
func (p pendingOperation) cmd() tea.Cmd {
switch p.kind {
case opCopy:
return copyCmd(p.sourcePath, p.targetDir, p.overwrite)
case opMove:
return moveCmd(p.sourcePath, p.targetDir, p.overwrite)
case opDelete:
@ -1217,8 +1476,6 @@ func (p pendingOperation) cmd() tea.Cmd {
func operationCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd {
switch kind {
case opCopy:
return copyCmd(sourcePath, targetDir, overwrite)
case opMove:
return moveCmd(sourcePath, targetDir, overwrite)
default:
@ -1233,12 +1490,71 @@ func dirSizeCmd(path string) tea.Cmd {
}
}
func copyCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
func copyPlanCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
return func() tea.Msg {
targetPath, err := vfs.CopyPath(sourcePath, targetDir, overwrite)
return opMsg{kind: opCopy, sourcePath: sourcePath, targetPath: targetPath, err: err}
stats, err := vfs.CopyStats(sourcePath)
return copyPlanMsg{
sourcePath: sourcePath,
targetDir: targetDir,
targetPath: filepath.Join(targetDir, filepath.Base(sourcePath)),
overwrite: overwrite,
stats: stats,
err: err,
}
}
}
func waitCopyProgressCmd(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-ch
}
}
func dismissNoticeCmd(delay time.Duration) tea.Cmd {
return tea.Tick(delay, func(time.Time) tea.Msg {
return dismissNoticeMsg{}
})
}
func (m *Model) startCopyJob(sourcePath, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
m.nextCopyJob++
jobID := m.nextCopyJob
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
m.copyJob = &copyJobState{
id: jobID,
sourcePath: sourcePath,
targetDir: targetDir,
targetPath: targetPath,
overwrite: overwrite,
progress: vfs.CopyProgress{
FilesDone: 0,
FilesTotal: stats.FilesTotal,
BytesDone: 0,
BytesTotal: stats.BytesTotal,
CurrentPath: sourcePath,
},
}
m.modal = modalState{kind: modalCopyProgress}
m.status = "Copy started"
return tea.Batch(
func() tea.Msg {
go func() {
target, err := vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, stats, func(progress vfs.CopyProgress) {
m.copyProgress <- copyProgressMsg{jobID: jobID, progress: progress}
})
m.copyProgress <- copyDoneMsg{
jobID: jobID,
sourcePath: sourcePath,
targetPath: target,
err: err,
}
}()
return nil
},
waitCopyProgressCmd(m.copyProgress),
)
}
func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
return func() tea.Msg {
@ -1290,6 +1606,16 @@ func formatSize(size int64, human bool) string {
return fmt.Sprintf("%d", size)
}
func formatCopyStatus(progress vfs.CopyProgress) string {
return fmt.Sprintf(
"Copy in background: %d/%d files, %s/%s",
progress.FilesDone,
progress.FilesTotal,
formatSize(progress.BytesDone, true),
formatSize(progress.BytesTotal, true),
)
}
func operationVerb(kind fileOpKind) string {
switch kind {
case opCopy: