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/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
||||
|
|
@ -156,3 +158,374 @@ func safeArchivePath(name string) (string, bool) {
|
|||
}
|
||||
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
|
||||
Rename key.Binding
|
||||
Info key.Binding
|
||||
Archive key.Binding
|
||||
SelectText key.Binding
|
||||
ToggleHidden key.Binding
|
||||
CycleTheme key.Binding
|
||||
|
|
@ -43,7 +44,8 @@ func DefaultKeyMap() KeyMap {
|
|||
View: key.NewBinding(key.WithKeys("f3"), key.WithHelp("F3", "view")),
|
||||
Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")),
|
||||
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")),
|
||||
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
|
||||
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
|
||||
|
|
@ -73,13 +75,13 @@ func DefaultKeyMap() KeyMap {
|
|||
}
|
||||
|
||||
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 {
|
||||
return [][]key.Binding{
|
||||
{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},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ const (
|
|||
modalCopyProgress
|
||||
modalNotice
|
||||
modalHelp
|
||||
modalArchiveType
|
||||
modalArchiveProgress
|
||||
)
|
||||
|
||||
type fileOpKind int
|
||||
|
|
@ -45,6 +47,7 @@ const (
|
|||
opRename
|
||||
opEdit
|
||||
opView
|
||||
opArchive
|
||||
)
|
||||
|
||||
type pendingOperation struct {
|
||||
|
|
@ -104,6 +107,25 @@ type deletePlanMsg struct {
|
|||
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 {
|
||||
jobID int
|
||||
kind fileOpKind
|
||||
|
|
@ -131,6 +153,16 @@ type copyJobState struct {
|
|||
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 {
|
||||
pane PaneID
|
||||
index int
|
||||
|
|
@ -184,6 +216,11 @@ type Model struct {
|
|||
nextCopyJob int
|
||||
copyProgress chan tea.Msg
|
||||
copyPath string
|
||||
|
||||
archiveJob *archiveJobState
|
||||
nextArchiveJob int
|
||||
archiveProgress chan tea.Msg
|
||||
archiveFormat string
|
||||
}
|
||||
|
||||
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||
|
|
@ -207,16 +244,17 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
|
|||
}
|
||||
|
||||
model := Model{
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
palette: palette,
|
||||
keys: DefaultKeyMap(),
|
||||
overlay: newImageOverlayManager(),
|
||||
left: BrowserPane{ID: PaneLeft, Path: leftPath},
|
||||
right: BrowserPane{ID: PaneRight, Path: rightPath},
|
||||
active: PaneLeft,
|
||||
status: "Ready",
|
||||
copyProgress: make(chan tea.Msg, 256),
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
palette: palette,
|
||||
keys: DefaultKeyMap(),
|
||||
overlay: newImageOverlayManager(),
|
||||
left: BrowserPane{ID: PaneLeft, Path: leftPath},
|
||||
right: BrowserPane{ID: PaneRight, Path: rightPath},
|
||||
active: PaneLeft,
|
||||
status: "Ready",
|
||||
copyProgress: make(chan tea.Msg, 256),
|
||||
archiveProgress: make(chan tea.Msg, 256),
|
||||
}
|
||||
model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode)
|
||||
if model.status == "" {
|
||||
|
|
@ -363,6 +401,95 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
)
|
||||
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:
|
||||
if m.copyJob == nil || msg.jobID != m.copyJob.id {
|
||||
return m, nil
|
||||
|
|
@ -637,6 +764,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
case key.Matches(msg, m.keys.Edit):
|
||||
return m.handleEdit()
|
||||
case key.Matches(msg, m.keys.Archive):
|
||||
return m.handleArchive()
|
||||
case key.Matches(msg, m.keys.Info):
|
||||
return m.toggleInfo()
|
||||
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()
|
||||
}
|
||||
|
||||
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:
|
||||
if key.Matches(msg, m.keys.Background) {
|
||||
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."
|
||||
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
|
||||
pane.Path = selected.Path
|
||||
if err := m.reloadPane(pane.ID, currentName); err != nil {
|
||||
|
|
@ -1009,15 +1207,28 @@ func (m *Model) goParent() error {
|
|||
m.hover = hoverState{}
|
||||
pane := m.activePane()
|
||||
|
||||
if mount, ok := pane.CurrentArchive(); ok && pane.Path == mount.RootPath {
|
||||
if _, popped := pane.PopArchive(); popped {
|
||||
_ = os.RemoveAll(mount.TempDir)
|
||||
if mount, ok := pane.CurrentArchive(); ok {
|
||||
root := filepath.Clean(mount.RootPath)
|
||||
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
|
||||
if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil {
|
||||
// Inside archive subdirectory — go up one level within the archive
|
||||
parent := filepath.Dir(current)
|
||||
pane.Path = parent
|
||||
if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath))
|
||||
m.status = fmt.Sprintf("Moved to %s", parent)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1132,6 +1343,23 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
|
|||
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) {
|
||||
if m.activePane().InArchive() {
|
||||
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 {
|
||||
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 {
|
||||
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"))
|
||||
}
|
||||
|
||||
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 {
|
||||
if width < 10 {
|
||||
width = 10
|
||||
|
|
@ -2994,6 +3273,8 @@ func (m *Model) enterArchive(selected vfs.Entry) error {
|
|||
TempDir: tempDir,
|
||||
})
|
||||
pane.Path = tempDir
|
||||
pane.Cursor = 0
|
||||
pane.Offset = 0
|
||||
if err := m.reloadPane(pane.ID, ""); err != nil {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
_, _ = 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 {
|
||||
return func() tea.Msg {
|
||||
return <-ch
|
||||
}
|
||||
}
|
||||
|
||||
func waitArchiveProgressCmd(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{}
|
||||
|
|
@ -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 {
|
||||
return func() tea.Msg {
|
||||
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 {
|
||||
if len(paths) == 0 {
|
||||
return "n/a"
|
||||
|
|
@ -3228,6 +3603,8 @@ func progressTitle(kind fileOpKind) string {
|
|||
switch kind {
|
||||
case opMove:
|
||||
return "Moving"
|
||||
case opArchive:
|
||||
return "Archiving"
|
||||
default:
|
||||
return "Copying"
|
||||
}
|
||||
|
|
@ -3241,6 +3618,8 @@ func operationDoneLabel(kind fileOpKind) string {
|
|||
return "Copied"
|
||||
case opDelete:
|
||||
return "Deleted"
|
||||
case opArchive:
|
||||
return "Archived"
|
||||
default:
|
||||
return "Done"
|
||||
}
|
||||
|
|
@ -3254,6 +3633,8 @@ func operationVerb(kind fileOpKind) string {
|
|||
return "move"
|
||||
case opDelete:
|
||||
return "delete"
|
||||
case opArchive:
|
||||
return "archive"
|
||||
default:
|
||||
return "operate on"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ right_path = ''
|
|||
|
||||
[ui]
|
||||
app_title = 'vcom'
|
||||
theme = 'eldritch'
|
||||
theme = 'vesper'
|
||||
icon_mode = 'auto'
|
||||
show_title_bar = true
|
||||
show_footer = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue