Add archive-as-folder navigation and extraction copy flow
This commit is contained in:
parent
780150500d
commit
6a518896b8
4 changed files with 298 additions and 1 deletions
158
internal/fs/archive.go
Normal file
158
internal/fs/archive.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -437,6 +437,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Quit):
|
case key.Matches(msg, m.keys.Quit):
|
||||||
|
m.cleanupArchiveMounts()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case key.Matches(msg, m.keys.Help):
|
case key.Matches(msg, m.keys.Help):
|
||||||
m.openHelpModal()
|
m.openHelpModal()
|
||||||
|
|
@ -800,6 +801,14 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
|
||||||
return m, m.loadPreviewCmd()
|
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) {
|
if isEditableEntry(selected) {
|
||||||
return m.handleEdit()
|
return m.handleEdit()
|
||||||
}
|
}
|
||||||
|
|
@ -809,6 +818,19 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
|
||||||
func (m *Model) goParent() error {
|
func (m *Model) goParent() error {
|
||||||
m.hover = hoverState{}
|
m.hover = hoverState{}
|
||||||
pane := m.activePane()
|
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)
|
parent := filepath.Dir(pane.Path)
|
||||||
if parent == pane.Path {
|
if parent == pane.Path {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -878,6 +900,11 @@ func (m *Model) handleDirSize() (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleTransfer(kind fileOpKind) (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()
|
sources := m.operationSources()
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind))
|
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) {
|
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()
|
sources := m.operationSources()
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
m.status = "Nothing to delete"
|
m.status = "Nothing to delete"
|
||||||
|
|
@ -1181,6 +1213,11 @@ func (m *Model) cycleSort() (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) openMkdirModal() {
|
func (m *Model) openMkdirModal() {
|
||||||
|
if m.activePane().InArchive() {
|
||||||
|
m.status = "Archive mode is read-only; create directory is disabled"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
input := textinput.New()
|
input := textinput.New()
|
||||||
input.Placeholder = "new-directory"
|
input.Placeholder = "new-directory"
|
||||||
input.Focus()
|
input.Focus()
|
||||||
|
|
@ -1197,6 +1234,11 @@ func (m *Model) openMkdirModal() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) openRenameModal() {
|
func (m *Model) openRenameModal() {
|
||||||
|
if m.activePane().InArchive() {
|
||||||
|
m.status = "Archive mode is read-only; rename is disabled"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
selected, ok := m.activePane().Selected()
|
selected, ok := m.activePane().Selected()
|
||||||
if !ok || selected.IsParent {
|
if !ok || selected.IsParent {
|
||||||
m.status = "Select an entry to rename"
|
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 {
|
func deletePlanCmd(sourcePaths []string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
stats := vfs.TransferStats{}
|
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 {
|
func (m *Model) hoverIndexFor(pane PaneID) int {
|
||||||
if m.hover.ok && m.hover.pane == pane {
|
if m.hover.ok && m.hover.pane == pane {
|
||||||
return m.hover.index
|
return m.hover.index
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
@ -25,6 +26,14 @@ type BrowserPane struct {
|
||||||
Cursor int
|
Cursor int
|
||||||
Offset int
|
Offset int
|
||||||
Marked map[string]struct{}
|
Marked map[string]struct{}
|
||||||
|
Archive []ArchiveMount
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveMount struct {
|
||||||
|
SourcePath string
|
||||||
|
ParentPath string
|
||||||
|
RootPath string
|
||||||
|
TempDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BrowserPane) Selected() (vfs.Entry, bool) {
|
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(
|
func renderPane(
|
||||||
pane BrowserPane,
|
pane BrowserPane,
|
||||||
cfg config.Config,
|
cfg config.Config,
|
||||||
|
|
@ -229,7 +291,7 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
Background(headerBg).
|
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 {
|
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool) string {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
[ui]
|
[ui]
|
||||||
app_title = "vcom"
|
app_title = "vcom"
|
||||||
theme = "catppuccin-mocha"
|
theme = "catppuccin-mocha"
|
||||||
|
icon_mode = "auto" # auto | nerd | ascii
|
||||||
show_title_bar = true
|
show_title_bar = true
|
||||||
show_footer = true
|
show_footer = true
|
||||||
border = "rounded"
|
border = "rounded"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue