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
}