diff --git a/internal/fs/archive.go b/internal/fs/archive.go new file mode 100644 index 0000000..a52a22e --- /dev/null +++ b/internal/fs/archive.go @@ -0,0 +1,158 @@ +package vfs + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func ExtractArchiveToTemp(sourcePath string) (string, error) { + tempDir, err := os.MkdirTemp("", "vcom-archive-") + if err != nil { + return "", err + } + cleanupOnErr := func(extractErr error) (string, error) { + _ = os.RemoveAll(tempDir) + return "", extractErr + } + + sourceLower := strings.ToLower(sourcePath) + switch { + case strings.HasSuffix(sourceLower, ".zip"): + if err := extractZipArchive(sourcePath, tempDir); err != nil { + return cleanupOnErr(err) + } + case strings.HasSuffix(sourceLower, ".tar"): + if err := extractTarArchive(sourcePath, tempDir, false); err != nil { + return cleanupOnErr(err) + } + case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"): + if err := extractTarArchive(sourcePath, tempDir, true); err != nil { + return cleanupOnErr(err) + } + default: + return cleanupOnErr(fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath))) + } + + return tempDir, nil +} + +func extractZipArchive(sourcePath string, targetDir string) error { + reader, err := zip.OpenReader(sourcePath) + if err != nil { + return err + } + defer reader.Close() + + for _, file := range reader.File { + relPath, ok := safeArchivePath(file.Name) + if !ok { + continue + } + fullPath := filepath.Join(targetDir, relPath) + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(fullPath, 0o755); err != nil { + return err + } + continue + } + + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return err + } + src, err := file.Open() + if err != nil { + return err + } + if err := writeArchiveFile(fullPath, src, file.Mode()); err != nil { + src.Close() + return err + } + src.Close() + } + return nil +} + +func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error { + file, err := os.Open(sourcePath) + if err != nil { + return err + } + defer file.Close() + + var reader io.Reader = file + if gzipped { + gzipReader, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzipReader.Close() + reader = gzipReader + } + + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + relPath, ok := safeArchivePath(header.Name) + if !ok { + continue + } + fullPath := filepath.Join(targetDir, relPath) + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(fullPath, 0o755); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return err + } + if err := writeArchiveFile(fullPath, tarReader, os.FileMode(header.Mode)); err != nil { + return err + } + } + } + + return nil +} + +func writeArchiveFile(path string, source io.Reader, mode os.FileMode) error { + if mode == 0 { + mode = 0o644 + } + output, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + defer output.Close() + + _, err = io.Copy(output, source) + return err +} + +func safeArchivePath(name string) (string, bool) { + clean := filepath.Clean(name) + if clean == "." || clean == string(filepath.Separator) { + return "", false + } + if filepath.IsAbs(clean) { + return "", false + } + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", false + } + return clean, true +} diff --git a/internal/ui/model.go b/internal/ui/model.go index b8d792c..18ef872 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -437,6 +437,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Quit): + m.cleanupArchiveMounts() return m, tea.Quit case key.Matches(msg, m.keys.Help): m.openHelpModal() @@ -800,6 +801,14 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { return m, m.loadPreviewCmd() } + if isArchiveEntry(selected) { + if err := m.enterArchive(selected); err != nil { + m.status = err.Error() + return m, nil + } + return m, m.loadPreviewCmd() + } + if isEditableEntry(selected) { return m.handleEdit() } @@ -809,6 +818,19 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { 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) + } + 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 + } + parent := filepath.Dir(pane.Path) if parent == pane.Path { return nil @@ -878,6 +900,11 @@ func (m *Model) handleDirSize() (tea.Model, tea.Cmd) { } func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) { + if m.activePane().InArchive() && kind != opCopy { + m.status = "Archive mode is read-only; only copy is allowed" + return m, nil + } + sources := m.operationSources() if len(sources) == 0 { m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind)) @@ -916,6 +943,11 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) { } func (m *Model) handleDelete() (tea.Model, tea.Cmd) { + if m.activePane().InArchive() { + m.status = "Archive mode is read-only; delete is disabled" + return m, nil + } + sources := m.operationSources() if len(sources) == 0 { m.status = "Nothing to delete" @@ -1181,6 +1213,11 @@ func (m *Model) cycleSort() (tea.Model, tea.Cmd) { } func (m *Model) openMkdirModal() { + if m.activePane().InArchive() { + m.status = "Archive mode is read-only; create directory is disabled" + return + } + input := textinput.New() input.Placeholder = "new-directory" input.Focus() @@ -1197,6 +1234,11 @@ func (m *Model) openMkdirModal() { } func (m *Model) openRenameModal() { + if m.activePane().InArchive() { + m.status = "Archive mode is read-only; rename is disabled" + return + } + selected, ok := m.activePane().Selected() if !ok || selected.IsParent { m.status = "Select an entry to rename" @@ -2225,6 +2267,36 @@ func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwr } } +func (m *Model) enterArchive(selected vfs.Entry) error { + pane := m.activePane() + tempDir, err := vfs.ExtractArchiveToTemp(selected.Path) + if err != nil { + return err + } + pane.PushArchive(ArchiveMount{ + SourcePath: selected.Path, + ParentPath: pane.Path, + RootPath: tempDir, + TempDir: tempDir, + }) + pane.Path = tempDir + if err := m.reloadPane(pane.ID, ""); err != nil { + _ = os.RemoveAll(tempDir) + _, _ = pane.PopArchive() + return err + } + m.status = fmt.Sprintf("Opened archive %s", selected.DisplayName()) + return nil +} + +func (m *Model) cleanupArchiveMounts() { + for _, pane := range []*BrowserPane{&m.left, &m.right} { + for _, mount := range pane.ClearArchives() { + _ = os.RemoveAll(mount.TempDir) + } + } +} + func deletePlanCmd(sourcePaths []string) tea.Cmd { return func() tea.Msg { stats := vfs.TransferStats{} @@ -2630,6 +2702,10 @@ func isEditableEntry(entry vfs.Entry) bool { } } +func isArchiveEntry(entry vfs.Entry) bool { + return !entry.IsDir && !entry.IsParent && entry.Category() == "archive" +} + func (m *Model) hoverIndexFor(pane PaneID) int { if m.hover.ok && m.hover.pane == pane { return m.hover.index diff --git a/internal/ui/pane.go b/internal/ui/pane.go index aad579f..3ecf473 100644 --- a/internal/ui/pane.go +++ b/internal/ui/pane.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "path/filepath" "strings" "github.com/charmbracelet/lipgloss" @@ -25,6 +26,14 @@ type BrowserPane struct { Cursor int Offset int Marked map[string]struct{} + Archive []ArchiveMount +} + +type ArchiveMount struct { + SourcePath string + ParentPath string + RootPath string + TempDir string } func (p *BrowserPane) Selected() (vfs.Entry, bool) { @@ -171,6 +180,59 @@ func (p *BrowserPane) EnsureVisible(pageSize int) { } } +func (p *BrowserPane) InArchive() bool { + return len(p.Archive) > 0 +} + +func (p *BrowserPane) PushArchive(mount ArchiveMount) { + p.Archive = append(p.Archive, mount) +} + +func (p *BrowserPane) PopArchive() (ArchiveMount, bool) { + if len(p.Archive) == 0 { + return ArchiveMount{}, false + } + last := p.Archive[len(p.Archive)-1] + p.Archive = p.Archive[:len(p.Archive)-1] + return last, true +} + +func (p *BrowserPane) CurrentArchive() (ArchiveMount, bool) { + if len(p.Archive) == 0 { + return ArchiveMount{}, false + } + return p.Archive[len(p.Archive)-1], true +} + +func (p *BrowserPane) ClearArchives() []ArchiveMount { + if len(p.Archive) == 0 { + return nil + } + out := make([]ArchiveMount, len(p.Archive)) + copy(out, p.Archive) + p.Archive = nil + return out +} + +func (p *BrowserPane) DisplayPath() string { + if len(p.Archive) == 0 { + return p.Path + } + top := p.Archive[len(p.Archive)-1] + rel, err := filepath.Rel(top.RootPath, p.Path) + if err != nil { + return p.Path + } + rel = filepath.ToSlash(rel) + if rel == "." { + rel = "" + } + if rel == "" { + return top.SourcePath + "::" + } + return top.SourcePath + "::/" + rel +} + func renderPane( pane BrowserPane, cfg config.Config, @@ -229,7 +291,7 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette return lipgloss.NewStyle(). Width(width). Background(headerBg). - Render(pathStyle.Render(truncateMiddle(compactPath(pane.Path, cfg.UI.PathDisplay), pathWidth))) + Render(pathStyle.Render(truncateMiddle(compactPath(pane.DisplayPath(), cfg.UI.PathDisplay), pathWidth))) } func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool) string { diff --git a/vcom.toml b/vcom.toml index 21812bb..8dc51c2 100644 --- a/vcom.toml +++ b/vcom.toml @@ -5,6 +5,7 @@ [ui] app_title = "vcom" theme = "catppuccin-mocha" +icon_mode = "auto" # auto | nerd | ascii show_title_bar = true show_footer = true border = "rounded"