Fix archive cursor, navigation, and format cycling color
- Reset cursor to '..' (position 0) when entering an archive so it
doesn't stay at the previous directory's cursor position
- Fix archive exit landing in /tmp when pressing Enter on '..' inside
an archive by delegating to archive-aware goParent() in enterSelected()
- Fix goParent() to handle ALL navigation within archive mounts
explicitly instead of falling through to filepath.Dir which may
navigate outside the mount to /tmp
- Fix format cycling text color reset by preserving '(f to change)'
hint in modal note, ensuring renderModalNoteLine applies proper
key/action styling via the '(' separator match
This commit is contained in:
parent
3e34944f99
commit
33974cdcb7
4 changed files with 776 additions and 20 deletions
|
|
@ -4,11 +4,13 @@ import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
||||||
|
|
@ -156,3 +158,374 @@ func safeArchivePath(name string) (string, bool) {
|
||||||
}
|
}
|
||||||
return clean, true
|
return clean, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveFormat returns the file extension for a given archive format name.
|
||||||
|
func ArchiveFormat(format string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
|
case "zip":
|
||||||
|
return ".zip"
|
||||||
|
case "tar":
|
||||||
|
return ".tar"
|
||||||
|
case "targz", "tar.gz", "tgz":
|
||||||
|
return ".tar.gz"
|
||||||
|
default:
|
||||||
|
return ".zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveName generates an archive filename from source paths.
|
||||||
|
func ArchiveName(sources []string, format string) string {
|
||||||
|
ext := ArchiveFormat(format)
|
||||||
|
if len(sources) == 1 {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(sources[0]), filepath.Ext(sources[0]))
|
||||||
|
return base + ext
|
||||||
|
}
|
||||||
|
base := filepath.Base(filepath.Dir(sources[0]))
|
||||||
|
if base == "." || base == "" || base == string(filepath.Separator) {
|
||||||
|
base = "archive"
|
||||||
|
}
|
||||||
|
return base + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArchive creates an archive from source paths using the given format.
|
||||||
|
// Supported formats: "zip", "tar", "tar.gz" (or "targz", "tgz").
|
||||||
|
// Progress is reported via the callback function.
|
||||||
|
func CreateArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(archivePath)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lower, ".zip"):
|
||||||
|
return createZipArchive(ctx, sources, archivePath, progress)
|
||||||
|
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
|
||||||
|
return createTarGzArchive(ctx, sources, archivePath, progress)
|
||||||
|
case strings.HasSuffix(lower, ".tar"):
|
||||||
|
return createTarArchive(ctx, sources, archivePath, progress)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported archive format: %s", filepath.Ext(archivePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createZipArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
|
||||||
|
file, err := os.Create(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", archivePath, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(file)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
var totalFiles int
|
||||||
|
var totalBytes int64
|
||||||
|
for _, source := range sources {
|
||||||
|
info, err := os.Lstat(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", source, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalFiles++
|
||||||
|
if !info.IsDir() {
|
||||||
|
totalBytes += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalFiles++
|
||||||
|
totalBytes += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state := ©ProgressState{
|
||||||
|
ctx: ctx,
|
||||||
|
stats: TransferStats{FilesTotal: totalFiles, BytesTotal: totalBytes},
|
||||||
|
callback: progress,
|
||||||
|
lastEmit: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := commonBaseDir(sources)
|
||||||
|
for _, source := range sources {
|
||||||
|
info, err := os.Lstat(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", source, err)
|
||||||
|
}
|
||||||
|
relRoot := source
|
||||||
|
if baseDir != "" {
|
||||||
|
relRoot, _ = filepath.Rel(baseDir, source)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
relPath, _ := filepath.Rel(baseDir, path)
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
header, zipErr := zip.FileInfoHeader(info)
|
||||||
|
if zipErr != nil {
|
||||||
|
return zipErr
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
if info.IsDir() {
|
||||||
|
header.Name += "/"
|
||||||
|
} else {
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
}
|
||||||
|
writer, zipErr := zipWriter.CreateHeader(header)
|
||||||
|
if zipErr != nil {
|
||||||
|
return zipErr
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
f, openErr := os.Open(path)
|
||||||
|
if openErr != nil {
|
||||||
|
return openErr
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(writer, f)
|
||||||
|
f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
state.filesDone++
|
||||||
|
state.bytesDone += written
|
||||||
|
} else {
|
||||||
|
state.filesDone++
|
||||||
|
}
|
||||||
|
emitArchiveProgress(state, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
relPath := filepath.ToSlash(relRoot)
|
||||||
|
header, zipErr := zip.FileInfoHeader(info)
|
||||||
|
if zipErr != nil {
|
||||||
|
return zipErr
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
writer, zipErr := zipWriter.CreateHeader(header)
|
||||||
|
if zipErr != nil {
|
||||||
|
return zipErr
|
||||||
|
}
|
||||||
|
f, openErr := os.Open(source)
|
||||||
|
if openErr != nil {
|
||||||
|
return openErr
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(writer, f)
|
||||||
|
f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
state.filesDone++
|
||||||
|
state.bytesDone += written
|
||||||
|
emitArchiveProgress(state, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTarArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
|
||||||
|
return createTarArchiveWithGzip(ctx, sources, archivePath, false, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTarGzArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
|
||||||
|
return createTarArchiveWithGzip(ctx, sources, archivePath, true, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTarArchiveWithGzip(ctx context.Context, sources []string, archivePath string, gzipped bool, progress func(CopyProgress)) error {
|
||||||
|
file, err := os.Create(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", archivePath, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var writer io.WriteCloser = file
|
||||||
|
if gzipped {
|
||||||
|
gzipWriter := gzip.NewWriter(file)
|
||||||
|
defer gzipWriter.Close()
|
||||||
|
writer = gzipWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
tarWriter := tar.NewWriter(writer)
|
||||||
|
defer tarWriter.Close()
|
||||||
|
|
||||||
|
var totalFiles int
|
||||||
|
var totalBytes int64
|
||||||
|
for _, source := range sources {
|
||||||
|
info, err := os.Lstat(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", source, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalFiles++
|
||||||
|
if !info.IsDir() {
|
||||||
|
totalBytes += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalFiles++
|
||||||
|
totalBytes += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state := ©ProgressState{
|
||||||
|
ctx: ctx,
|
||||||
|
stats: TransferStats{FilesTotal: totalFiles, BytesTotal: totalBytes},
|
||||||
|
callback: progress,
|
||||||
|
lastEmit: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := commonBaseDir(sources)
|
||||||
|
for _, source := range sources {
|
||||||
|
info, err := os.Lstat(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", source, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
relPath, _ := filepath.Rel(baseDir, path)
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
header, tarErr := tar.FileInfoHeader(info, path)
|
||||||
|
if tarErr != nil {
|
||||||
|
return tarErr
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
if info.IsDir() {
|
||||||
|
header.Name += "/"
|
||||||
|
}
|
||||||
|
if err := tarWriter.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
f, openErr := os.Open(path)
|
||||||
|
if openErr != nil {
|
||||||
|
return openErr
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(tarWriter, f)
|
||||||
|
f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
state.filesDone++
|
||||||
|
state.bytesDone += written
|
||||||
|
} else {
|
||||||
|
state.filesDone++
|
||||||
|
}
|
||||||
|
emitArchiveProgress(state, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
relPath, _ := filepath.Rel(baseDir, source)
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
header, tarErr := tar.FileInfoHeader(info, source)
|
||||||
|
if tarErr != nil {
|
||||||
|
return tarErr
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
if err := tarWriter.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, openErr := os.Open(source)
|
||||||
|
if openErr != nil {
|
||||||
|
return openErr
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(tarWriter, f)
|
||||||
|
f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
state.filesDone++
|
||||||
|
state.bytesDone += written
|
||||||
|
emitArchiveProgress(state, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitArchiveProgress(state *copyProgressState, currentPath string) {
|
||||||
|
if state.callback == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(state.lastEmit) < 50*time.Millisecond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.lastEmit = now
|
||||||
|
state.callback(CopyProgress{
|
||||||
|
FilesDone: state.filesDone,
|
||||||
|
FilesTotal: state.stats.FilesTotal,
|
||||||
|
BytesDone: state.bytesDone,
|
||||||
|
BytesTotal: state.stats.BytesTotal,
|
||||||
|
CurrentPath: currentPath,
|
||||||
|
Stage: "Archiving",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonBaseDir returns the longest common directory prefix for the given paths.
|
||||||
|
func commonBaseDir(paths []string) string {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(paths) == 1 {
|
||||||
|
if info, err := os.Lstat(paths[0]); err == nil && info.IsDir() {
|
||||||
|
return filepath.Dir(paths[0])
|
||||||
|
}
|
||||||
|
return filepath.Dir(paths[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Dir(paths[0])
|
||||||
|
for _, p := range paths[1:] {
|
||||||
|
dir := filepath.Dir(p)
|
||||||
|
for !strings.HasPrefix(dir, base) && base != "" {
|
||||||
|
parent := filepath.Dir(base)
|
||||||
|
if parent == base {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type KeyMap struct {
|
||||||
Edit key.Binding
|
Edit key.Binding
|
||||||
Rename key.Binding
|
Rename key.Binding
|
||||||
Info key.Binding
|
Info key.Binding
|
||||||
|
Archive key.Binding
|
||||||
SelectText key.Binding
|
SelectText key.Binding
|
||||||
ToggleHidden key.Binding
|
ToggleHidden key.Binding
|
||||||
CycleTheme key.Binding
|
CycleTheme key.Binding
|
||||||
|
|
@ -43,7 +44,8 @@ func DefaultKeyMap() KeyMap {
|
||||||
View: key.NewBinding(key.WithKeys("f3"), key.WithHelp("F3", "view")),
|
View: key.NewBinding(key.WithKeys("f3"), key.WithHelp("F3", "view")),
|
||||||
Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")),
|
Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")),
|
||||||
Caret: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "caret")),
|
Caret: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "caret")),
|
||||||
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
|
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
|
||||||
|
Archive: key.NewBinding(key.WithKeys("f4", "a"), key.WithHelp("F4/a", "archive")),
|
||||||
Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "info")),
|
Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "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")),
|
||||||
|
|
@ -73,13 +75,13 @@ func DefaultKeyMap() KeyMap {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k KeyMap) ShortHelp() []key.Binding {
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
return []key.Binding{k.Help, k.Rename, k.View, k.Visual, k.Copy, k.Delete, k.Info, k.Quit}
|
return []key.Binding{k.Help, k.Rename, k.View, k.Archive, k.Copy, k.Delete, k.Info, k.Quit}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.SelectUp, k.SelectDown, k.Open, k.Back},
|
{k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back},
|
||||||
{k.Rename, k.View, k.Caret, k.Visual, k.Edit, k.Copy, k.Move, k.Delete},
|
{k.Rename, k.View, k.Caret, k.Visual, k.Edit, k.Archive, k.Copy, k.Move, 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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const (
|
||||||
modalCopyProgress
|
modalCopyProgress
|
||||||
modalNotice
|
modalNotice
|
||||||
modalHelp
|
modalHelp
|
||||||
|
modalArchiveType
|
||||||
|
modalArchiveProgress
|
||||||
)
|
)
|
||||||
|
|
||||||
type fileOpKind int
|
type fileOpKind int
|
||||||
|
|
@ -45,6 +47,7 @@ const (
|
||||||
opRename
|
opRename
|
||||||
opEdit
|
opEdit
|
||||||
opView
|
opView
|
||||||
|
opArchive
|
||||||
)
|
)
|
||||||
|
|
||||||
type pendingOperation struct {
|
type pendingOperation struct {
|
||||||
|
|
@ -104,6 +107,25 @@ type deletePlanMsg struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type archivePlanMsg struct {
|
||||||
|
sourcePaths []string
|
||||||
|
targetDir string
|
||||||
|
stats vfs.TransferStats
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveProgressMsg struct {
|
||||||
|
jobID int
|
||||||
|
progress vfs.CopyProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveDoneMsg struct {
|
||||||
|
jobID int
|
||||||
|
sourcePaths []string
|
||||||
|
targetPath string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type copyDoneMsg struct {
|
type copyDoneMsg struct {
|
||||||
jobID int
|
jobID int
|
||||||
kind fileOpKind
|
kind fileOpKind
|
||||||
|
|
@ -131,6 +153,16 @@ type copyJobState struct {
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type archiveJobState struct {
|
||||||
|
id int
|
||||||
|
sourcePaths []string
|
||||||
|
targetPath string
|
||||||
|
progress vfs.CopyProgress
|
||||||
|
background bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
startedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type mouseClickState struct {
|
type mouseClickState struct {
|
||||||
pane PaneID
|
pane PaneID
|
||||||
index int
|
index int
|
||||||
|
|
@ -184,6 +216,11 @@ type Model struct {
|
||||||
nextCopyJob int
|
nextCopyJob int
|
||||||
copyProgress chan tea.Msg
|
copyProgress chan tea.Msg
|
||||||
copyPath string
|
copyPath string
|
||||||
|
|
||||||
|
archiveJob *archiveJobState
|
||||||
|
nextArchiveJob int
|
||||||
|
archiveProgress chan tea.Msg
|
||||||
|
archiveFormat string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||||
|
|
@ -207,16 +244,17 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
model := Model{
|
model := Model{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
keys: DefaultKeyMap(),
|
keys: DefaultKeyMap(),
|
||||||
overlay: newImageOverlayManager(),
|
overlay: newImageOverlayManager(),
|
||||||
left: BrowserPane{ID: PaneLeft, Path: leftPath},
|
left: BrowserPane{ID: PaneLeft, Path: leftPath},
|
||||||
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),
|
copyProgress: make(chan tea.Msg, 256),
|
||||||
|
archiveProgress: make(chan tea.Msg, 256),
|
||||||
}
|
}
|
||||||
model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode)
|
model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode)
|
||||||
if model.status == "" {
|
if model.status == "" {
|
||||||
|
|
@ -363,6 +401,95 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
)
|
)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case archivePlanMsg:
|
||||||
|
m.busy = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.status = msg.err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.archiveFormat = "zip"
|
||||||
|
bodyLines := []string{
|
||||||
|
fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
|
||||||
|
fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
|
||||||
|
fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)),
|
||||||
|
}
|
||||||
|
m.modal = modalState{
|
||||||
|
kind: modalArchiveType,
|
||||||
|
title: "Archive selected files?",
|
||||||
|
body: strings.Join(bodyLines, "\n"),
|
||||||
|
note: fmt.Sprintf(
|
||||||
|
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel",
|
||||||
|
m.archiveFormat,
|
||||||
|
),
|
||||||
|
pending: &pendingOperation{
|
||||||
|
kind: opArchive,
|
||||||
|
sourcePaths: append([]string(nil), msg.sourcePaths...),
|
||||||
|
targetDir: msg.targetDir,
|
||||||
|
stats: msg.stats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case archiveProgressMsg:
|
||||||
|
if m.archiveJob == nil || msg.jobID != m.archiveJob.id {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.archiveJob.progress = msg.progress
|
||||||
|
if m.archiveJob.background {
|
||||||
|
m.status = formatArchiveStatus(msg.progress)
|
||||||
|
}
|
||||||
|
return m, waitArchiveProgressCmd(m.archiveProgress)
|
||||||
|
|
||||||
|
case archiveDoneMsg:
|
||||||
|
if m.archiveJob == nil || msg.jobID != m.archiveJob.id {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.busy = false
|
||||||
|
if msg.err != nil {
|
||||||
|
activeSelection := selectedName(m.activePane())
|
||||||
|
_ = m.reloadPane(PaneLeft, activeSelection)
|
||||||
|
_ = m.reloadPane(PaneRight, activeSelection)
|
||||||
|
if msg.err == context.Canceled {
|
||||||
|
m.status = "Archiving cancelled"
|
||||||
|
} else {
|
||||||
|
m.status = fmt.Sprintf("Archiving failed: %v", msg.err)
|
||||||
|
}
|
||||||
|
m.archiveJob = nil
|
||||||
|
if m.modal.kind == modalArchiveProgress {
|
||||||
|
m.modal = modalState{}
|
||||||
|
}
|
||||||
|
return m, m.loadPreviewCmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
m.status = fmt.Sprintf("Archived %d entr%s to %s", len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetPath)
|
||||||
|
activeSelection := selectedName(m.activePane())
|
||||||
|
_ = m.reloadPane(PaneLeft, activeSelection)
|
||||||
|
_ = m.reloadPane(PaneRight, activeSelection)
|
||||||
|
background := m.archiveJob.background
|
||||||
|
sourceCount := len(m.archiveJob.sourcePaths)
|
||||||
|
m.archiveJob = nil
|
||||||
|
m.activePane().ClearMarks()
|
||||||
|
|
||||||
|
cmd := m.loadPreviewCmd()
|
||||||
|
if m.modal.kind == modalArchiveProgress {
|
||||||
|
m.modal = modalState{}
|
||||||
|
}
|
||||||
|
if background {
|
||||||
|
doneBody := fmt.Sprintf("%d entr%s archived successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies"))
|
||||||
|
if sourceCount == 1 && len(msg.sourcePaths) == 1 {
|
||||||
|
doneBody = filepath.Base(msg.sourcePaths[0]) + " archived successfully."
|
||||||
|
}
|
||||||
|
m.modal = modalState{
|
||||||
|
kind: modalNotice,
|
||||||
|
title: "Archive complete",
|
||||||
|
body: doneBody,
|
||||||
|
note: "Press Esc to close",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
case copyProgressMsg:
|
case copyProgressMsg:
|
||||||
if m.copyJob == nil || msg.jobID != m.copyJob.id {
|
if m.copyJob == nil || msg.jobID != m.copyJob.id {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -637,6 +764,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
case key.Matches(msg, m.keys.Edit):
|
case key.Matches(msg, m.keys.Edit):
|
||||||
return m.handleEdit()
|
return m.handleEdit()
|
||||||
|
case key.Matches(msg, m.keys.Archive):
|
||||||
|
return m.handleArchive()
|
||||||
case key.Matches(msg, m.keys.Info):
|
case key.Matches(msg, m.keys.Info):
|
||||||
return m.toggleInfo()
|
return m.toggleInfo()
|
||||||
case key.Matches(msg, m.keys.SelectText):
|
case key.Matches(msg, m.keys.SelectText):
|
||||||
|
|
@ -847,6 +976,67 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, pending.cmd()
|
return m, pending.cmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case modalArchiveType:
|
||||||
|
switch {
|
||||||
|
case isModalCloseKey(msg, m.keys):
|
||||||
|
m.modal = modalState{}
|
||||||
|
m.status = "Cancelled"
|
||||||
|
return m, nil
|
||||||
|
case key.Matches(msg, m.keys.Confirm):
|
||||||
|
if m.modal.pending == nil {
|
||||||
|
m.modal = modalState{}
|
||||||
|
m.status = "Nothing to confirm"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
pending := *m.modal.pending
|
||||||
|
m.modal = modalState{}
|
||||||
|
if m.archiveJob != nil {
|
||||||
|
m.status = "Archive is already running"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.busy = true
|
||||||
|
return m, m.startArchiveJob(pending.sourcePaths, pending.targetDir, m.archiveFormat, pending.stats)
|
||||||
|
case msg.String() == "f":
|
||||||
|
switch m.archiveFormat {
|
||||||
|
case "zip":
|
||||||
|
m.archiveFormat = "tar"
|
||||||
|
case "tar":
|
||||||
|
m.archiveFormat = "tar.gz"
|
||||||
|
default:
|
||||||
|
m.archiveFormat = "zip"
|
||||||
|
}
|
||||||
|
m.modal.note = fmt.Sprintf(
|
||||||
|
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel",
|
||||||
|
m.archiveFormat,
|
||||||
|
)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case modalArchiveProgress:
|
||||||
|
if key.Matches(msg, m.keys.Background) {
|
||||||
|
if m.archiveJob == nil {
|
||||||
|
m.modal = modalState{}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.archiveJob.background = true
|
||||||
|
m.modal = modalState{}
|
||||||
|
m.status = "Archive continues in background"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if key.Matches(msg, m.keys.ProgressCancel) {
|
||||||
|
if m.archiveJob == nil {
|
||||||
|
m.modal = modalState{}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.archiveJob.cancel != nil {
|
||||||
|
m.archiveJob.cancel()
|
||||||
|
}
|
||||||
|
m.status = "Archiving cancelling..."
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case modalCopyProgress:
|
case modalCopyProgress:
|
||||||
if key.Matches(msg, m.keys.Background) {
|
if key.Matches(msg, m.keys.Background) {
|
||||||
if m.copyJob == nil {
|
if m.copyJob == nil {
|
||||||
|
|
@ -968,6 +1158,14 @@ func (m *Model) enterSelected() error {
|
||||||
m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor."
|
m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor."
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// When inside an archive mount and selecting "..", use archive-aware
|
||||||
|
// navigation (goParent) instead of blindly setting pane.Path to the
|
||||||
|
// parent directory (which would be /tmp for a temp-mounted archive).
|
||||||
|
if selected.IsParent {
|
||||||
|
if _, archiveMounted := pane.CurrentArchive(); archiveMounted {
|
||||||
|
return m.goParent()
|
||||||
|
}
|
||||||
|
}
|
||||||
currentName := selected.Name
|
currentName := selected.Name
|
||||||
pane.Path = selected.Path
|
pane.Path = selected.Path
|
||||||
if err := m.reloadPane(pane.ID, currentName); err != nil {
|
if err := m.reloadPane(pane.ID, currentName); err != nil {
|
||||||
|
|
@ -1009,15 +1207,28 @@ func (m *Model) goParent() error {
|
||||||
m.hover = hoverState{}
|
m.hover = hoverState{}
|
||||||
pane := m.activePane()
|
pane := m.activePane()
|
||||||
|
|
||||||
if mount, ok := pane.CurrentArchive(); ok && pane.Path == mount.RootPath {
|
if mount, ok := pane.CurrentArchive(); ok {
|
||||||
if _, popped := pane.PopArchive(); popped {
|
root := filepath.Clean(mount.RootPath)
|
||||||
_ = os.RemoveAll(mount.TempDir)
|
current := filepath.Clean(pane.Path)
|
||||||
|
if current == root {
|
||||||
|
// At archive root — pop archive and return to the directory containing it
|
||||||
|
if _, popped := pane.PopArchive(); popped {
|
||||||
|
_ = os.RemoveAll(mount.TempDir)
|
||||||
|
}
|
||||||
|
pane.Path = mount.ParentPath
|
||||||
|
if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
pane.Path = mount.ParentPath
|
// Inside archive subdirectory — go up one level within the archive
|
||||||
if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil {
|
parent := filepath.Dir(current)
|
||||||
|
pane.Path = parent
|
||||||
|
if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath))
|
m.status = fmt.Sprintf("Moved to %s", parent)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1132,6 +1343,23 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleArchive() (tea.Model, tea.Cmd) {
|
||||||
|
sources := m.operationSources()
|
||||||
|
if len(sources) == 0 {
|
||||||
|
m.status = "Nothing to archive"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.archiveJob != nil {
|
||||||
|
m.status = "Archive is already running"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.busy = true
|
||||||
|
m.status = "Calculating archive size"
|
||||||
|
return m, archivePlanCmd(sources, m.passivePane().Path)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
|
func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
|
||||||
if m.activePane().InArchive() {
|
if m.activePane().InArchive() {
|
||||||
m.status = "Archive mode is read-only; delete is disabled"
|
m.status = "Archive mode is read-only; delete is disabled"
|
||||||
|
|
@ -2339,6 +2567,9 @@ func renderModal(m Model, palette theme.Palette, width int) string {
|
||||||
if m.modal.kind == modalCopyProgress && m.copyJob != nil {
|
if m.modal.kind == modalCopyProgress && m.copyJob != nil {
|
||||||
return renderCopyProgressModal(*m.copyJob, palette, width)
|
return renderCopyProgressModal(*m.copyJob, palette, width)
|
||||||
}
|
}
|
||||||
|
if m.modal.kind == modalArchiveProgress && m.archiveJob != nil {
|
||||||
|
return renderArchiveProgressModal(*m.archiveJob, palette, width)
|
||||||
|
}
|
||||||
if m.modal.kind == modalHelp {
|
if m.modal.kind == modalHelp {
|
||||||
return renderHelpModal(m.modal, palette, width)
|
return renderHelpModal(m.modal, palette, width)
|
||||||
}
|
}
|
||||||
|
|
@ -2703,6 +2934,54 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
|
||||||
return box.Render(strings.Join(lines, "\n"))
|
return box.Render(strings.Join(lines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, width int) string {
|
||||||
|
outerWidth := max(width, 8)
|
||||||
|
contentWidth := max(outerWidth-6, 1)
|
||||||
|
titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
|
||||||
|
mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
|
||||||
|
spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Width(contentWidth).
|
||||||
|
Padding(1, 2).
|
||||||
|
Background(palette.Panel).
|
||||||
|
Foreground(palette.Text).
|
||||||
|
BorderStyle(lipgloss.DoubleBorder()).
|
||||||
|
BorderForeground(palette.BorderActive).
|
||||||
|
BorderBackground(palette.Panel)
|
||||||
|
|
||||||
|
progress := job.progress
|
||||||
|
ratio := 0.0
|
||||||
|
if progress.BytesTotal > 0 {
|
||||||
|
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
stage := progress.Stage
|
||||||
|
if stage == "" {
|
||||||
|
stage = "Archiving data"
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
titleStyle.Render("Archiving"),
|
||||||
|
spacer,
|
||||||
|
renderProgressBarLine(ratio, contentWidth, palette),
|
||||||
|
spacer,
|
||||||
|
renderProgressPercentLine(ratio, contentWidth, palette),
|
||||||
|
renderProgressStatLine("Stage:", stage, contentWidth, palette),
|
||||||
|
spacer,
|
||||||
|
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,
|
||||||
|
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
|
||||||
|
}
|
||||||
|
if job.background {
|
||||||
|
lines = append(lines, mutedStyle.Render("Archive continues in background"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return box.Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
func renderProgressBar(ratio float64, width int, palette theme.Palette) string {
|
func renderProgressBar(ratio float64, width int, palette theme.Palette) string {
|
||||||
if width < 10 {
|
if width < 10 {
|
||||||
width = 10
|
width = 10
|
||||||
|
|
@ -2994,6 +3273,8 @@ func (m *Model) enterArchive(selected vfs.Entry) error {
|
||||||
TempDir: tempDir,
|
TempDir: tempDir,
|
||||||
})
|
})
|
||||||
pane.Path = tempDir
|
pane.Path = tempDir
|
||||||
|
pane.Cursor = 0
|
||||||
|
pane.Offset = 0
|
||||||
if err := m.reloadPane(pane.ID, ""); err != nil {
|
if err := m.reloadPane(pane.ID, ""); err != nil {
|
||||||
_ = os.RemoveAll(tempDir)
|
_ = os.RemoveAll(tempDir)
|
||||||
_, _ = pane.PopArchive()
|
_, _ = pane.PopArchive()
|
||||||
|
|
@ -3032,12 +3313,40 @@ func deletePlanCmd(sourcePaths []string) tea.Cmd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func archivePlanCmd(sourcePaths []string, targetDir string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
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 archivePlanMsg{
|
||||||
|
sourcePaths: append([]string(nil), sourcePaths...),
|
||||||
|
targetDir: targetDir,
|
||||||
|
stats: stats,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitCopyProgressCmd(ch <-chan tea.Msg) tea.Cmd {
|
func waitCopyProgressCmd(ch <-chan tea.Msg) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return <-ch
|
return <-ch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitArchiveProgressCmd(ch <-chan tea.Msg) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return <-ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func dismissNoticeCmd(delay time.Duration) tea.Cmd {
|
func dismissNoticeCmd(delay time.Duration) tea.Cmd {
|
||||||
return tea.Tick(delay, func(time.Time) tea.Msg {
|
return tea.Tick(delay, func(time.Time) tea.Msg {
|
||||||
return dismissNoticeMsg{}
|
return dismissNoticeMsg{}
|
||||||
|
|
@ -3135,6 +3444,62 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format string, stats vfs.TransferStats) tea.Cmd {
|
||||||
|
m.nextArchiveJob++
|
||||||
|
jobID := m.nextArchiveJob
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
archiveName := vfs.ArchiveName(sourcePaths, format)
|
||||||
|
archivePath := filepath.Join(targetDir, archiveName)
|
||||||
|
|
||||||
|
m.archiveJob = &archiveJobState{
|
||||||
|
id: jobID,
|
||||||
|
sourcePaths: append([]string(nil), sourcePaths...),
|
||||||
|
targetPath: archivePath,
|
||||||
|
progress: vfs.CopyProgress{
|
||||||
|
FilesDone: 0,
|
||||||
|
FilesTotal: stats.FilesTotal,
|
||||||
|
BytesDone: 0,
|
||||||
|
BytesTotal: stats.BytesTotal,
|
||||||
|
CurrentPath: sourcePaths[0],
|
||||||
|
},
|
||||||
|
cancel: cancel,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
}
|
||||||
|
m.modal = modalState{kind: modalArchiveProgress}
|
||||||
|
m.status = "Archiving started"
|
||||||
|
|
||||||
|
return tea.Batch(
|
||||||
|
func() tea.Msg {
|
||||||
|
go func() {
|
||||||
|
emitProgress := func(p vfs.CopyProgress) {
|
||||||
|
m.archiveProgress <- archiveProgressMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
progress: p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := vfs.CreateArchive(ctx, sourcePaths, archivePath, emitProgress)
|
||||||
|
if err != nil {
|
||||||
|
m.archiveProgress <- archiveDoneMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
sourcePaths: append([]string(nil), sourcePaths...),
|
||||||
|
targetPath: archivePath,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.archiveProgress <- archiveDoneMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
sourcePaths: append([]string(nil), sourcePaths...),
|
||||||
|
targetPath: archivePath,
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
waitArchiveProgressCmd(m.archiveProgress),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
|
func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite)
|
targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite)
|
||||||
|
|
@ -3207,6 +3572,16 @@ func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatArchiveStatus(progress vfs.CopyProgress) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Archiving in background: %d/%d files, %s/%s",
|
||||||
|
progress.FilesDone,
|
||||||
|
progress.FilesTotal,
|
||||||
|
formatSize(progress.BytesDone, true),
|
||||||
|
formatSize(progress.BytesTotal, true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func transferSourceLabel(paths []string) string {
|
func transferSourceLabel(paths []string) string {
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
return "n/a"
|
return "n/a"
|
||||||
|
|
@ -3228,6 +3603,8 @@ func progressTitle(kind fileOpKind) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case opMove:
|
case opMove:
|
||||||
return "Moving"
|
return "Moving"
|
||||||
|
case opArchive:
|
||||||
|
return "Archiving"
|
||||||
default:
|
default:
|
||||||
return "Copying"
|
return "Copying"
|
||||||
}
|
}
|
||||||
|
|
@ -3241,6 +3618,8 @@ func operationDoneLabel(kind fileOpKind) string {
|
||||||
return "Copied"
|
return "Copied"
|
||||||
case opDelete:
|
case opDelete:
|
||||||
return "Deleted"
|
return "Deleted"
|
||||||
|
case opArchive:
|
||||||
|
return "Archived"
|
||||||
default:
|
default:
|
||||||
return "Done"
|
return "Done"
|
||||||
}
|
}
|
||||||
|
|
@ -3254,6 +3633,8 @@ func operationVerb(kind fileOpKind) string {
|
||||||
return "move"
|
return "move"
|
||||||
case opDelete:
|
case opDelete:
|
||||||
return "delete"
|
return "delete"
|
||||||
|
case opArchive:
|
||||||
|
return "archive"
|
||||||
default:
|
default:
|
||||||
return "operate on"
|
return "operate on"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ right_path = ''
|
||||||
|
|
||||||
[ui]
|
[ui]
|
||||||
app_title = 'vcom'
|
app_title = 'vcom'
|
||||||
theme = 'eldritch'
|
theme = 'vesper'
|
||||||
icon_mode = 'auto'
|
icon_mode = 'auto'
|
||||||
show_title_bar = true
|
show_title_bar = true
|
||||||
show_footer = true
|
show_footer = true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue