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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue