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:
vrubelroman 2026-04-27 15:30:39 +03:00
parent 3e34944f99
commit 33974cdcb7
4 changed files with 776 additions and 20 deletions

View file

@ -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 := &copyProgressState{
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 := &copyProgressState{
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
}

View file

@ -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},
}
}

View file

@ -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"
}

View file

@ -4,7 +4,7 @@ right_path = ''
[ui]
app_title = 'vcom'
theme = 'eldritch'
theme = 'vesper'
icon_mode = 'auto'
show_title_bar = true
show_footer = true