feat: add extraction progress dialog (F11/e)
- ExtractArchiveToDir now accepts progress callback for file-by-file reporting
- Added countArchiveEntries/countZipEntries/countTarEntries helpers
- Added startExtractJob method following startArchiveJob pattern
- archiveJobState now has kind field ('archive'/'extract')
- archiveDoneMsg handler reloads only passive pane for extraction
- renderArchiveProgressModal shows file-based progress for extraction (no size/speed)
- Removed old synchronous extractArchiveCmd() and opExtractArchive case in opMsg
This commit is contained in:
parent
3229d9b263
commit
42c51f0ef5
2 changed files with 247 additions and 51 deletions
|
|
@ -14,6 +14,9 @@ import (
|
|||
)
|
||||
|
||||
func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
||||
// Count total files for progress reporting
|
||||
totalFiles, totalBytes := countArchiveEntries(sourcePath)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "vcom-archive-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -26,15 +29,15 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
|||
sourceLower := strings.ToLower(sourcePath)
|
||||
switch {
|
||||
case strings.HasSuffix(sourceLower, ".zip"):
|
||||
if err := extractZipArchive(sourcePath, tempDir); err != nil {
|
||||
if err := extractZipArchive(sourcePath, tempDir, nil, totalFiles, totalBytes); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
case strings.HasSuffix(sourceLower, ".tar"):
|
||||
if err := extractTarArchive(sourcePath, tempDir, false); err != nil {
|
||||
if err := extractTarArchive(sourcePath, tempDir, false, nil, totalFiles, totalBytes); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
|
||||
if err := extractTarArchive(sourcePath, tempDir, true); err != nil {
|
||||
if err := extractTarArchive(sourcePath, tempDir, true, nil, totalFiles, totalBytes); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
default:
|
||||
|
|
@ -46,28 +49,96 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
|||
|
||||
// ExtractArchiveToDir extracts an archive to the specified target directory.
|
||||
// Unlike ExtractArchiveToTemp, it extracts directly to targetDir without
|
||||
// creating a temporary directory.
|
||||
func ExtractArchiveToDir(sourcePath, targetDir string) error {
|
||||
// creating a temporary directory. The progress callback is called after each
|
||||
// file is extracted; it may be nil.
|
||||
func ExtractArchiveToDir(sourcePath, targetDir string, progress func(CopyProgress)) error {
|
||||
totalFiles, totalBytes := countArchiveEntries(sourcePath)
|
||||
sourceLower := strings.ToLower(sourcePath)
|
||||
switch {
|
||||
case strings.HasSuffix(sourceLower, ".zip"):
|
||||
return extractZipArchive(sourcePath, targetDir)
|
||||
return extractZipArchive(sourcePath, targetDir, progress, totalFiles, totalBytes)
|
||||
case strings.HasSuffix(sourceLower, ".tar"):
|
||||
return extractTarArchive(sourcePath, targetDir, false)
|
||||
return extractTarArchive(sourcePath, targetDir, false, progress, totalFiles, totalBytes)
|
||||
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
|
||||
return extractTarArchive(sourcePath, targetDir, true)
|
||||
return extractTarArchive(sourcePath, targetDir, true, progress, totalFiles, totalBytes)
|
||||
default:
|
||||
return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath))
|
||||
}
|
||||
}
|
||||
|
||||
func extractZipArchive(sourcePath string, targetDir string) error {
|
||||
// countArchiveEntries counts the total number of files and total uncompressed
|
||||
// bytes in an archive without extracting. Used for progress reporting.
|
||||
func countArchiveEntries(sourcePath string) (int64, int64) {
|
||||
sourceLower := strings.ToLower(sourcePath)
|
||||
switch {
|
||||
case strings.HasSuffix(sourceLower, ".zip"):
|
||||
return countZipEntries(sourcePath)
|
||||
case strings.HasSuffix(sourceLower, ".tar"), strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
|
||||
return countTarEntries(sourcePath)
|
||||
default:
|
||||
return 0, 0
|
||||
}
|
||||
}
|
||||
|
||||
func countZipEntries(sourcePath string) (int64, int64) {
|
||||
r, err := zip.OpenReader(sourcePath)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer r.Close()
|
||||
var files, bytes int64
|
||||
for _, f := range r.File {
|
||||
if !f.FileInfo().IsDir() {
|
||||
files++
|
||||
bytes += int64(f.UncompressedSize64)
|
||||
}
|
||||
}
|
||||
return files, bytes
|
||||
}
|
||||
|
||||
func countTarEntries(sourcePath string) (int64, int64) {
|
||||
f, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var reader io.Reader = f
|
||||
if strings.HasSuffix(strings.ToLower(sourcePath), ".tar.gz") || strings.HasSuffix(strings.ToLower(sourcePath), ".tgz") {
|
||||
gr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer gr.Close()
|
||||
reader = gr
|
||||
}
|
||||
|
||||
tarReader := tar.NewReader(reader)
|
||||
var files, bytes int64
|
||||
for {
|
||||
hdr, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeReg || hdr.Typeflag == tar.TypeRegA {
|
||||
files++
|
||||
bytes += hdr.Size
|
||||
}
|
||||
}
|
||||
return files, bytes
|
||||
}
|
||||
|
||||
func extractZipArchive(sourcePath string, targetDir string, progress func(CopyProgress), totalFiles, totalBytes int64) error {
|
||||
reader, err := zip.OpenReader(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var filesDone int64
|
||||
for _, file := range reader.File {
|
||||
relPath, ok := safeArchivePath(file.Name)
|
||||
if !ok {
|
||||
|
|
@ -94,11 +165,22 @@ func extractZipArchive(sourcePath string, targetDir string) error {
|
|||
return err
|
||||
}
|
||||
src.Close()
|
||||
|
||||
filesDone++
|
||||
if progress != nil {
|
||||
progress(CopyProgress{
|
||||
FilesDone: int(filesDone),
|
||||
FilesTotal: int(totalFiles),
|
||||
BytesDone: 0,
|
||||
BytesTotal: totalBytes,
|
||||
Stage: "Extracting data",
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error {
|
||||
func extractTarArchive(sourcePath string, targetDir string, gzipped bool, progress func(CopyProgress), totalFiles, totalBytes int64) error {
|
||||
file, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -116,6 +198,7 @@ func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error
|
|||
}
|
||||
|
||||
tarReader := tar.NewReader(reader)
|
||||
var filesDone int64
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
|
|
@ -142,6 +225,16 @@ func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error
|
|||
if err := writeArchiveFile(fullPath, tarReader, os.FileMode(header.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
filesDone++
|
||||
if progress != nil {
|
||||
progress(CopyProgress{
|
||||
FilesDone: int(filesDone),
|
||||
FilesTotal: int(totalFiles),
|
||||
BytesDone: 0,
|
||||
BytesTotal: totalBytes,
|
||||
Stage: "Extracting data",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package ui
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
|
@ -178,6 +179,7 @@ type copyJobState struct {
|
|||
|
||||
type archiveJobState struct {
|
||||
id int
|
||||
kind string // "archive" for creation, "extract" for extraction
|
||||
sourcePaths []string
|
||||
targetPath string
|
||||
progress vfs.CopyProgress
|
||||
|
|
@ -513,21 +515,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case opExecute:
|
||||
m.status = "Executable closed"
|
||||
return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
|
||||
case opExtractArchive:
|
||||
if msg.err != nil {
|
||||
log.Printf("[ERROR] opMsg: ExtractArchive failed — err=%v", msg.err)
|
||||
m.status = msg.err.Error()
|
||||
return m, nil
|
||||
}
|
||||
log.Printf("[ACTION] opMsg: ExtractArchive done — source=%s target=%s", msg.sourcePath, msg.targetPath)
|
||||
m.status = fmt.Sprintf("Extracted to %s", msg.targetPath)
|
||||
// Reload the target (passive) pane so extracted files appear
|
||||
targetID := PaneRight
|
||||
if m.active == PaneRight {
|
||||
targetID = PaneLeft
|
||||
}
|
||||
_ = m.reloadPane(targetID, "")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Reload panes — use remote reload for remote mounts
|
||||
|
|
@ -622,7 +609,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
note := "confirm-actions"
|
||||
if msg.srcClient == nil {
|
||||
note = fmt.Sprintf("Mode: %s (d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind)
|
||||
note = fmt.Sprintf("Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind)
|
||||
}
|
||||
m.openConfirmModal(
|
||||
title,
|
||||
|
|
@ -691,15 +678,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
m.busy = false
|
||||
if msg.err != nil {
|
||||
log.Printf("[ERROR] archiveDoneMsg: job=%d err=%v", msg.jobID, msg.err)
|
||||
log.Printf("[ERROR] archiveDoneMsg: job=%d kind=%s err=%v", msg.jobID, m.archiveJob.kind, msg.err)
|
||||
activeSelection := selectedName(m.activePane())
|
||||
_ = m.reloadPane(PaneLeft, activeSelection)
|
||||
_ = m.reloadPane(PaneRight, activeSelection)
|
||||
if msg.err == context.Canceled {
|
||||
if m.archiveJob.kind == "extract" {
|
||||
m.status = "Extraction cancelled"
|
||||
} else {
|
||||
m.status = "Archiving cancelled"
|
||||
}
|
||||
} else {
|
||||
if m.archiveJob.kind == "extract" {
|
||||
m.status = fmt.Sprintf("Extraction failed: %v", msg.err)
|
||||
} else {
|
||||
m.status = fmt.Sprintf("Archiving failed: %v", msg.err)
|
||||
}
|
||||
}
|
||||
m.archiveJob = nil
|
||||
if m.modal.kind == modalArchiveProgress {
|
||||
m.modal = modalState{}
|
||||
|
|
@ -707,6 +702,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, m.loadPreviewCmd()
|
||||
}
|
||||
|
||||
// Extraction completion — reload only the passive pane
|
||||
if m.archiveJob.kind == "extract" {
|
||||
log.Printf("[DONE] extractDoneMsg: job=%d targetDir=%s source=%s", msg.jobID, msg.targetPath, msg.sourcePaths[0])
|
||||
m.status = fmt.Sprintf("Extracted to %s", msg.targetPath)
|
||||
targetID := PaneRight
|
||||
if m.active == PaneRight {
|
||||
targetID = PaneLeft
|
||||
}
|
||||
background := m.archiveJob.background
|
||||
m.archiveJob = nil
|
||||
cmd := m.loadPreviewCmd()
|
||||
_ = m.reloadPane(targetID, "")
|
||||
if m.modal.kind == modalArchiveProgress {
|
||||
m.modal = modalState{}
|
||||
}
|
||||
if background {
|
||||
m.modal = modalState{
|
||||
kind: modalNotice,
|
||||
title: "Extraction complete",
|
||||
body: "Archive extracted successfully.",
|
||||
note: "Press Esc to close",
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Archive creation completion
|
||||
log.Printf("[DONE] archiveDoneMsg: job=%d targetPath=%s sources=%d", msg.jobID, msg.targetPath, len(msg.sourcePaths))
|
||||
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())
|
||||
|
|
@ -1418,15 +1440,18 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
|
||||
// Extract archive
|
||||
if pending.kind == opExtractArchive {
|
||||
m.busy = true
|
||||
if m.archiveJob != nil {
|
||||
m.status = "Extraction is already running"
|
||||
return m, nil
|
||||
}
|
||||
log.Printf("[MODAL] Confirm — extract archive source=%s target=%s", pending.sourcePaths[0], pending.targetDir)
|
||||
return m, extractArchiveCmd(pending.sourcePaths[0], pending.targetDir)
|
||||
return m, m.startExtractJob(pending.sourcePaths[0], pending.targetDir)
|
||||
}
|
||||
|
||||
m.busy = true
|
||||
log.Printf("[MODAL] Confirm — %s sources=%d", operationVerb(pending.kind), len(pending.sourcePaths))
|
||||
return m, pending.cmd()
|
||||
case msg.String() == "d":
|
||||
case msg.String() == "d" || msg.String() == "D":
|
||||
if m.modal.pending == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -1444,7 +1469,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
m.modal.title = "Move selected entr" + pluralSuffix(len(sources), "y", "ies") + " to trash?"
|
||||
}
|
||||
m.modal.note = fmt.Sprintf(
|
||||
"Mode: %s (d to change)\nEnter / y to confirm, Esc / n to cancel",
|
||||
"Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel",
|
||||
m.deleteKind,
|
||||
)
|
||||
return m, nil
|
||||
|
|
@ -1473,7 +1498,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
m.busy = true
|
||||
log.Printf("[MODAL] Archive confirmed — sources=%d targetDir=%s format=%s", len(pending.sourcePaths), pending.targetDir, m.archiveFormat)
|
||||
return m, m.startArchiveJob(pending.sourcePaths, pending.targetDir, m.archiveFormat, pending.stats)
|
||||
case msg.String() == "f":
|
||||
case msg.String() == "f" || msg.String() == "F":
|
||||
switch m.archiveFormat {
|
||||
case "zip":
|
||||
m.archiveFormat = "tar"
|
||||
|
|
@ -1483,7 +1508,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
m.archiveFormat = "zip"
|
||||
}
|
||||
m.modal.note = fmt.Sprintf(
|
||||
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel",
|
||||
"Format: %s (F/f to change)\nEnter / y to confirm, Esc / n to cancel",
|
||||
m.archiveFormat,
|
||||
)
|
||||
return m, nil
|
||||
|
|
@ -2263,7 +2288,7 @@ func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
|
|||
// No plan needed — show a simple confirm dialog with mode toggle
|
||||
title := "Permanently delete selected entr" + pluralSuffix(len(sources), "y", "ies") + "?"
|
||||
body := fmt.Sprintf("Items: %d", len(sources))
|
||||
note := fmt.Sprintf("Mode: %s (d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind)
|
||||
note := fmt.Sprintf("Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind)
|
||||
pending := pendingOperation{
|
||||
kind: opPermanentDelete,
|
||||
sourcePaths: append([]string(nil), sources...),
|
||||
|
|
@ -3956,17 +3981,33 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
|
|||
|
||||
progress := job.progress
|
||||
ratio := 0.0
|
||||
if job.kind == "extract" {
|
||||
// Extraction: progress by file count (no byte tracking during extraction)
|
||||
if progress.FilesTotal > 0 {
|
||||
ratio = float64(progress.FilesDone) / float64(progress.FilesTotal)
|
||||
}
|
||||
} else {
|
||||
if progress.BytesTotal > 0 {
|
||||
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
|
||||
}
|
||||
}
|
||||
|
||||
stage := progress.Stage
|
||||
if stage == "" {
|
||||
if job.kind == "extract" {
|
||||
stage = "Extracting data"
|
||||
} else {
|
||||
stage = "Archiving data"
|
||||
}
|
||||
}
|
||||
|
||||
title := "Archiving"
|
||||
if job.kind == "extract" {
|
||||
title = "Extracting"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
titleStyle.Render("Archiving"),
|
||||
titleStyle.Render(title),
|
||||
spacer,
|
||||
renderProgressBarLine(ratio, contentWidth, palette),
|
||||
spacer,
|
||||
|
|
@ -3974,14 +4015,27 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
|
|||
renderProgressStatLine("Stage:", stage, contentWidth, palette),
|
||||
spacer,
|
||||
renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette),
|
||||
}
|
||||
|
||||
// Only show size and speed for archive creation (not tracked during extraction)
|
||||
if job.kind != "extract" {
|
||||
lines = append(lines,
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
spacer,
|
||||
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
|
||||
}
|
||||
)
|
||||
if job.background {
|
||||
if job.kind == "extract" {
|
||||
lines = append(lines, mutedStyle.Render("Extraction continues in background"))
|
||||
} else {
|
||||
lines = append(lines, mutedStyle.Render("Archive continues in background"))
|
||||
}
|
||||
}
|
||||
|
||||
return box.Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
|
@ -4550,6 +4604,7 @@ func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format s
|
|||
|
||||
m.archiveJob = &archiveJobState{
|
||||
id: jobID,
|
||||
kind: "archive",
|
||||
sourcePaths: append([]string(nil), sourcePaths...),
|
||||
targetPath: archivePath,
|
||||
progress: vfs.CopyProgress{
|
||||
|
|
@ -4596,6 +4651,61 @@ func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format s
|
|||
)
|
||||
}
|
||||
|
||||
func (m *Model) startExtractJob(sourcePath, targetDir string) tea.Cmd {
|
||||
m.nextArchiveJob++
|
||||
jobID := m.nextArchiveJob
|
||||
|
||||
m.archiveJob = &archiveJobState{
|
||||
id: jobID,
|
||||
kind: "extract",
|
||||
sourcePaths: []string{sourcePath},
|
||||
targetPath: targetDir,
|
||||
progress: vfs.CopyProgress{
|
||||
FilesDone: 0,
|
||||
FilesTotal: 0,
|
||||
BytesDone: 0,
|
||||
BytesTotal: 0,
|
||||
CurrentPath: sourcePath,
|
||||
},
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
m.modal = modalState{kind: modalArchiveProgress}
|
||||
m.status = "Extracting started"
|
||||
|
||||
return tea.Batch(
|
||||
func() tea.Msg {
|
||||
go func() {
|
||||
emitProgress := func(p vfs.CopyProgress) {
|
||||
m.archiveProgress <- archiveProgressMsg{
|
||||
jobID: jobID,
|
||||
progress: p,
|
||||
}
|
||||
}
|
||||
err := vfs.ExtractArchiveToDir(sourcePath, targetDir, emitProgress)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
err = context.Canceled
|
||||
}
|
||||
m.archiveProgress <- archiveDoneMsg{
|
||||
jobID: jobID,
|
||||
sourcePaths: []string{sourcePath},
|
||||
targetPath: targetDir,
|
||||
err: err,
|
||||
}
|
||||
return
|
||||
}
|
||||
m.archiveProgress <- archiveDoneMsg{
|
||||
jobID: jobID,
|
||||
sourcePaths: []string{sourcePath},
|
||||
targetPath: targetDir,
|
||||
}
|
||||
}()
|
||||
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)
|
||||
|
|
@ -4669,13 +4779,6 @@ func deletePlanPermanentCmd(sourcePaths []string) tea.Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
func extractArchiveCmd(sourcePath, targetDir string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := vfs.ExtractArchiveToDir(sourcePath, targetDir)
|
||||
return opMsg{kind: opExtractArchive, sourcePath: sourcePath, targetPath: targetDir, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) mkdirCmd(parent, name string) tea.Cmd {
|
||||
// Remote mkdir via SFTP
|
||||
if mount, ok := m.activePane().CurrentRemote(); ok {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue