Add copy confirmation and background progress modal
This commit is contained in:
parent
c4fdc41edf
commit
a196a16c6f
3 changed files with 509 additions and 38 deletions
|
|
@ -4,12 +4,79 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"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) {
|
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)
|
srcInfo, err := os.Lstat(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("stat %s: %w", srcPath, err)
|
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 {
|
if srcInfo.Mode()&os.ModeSymlink != 0 {
|
||||||
target, err := os.Readlink(srcPath)
|
target, err := os.Readlink(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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() {
|
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) {
|
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
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyDir(srcDir string, dstDir string) error {
|
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
|
||||||
info, err := os.Lstat(srcDir)
|
info, err := os.Lstat(srcDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -137,12 +229,15 @@ func copyDir(srcDir string, dstDir string) error {
|
||||||
if err := os.Symlink(target, dstPath); err != nil {
|
if err := os.Symlink(target, dstPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if tracker != nil {
|
||||||
|
tracker.finishFile(srcPath)
|
||||||
|
}
|
||||||
case info.IsDir():
|
case info.IsDir():
|
||||||
if err := copyDir(srcPath, dstPath); err != nil {
|
if err := copyDir(srcPath, dstPath, tracker); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if err := copyFile(srcPath, dstPath, info.Mode()); err != nil {
|
if err := copyFile(srcPath, dstPath, info.Mode(), tracker); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +246,7 @@ func copyDir(srcDir string, dstDir string) error {
|
||||||
return nil
|
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)
|
srcFile, err := os.Open(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -164,12 +259,60 @@ func copyFile(srcPath string, dstPath string, mode os.FileMode) error {
|
||||||
}
|
}
|
||||||
defer dstFile.Close()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
if tracker != nil {
|
||||||
|
tracker.finishFile(srcPath)
|
||||||
|
}
|
||||||
return nil
|
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) {
|
func samePath(left string, right string) (bool, error) {
|
||||||
leftAbs, err := filepath.Abs(left)
|
leftAbs, err := filepath.Abs(left)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type KeyMap struct {
|
||||||
Mkdir key.Binding
|
Mkdir key.Binding
|
||||||
Delete key.Binding
|
Delete key.Binding
|
||||||
Confirm key.Binding
|
Confirm key.Binding
|
||||||
|
Background key.Binding
|
||||||
Cancel key.Binding
|
Cancel key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +52,7 @@ func DefaultKeyMap() KeyMap {
|
||||||
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")),
|
||||||
Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")),
|
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")),
|
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
"vcom/internal/config"
|
"vcom/internal/config"
|
||||||
vfs "vcom/internal/fs"
|
vfs "vcom/internal/fs"
|
||||||
|
|
@ -25,6 +26,8 @@ const (
|
||||||
modalNone modalKind = iota
|
modalNone modalKind = iota
|
||||||
modalMkdir
|
modalMkdir
|
||||||
modalConfirm
|
modalConfirm
|
||||||
|
modalCopyProgress
|
||||||
|
modalNotice
|
||||||
)
|
)
|
||||||
|
|
||||||
type fileOpKind int
|
type fileOpKind int
|
||||||
|
|
@ -43,6 +46,7 @@ type pendingOperation struct {
|
||||||
sourcePath string
|
sourcePath string
|
||||||
targetDir string
|
targetDir string
|
||||||
overwrite bool
|
overwrite bool
|
||||||
|
stats vfs.TransferStats
|
||||||
}
|
}
|
||||||
|
|
||||||
type modalState struct {
|
type modalState struct {
|
||||||
|
|
@ -72,6 +76,39 @@ type opMsg struct {
|
||||||
err error
|
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 {
|
type mouseClickState struct {
|
||||||
pane PaneID
|
pane PaneID
|
||||||
index int
|
index int
|
||||||
|
|
@ -108,6 +145,10 @@ type Model struct {
|
||||||
|
|
||||||
lastClick mouseClickState
|
lastClick mouseClickState
|
||||||
hover hoverState
|
hover hoverState
|
||||||
|
|
||||||
|
copyJob *copyJobState
|
||||||
|
nextCopyJob int
|
||||||
|
copyProgress chan tea.Msg
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
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},
|
right: BrowserPane{ID: PaneRight, Path: rightPath},
|
||||||
active: PaneLeft,
|
active: PaneLeft,
|
||||||
status: "Ready",
|
status: "Ready",
|
||||||
|
copyProgress: make(chan tea.Msg, 256),
|
||||||
}
|
}
|
||||||
|
|
||||||
model.previewModel = viewport.New(0, 0)
|
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)
|
_ = m.reloadPane(PaneRight, activeSelection)
|
||||||
return m, m.loadPreviewCmd()
|
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:
|
case tea.KeyMsg:
|
||||||
if m.modal.kind != modalNone {
|
if m.modal.kind != modalNone {
|
||||||
return m.handleModalKey(msg)
|
return m.handleModalKey(msg)
|
||||||
|
|
@ -345,7 +467,7 @@ func (m Model) View() string {
|
||||||
Foreground(m.palette.Text).
|
Foreground(m.palette.Text).
|
||||||
Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
|
Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
|
||||||
if m.modal.kind != modalNone {
|
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
|
return view
|
||||||
}
|
}
|
||||||
|
|
@ -384,11 +506,38 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.status = "Nothing to confirm"
|
m.status = "Nothing to confirm"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.busy = true
|
pending := *m.modal.pending
|
||||||
cmd := m.modal.pending.cmd()
|
|
||||||
m.modal = modalState{}
|
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
|
return m, nil
|
||||||
|
|
@ -555,6 +704,21 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
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 {
|
if exists && m.cfg.Behavior.ConfirmOverwrite {
|
||||||
title := fmt.Sprintf("Overwrite existing target before %s?", operationVerb(kind))
|
title := fmt.Sprintf("Overwrite existing target before %s?", operationVerb(kind))
|
||||||
body := fmt.Sprintf("%s\n\n-> %s", selected.Path, targetPath)
|
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().
|
box := lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
|
|
@ -1131,27 +1306,113 @@ func renderModal(modal modalState, palette theme.Palette, width int) string {
|
||||||
BorderStyle(lipgloss.DoubleBorder()).
|
BorderStyle(lipgloss.DoubleBorder()).
|
||||||
BorderForeground(palette.BorderActive)
|
BorderForeground(palette.BorderActive)
|
||||||
|
|
||||||
lines := []string{
|
lines := []string{titleStyle.Render(modal.title), spacer, bodyStyle.Render(modal.body)}
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(palette.Accent).Render(modal.title),
|
|
||||||
lipgloss.NewStyle().Foreground(palette.Muted).Render(modal.body),
|
|
||||||
}
|
|
||||||
|
|
||||||
if modal.kind == modalMkdir {
|
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 != "" {
|
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 {
|
func overlayCenter(base string, overlay string, width int) string {
|
||||||
if width <= 0 {
|
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 {
|
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 {
|
func (p pendingOperation) cmd() tea.Cmd {
|
||||||
switch p.kind {
|
switch p.kind {
|
||||||
case opCopy:
|
|
||||||
return copyCmd(p.sourcePath, p.targetDir, p.overwrite)
|
|
||||||
case opMove:
|
case opMove:
|
||||||
return moveCmd(p.sourcePath, p.targetDir, p.overwrite)
|
return moveCmd(p.sourcePath, p.targetDir, p.overwrite)
|
||||||
case opDelete:
|
case opDelete:
|
||||||
|
|
@ -1217,8 +1476,6 @@ func (p pendingOperation) cmd() tea.Cmd {
|
||||||
|
|
||||||
func operationCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd {
|
func operationCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd {
|
||||||
switch kind {
|
switch kind {
|
||||||
case opCopy:
|
|
||||||
return copyCmd(sourcePath, targetDir, overwrite)
|
|
||||||
case opMove:
|
case opMove:
|
||||||
return moveCmd(sourcePath, targetDir, overwrite)
|
return moveCmd(sourcePath, targetDir, overwrite)
|
||||||
default:
|
default:
|
||||||
|
|
@ -1233,11 +1490,70 @@ 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 {
|
return func() tea.Msg {
|
||||||
targetPath, err := vfs.CopyPath(sourcePath, targetDir, overwrite)
|
stats, err := vfs.CopyStats(sourcePath)
|
||||||
return opMsg{kind: opCopy, sourcePath: sourcePath, targetPath: targetPath, err: err}
|
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 = ©JobState{
|
||||||
|
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 {
|
func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
|
||||||
|
|
@ -1290,6 +1606,16 @@ func formatSize(size int64, human bool) string {
|
||||||
return fmt.Sprintf("%d", size)
|
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 {
|
func operationVerb(kind fileOpKind) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case opCopy:
|
case opCopy:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue