fix: use Recommends instead of Depends for ueberzugpp in deb, add Ubuntu install guide

This commit is contained in:
vrubelroman 2026-05-13 13:47:38 +03:00
parent 915b695e88
commit 1577ee5525
1302 changed files with 402483 additions and 2 deletions

View file

@ -0,0 +1,656 @@
package vfs
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
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
}
cleanupOnErr := func(extractErr error) (string, error) {
_ = os.RemoveAll(tempDir)
return "", extractErr
}
// Use background context for temp extraction (no cancellation needed)
ctx := context.Background()
sourceLower := strings.ToLower(sourcePath)
switch {
case strings.HasSuffix(sourceLower, ".zip"):
if err := extractZipArchive(ctx, sourcePath, tempDir, nil, totalFiles, totalBytes); err != nil {
return cleanupOnErr(err)
}
case strings.HasSuffix(sourceLower, ".tar"):
if err := extractTarArchive(ctx, sourcePath, tempDir, false, nil, totalFiles, totalBytes); err != nil {
return cleanupOnErr(err)
}
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
if err := extractTarArchive(ctx, sourcePath, tempDir, true, nil, totalFiles, totalBytes); err != nil {
return cleanupOnErr(err)
}
default:
return cleanupOnErr(fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath)))
}
return tempDir, nil
}
// ExtractArchiveToDir extracts an archive to the specified target directory.
// Unlike ExtractArchiveToTemp, it extracts directly to targetDir without
// creating a temporary directory. The progress callback is called after each
// file is extracted; it may be nil. Cancellation is supported via ctx.
func ExtractArchiveToDir(ctx context.Context, sourcePath, targetDir string, progress func(CopyProgress)) error {
totalFiles, totalBytes := countArchiveEntries(sourcePath)
sourceLower := strings.ToLower(sourcePath)
switch {
case strings.HasSuffix(sourceLower, ".zip"):
return extractZipArchive(ctx, sourcePath, targetDir, progress, totalFiles, totalBytes)
case strings.HasSuffix(sourceLower, ".tar"):
return extractTarArchive(ctx, sourcePath, targetDir, false, progress, totalFiles, totalBytes)
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
return extractTarArchive(ctx, sourcePath, targetDir, true, progress, totalFiles, totalBytes)
default:
return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath))
}
}
// 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(ctx context.Context, 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 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
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()
filesDone++
if progress != nil {
progress(CopyProgress{
FilesDone: int(filesDone),
FilesTotal: int(totalFiles),
BytesDone: 0,
BytesTotal: totalBytes,
Stage: "Extracting data",
})
}
}
return nil
}
func extractTarArchive(ctx context.Context, sourcePath string, targetDir string, gzipped bool, progress func(CopyProgress), totalFiles, totalBytes int64) 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)
var filesDone int64
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
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
}
filesDone++
if progress != nil {
progress(CopyProgress{
FilesDone: int(filesDone),
FilesTotal: int(totalFiles),
BytesDone: 0,
BytesTotal: totalBytes,
Stage: "Extracting data",
})
}
}
}
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
}
// 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
}

View file

@ -0,0 +1,156 @@
package vfs
import (
"io/fs"
"path/filepath"
"strings"
"time"
)
var (
configExtensions = map[string]struct{}{
"toml": {}, "yaml": {}, "yml": {}, "json": {}, "jsonc": {}, "ini": {}, "conf": {},
"config": {}, "env": {}, "properties": {}, "xml": {}, "mod": {}, "sum": {}, "lock": {},
}
textExtensions = map[string]struct{}{
"txt": {}, "md": {}, "rst": {}, "go": {}, "rs": {}, "c": {}, "h": {}, "cpp": {}, "hpp": {},
"py": {}, "js": {}, "ts": {}, "tsx": {}, "jsx": {}, "java": {}, "kt": {}, "kts": {}, "swift": {},
"html": {}, "css": {}, "scss": {}, "sass": {}, "less": {}, "styl": {},
"sh": {}, "bash": {}, "zsh": {}, "fish": {}, "sql": {},
"log": {}, "csv": {}, "tsv": {},
"lua": {}, "rb": {}, "pl": {}, "pm": {}, "t": {}, "ps1": {}, "bat": {}, "cmd": {},
"vue": {}, "svelte": {}, "astro": {}, "ejs": {}, "hbs": {}, "pug": {}, "haml": {}, "php": {}, "twig": {},
"scala": {}, "groovy": {}, "clj": {}, "ex": {}, "exs": {}, "elm": {}, "hs": {}, "lisp": {}, "cl": {}, "rkt": {}, "scm": {}, "dart": {},
"tex": {}, "bib": {}, "sty": {}, "cls": {},
"gradle": {}, "cmake": {}, "mk": {}, "mak": {},
"asm": {}, "s": {}, "inc": {},
"patch": {}, "diff": {},
"proto": {}, "graphql": {}, "gql": {},
"tf": {}, "hcl": {},
"r": {}, "m": {}, "mm": {},
"nim": {}, "zig": {}, "odin": {}, "v": {}, "nix": {},
"cr": {}, "jl": {},
"erl": {}, "hrl": {},
}
// textFilenames lists common text files without a meaningful extension
// (like Makefile, Dockerfile, etc.) so they open in the editor.
textFilenames = map[string]struct{}{
"makefile": {}, "dockerfile": {}, "containerfile": {},
"readme": {}, "license": {}, "licence": {}, "copying": {}, "changelog": {}, "changes": {},
"todo": {}, "notes": {}, "authors": {}, "contributors": {}, "maintainers": {},
"procfile": {}, "gemfile": {}, "rakefile": {}, "snapfile": {}, "fastfile": {},
"cmakelists": {}, "justfile": {}, "taskfile": {},
"gitignore": {}, "gitattributes": {}, "gitmodules": {}, "gitkeep": {},
"gitconfig": {}, "git-blame-ignore-revs": {},
"editorconfig": {}, "envrc": {}, "hushlogin": {},
"xsession": {}, "xresources": {}, "xinitrc": {},
"bashrc": {}, "bash_profile": {}, "bash_logout": {},
"zshrc": {}, "zprofile": {}, "zlogin": {}, "zlogout": {},
"profile": {}, "inputrc": {}, "tmux.conf": {},
"npmrc": {}, "yarnrc": {}, "pnpmrc": {},
"eslintrc": {}, "prettierrc": {}, "babelrc": {},
"stylelintrc": {}, "commitlintrc": {},
"htaccess": {}, "htpasswd": {},
}
imageExtensions = map[string]struct{}{
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "bmp": {}, "svg": {}, "ico": {},
"avif": {}, "heic": {}, "heif": {}, "tiff": {}, "tif": {},
}
pdfExtensions = map[string]struct{}{
"pdf": {},
}
audioExtensions = map[string]struct{}{
"mp3": {}, "flac": {}, "ogg": {}, "opus": {}, "wav": {},
"aac": {}, "m4a": {}, "wma": {}, "dsf": {}, "ape": {},
}
videoExtensions = map[string]struct{}{
"mp4": {}, "mkv": {}, "mov": {}, "avi": {}, "webm": {},
"m4v": {}, "wmv": {}, "flv": {}, "ts": {}, "mts": {},
}
archiveExtensions = map[string]struct{}{
"zip": {}, "tar": {}, "gz": {}, "tgz": {}, "xz": {}, "bz2": {}, "7z": {}, "rar": {},
"zst": {}, "lz": {}, "lz4": {}, "lzma": {},
"iso": {}, "img": {}, "dmg": {},
}
)
type Entry struct {
Name string
Path string
Extension string
Mode fs.FileMode
Size int64
ModifiedAt time.Time
CreatedAt time.Time
CreatedKnown bool
IsDir bool
IsParent bool
IsHidden bool
IsRemote bool
Connected bool
DirSizeKnown bool
RemoteHostName string
}
func (e Entry) DisplayName() string {
if e.IsParent {
return ".."
}
if e.IsDir {
return e.Name + "/"
}
return e.Name
}
func (e Entry) IsFile() bool {
return !e.IsDir && !e.IsParent
}
func (e Entry) MatchKey() string {
return strings.ToLower(e.Name)
}
func (e Entry) IsExecutable() bool {
return !e.IsDir && !e.IsParent && e.Mode&0o111 != 0
}
func (e Entry) Category() string {
switch {
case e.IsParent:
return "parent"
case e.IsRemote:
return "remote"
case e.IsDir:
return "directory"
case hasExt(configExtensions, e.Extension):
return "config"
case hasExt(textExtensions, e.Extension):
return "text"
case hasExt(textFilenames, strings.ToLower(e.Name)):
return "text"
case hasExt(imageExtensions, e.Extension):
return "image"
case hasExt(pdfExtensions, e.Extension):
return "pdf"
case hasExt(audioExtensions, e.Extension):
return "audio"
case hasExt(videoExtensions, e.Extension):
return "video"
case hasExt(archiveExtensions, e.Extension):
return "archive"
case e.IsExecutable():
return "executable"
default:
return "binary"
}
}
func ext(name string) string {
value := strings.TrimPrefix(filepath.Ext(name), ".")
return strings.ToLower(value)
}
func hasExt(set map[string]struct{}, ext string) bool {
_, ok := set[strings.ToLower(ext)]
return ok
}

View file

@ -0,0 +1,527 @@
package vfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
"time"
)
type TransferStats struct {
FilesTotal int
BytesTotal int64
}
type CopyProgress struct {
FilesDone int
FilesTotal int
BytesDone int64
BytesTotal int64
CurrentPath string
Stage string
}
type copyProgressState struct {
ctx context.Context
filesDone int
bytesDone int64
stats TransferStats
callback func(CopyProgress)
lastEmit time.Time
stage string
discover bool // if true, count files during copy for progress total
}
func (s *copyProgressState) discoverFiles(count int, dirPath string) {
if count == 0 {
return
}
s.stats.FilesTotal += count
s.emit(dirPath, false)
}
func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
return CopyPathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func CopyPathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return "", fmt.Errorf("stat %s: %w", srcPath, err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if err := ctx.Err(); err != nil {
return "", err
}
if progress == nil {
progress = func(CopyProgress) {}
}
tracker := copyProgressState{
ctx: ctx,
stats: stats,
callback: progress,
stage: "Scanning files...",
discover: stats.FilesTotal == 0,
}
tracker.emit(srcPath, true)
tracker.stage = "Copying files..."
cleanupOnErr := func(copyErr error) (string, error) {
if copyErr != nil {
_ = os.RemoveAll(targetPath)
}
return "", copyErr
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(srcPath)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Symlink(target, targetPath); err != nil {
return "", err
}
tracker.finishFile(srcPath)
return targetPath, nil
}
if srcInfo.IsDir() {
if err := copyDir(srcPath, targetPath, &tracker); err != nil {
return cleanupOnErr(err)
}
if err := ctx.Err(); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
if err := copyFile(srcPath, targetPath, srcInfo.Mode(), &tracker); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
func CopyStats(srcPath string) (TransferStats, error) {
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return TransferStats{}, fmt.Errorf("stat %s: %w", srcPath, err)
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
return TransferStats{FilesTotal: 1, BytesTotal: 0}, nil
}
if !srcInfo.IsDir() {
return TransferStats{FilesTotal: 1, BytesTotal: srcInfo.Size()}, nil
}
stats := TransferStats{}
err = filepath.WalkDir(srcPath, func(current string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
stats.FilesTotal++
return nil
})
if err != nil {
return TransferStats{}, err
}
return stats, nil
}
func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return CopyPathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
return MovePathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return MovePathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if progress == nil {
progress = func(CopyProgress) {}
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Rename(srcPath, targetPath); err == nil {
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
Stage: "Move completed",
})
return targetPath, nil
} else if !errors.Is(err, syscall.EXDEV) {
return "", err
}
targetPath, err := CopyPathWithProgressContext(ctx, srcPath, dstDir, overwrite, stats, progress)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
_ = os.RemoveAll(targetPath)
return "", err
}
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
Stage: "Finalizing move",
})
if err := DeletePath(srcPath); err != nil {
return "", err
}
return targetPath, nil
}
func PathExists(path string) (bool, error) {
if _, err := os.Lstat(path); err == nil {
return true, nil
} else if errors.Is(err, os.ErrNotExist) {
return false, nil
} else {
return false, err
}
}
func DeletePath(path string) error {
return os.RemoveAll(path)
}
// MoveToTrash moves a file or directory to the FreeDesktop Trash directory
// (~/.local/share/Trash). Follows the FreeDesktop Trash specification:
// - The original item is moved to Trash/files/<basename>
// - A .trashinfo file is written to Trash/info/<basename>.trashinfo
// - If <basename> already exists in Trash/files, a numeric suffix is appended.
func MoveToTrash(path string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot determine home directory: %w", err)
}
trashDir := filepath.Join(home, ".local", "share", "Trash")
filesDir := filepath.Join(trashDir, "files")
infoDir := filepath.Join(trashDir, "info")
if err := os.MkdirAll(filesDir, 0o700); err != nil {
return fmt.Errorf("cannot create trash files directory: %w", err)
}
if err := os.MkdirAll(infoDir, 0o700); err != nil {
return fmt.Errorf("cannot create trash info directory: %w", err)
}
baseName := filepath.Base(path)
// Generate a unique name in the trash directory
destName := baseName
for counter := 1; ; counter++ {
destPath := filepath.Join(filesDir, destName)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
break
} else if err != nil {
return fmt.Errorf("cannot stat trash path: %w", err)
}
destName = fmt.Sprintf("%s.%d", baseName, counter)
}
destPath := filepath.Join(filesDir, destName)
if err := os.Rename(path, destPath); err != nil {
// Cross-filesystem move: fall back to copy+delete
return fmt.Errorf("cannot move to trash: %w", err)
}
// Write .trashinfo file
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
now := time.Now().Format("2006-01-02T15:04:05")
infoContent := fmt.Sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n", absPath, now)
infoPath := filepath.Join(infoDir, destName+".trashinfo")
if err := os.WriteFile(infoPath, []byte(infoContent), 0o600); err != nil {
// Best-effort: if info file fails, try to move the file back
_ = os.Rename(destPath, path)
return fmt.Errorf("cannot write trash info: %w", err)
}
return nil
}
func MakeDir(parent string, name string) (string, error) {
target := filepath.Join(parent, name)
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
return target, nil
}
func RenamePath(sourcePath string, newName string) (string, error) {
newName = filepath.Base(filepath.Clean(newName))
if newName == "." || newName == "" {
return "", fmt.Errorf("invalid target name")
}
targetPath := filepath.Join(filepath.Dir(sourcePath), newName)
if same, err := samePath(sourcePath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
return "", ErrOverwrite(targetPath)
}
if err := os.Rename(sourcePath, targetPath); err != nil {
return "", err
}
return targetPath, nil
}
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
info, err := os.Lstat(srcDir)
if err != nil {
return err
}
if err := os.MkdirAll(dstDir, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
// Count files in this directory so progress total converges
if tracker != nil && tracker.discover {
fileCount := 0
for _, entry := range entries {
if !entry.IsDir() {
fileCount++
}
}
tracker.discoverFiles(fileCount, srcDir)
}
for _, entry := range entries {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcPath := filepath.Join(srcDir, entry.Name())
dstPath := filepath.Join(dstDir, entry.Name())
info, err := os.Lstat(srcPath)
if err != nil {
return err
}
switch {
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(srcPath)
if err != nil {
return err
}
if err := os.Symlink(target, dstPath); err != nil {
return err
}
if tracker != nil {
tracker.finishFile(srcPath)
}
case info.IsDir():
if err := copyDir(srcPath, dstPath, tracker); err != nil {
return err
}
default:
if err := copyFile(srcPath, dstPath, info.Mode(), tracker); err != nil {
return err
}
}
}
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
return nil
}
func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode.Perm())
if err != nil {
return err
}
defer dstFile.Close()
writer := io.Writer(dstFile)
if tracker != nil {
writer = &progressWriter{base: dstFile, tracker: tracker, path: srcPath}
}
if _, err := io.Copy(writer, srcFile); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err
}
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err
}
}
if tracker != nil {
tracker.finishFile(srcPath)
}
return nil
}
type progressWriter struct {
base io.Writer
tracker *copyProgressState
path string
}
func (w *progressWriter) Write(data []byte) (int, error) {
if w.tracker != nil && w.tracker.ctx != nil {
if err := w.tracker.ctx.Err(); err != nil {
return 0, err
}
}
n, err := w.base.Write(data)
if n > 0 {
w.tracker.addBytes(int64(n), w.path)
}
return n, err
}
func (s *copyProgressState) addBytes(delta int64, currentPath string) {
s.bytesDone += delta
s.emit(currentPath, false)
}
func (s *copyProgressState) finishFile(currentPath string) {
s.filesDone++
s.emit(currentPath, true)
}
func (s *copyProgressState) emit(currentPath string, force bool) {
if s.callback == nil {
return
}
if !force && time.Since(s.lastEmit) < 75*time.Millisecond {
return
}
s.lastEmit = time.Now()
stage := s.stage
if stage == "" {
stage = "Transferring data"
}
s.callback(CopyProgress{
FilesDone: s.filesDone,
FilesTotal: s.stats.FilesTotal,
BytesDone: s.bytesDone,
BytesTotal: s.stats.BytesTotal,
CurrentPath: currentPath,
Stage: stage,
})
}
func samePath(left string, right string) (bool, error) {
leftAbs, err := filepath.Abs(left)
if err != nil {
return false, err
}
rightAbs, err := filepath.Abs(right)
if err != nil {
return false, err
}
return leftAbs == rightAbs, nil
}

View file

@ -0,0 +1,135 @@
package vfs
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"testing"
)
func TestCopyPathWithProgressContextRemovesPartialTargetOnCancel(t *testing.T) {
t.Parallel()
root := t.TempDir()
srcDir := filepath.Join(root, "src")
dstDir := filepath.Join(root, "dst")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatalf("mkdir src: %v", err)
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir dst: %v", err)
}
for idx := 0; idx < 64; idx++ {
path := filepath.Join(srcDir, "file-"+strconv.Itoa(idx)+".txt")
if err := os.WriteFile(path, []byte("payload-"+strconv.Itoa(idx)), 0o644); err != nil {
t.Fatalf("write source file %d: %v", idx, err)
}
}
stats, err := CopyStats(srcDir)
if err != nil {
t.Fatalf("copy stats: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err = CopyPathWithProgressContext(ctx, srcDir, dstDir, false, stats, func(progress CopyProgress) {
if progress.FilesDone >= 1 {
cancel()
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcDir))
if _, statErr := os.Stat(targetPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected partial target to be removed, stat err=%v", statErr)
}
}
func TestMovePathWithProgressContextCancelledBeforeStartKeepsSource(t *testing.T) {
t.Parallel()
root := t.TempDir()
srcFile := filepath.Join(root, "source.txt")
dstDir := filepath.Join(root, "dst")
if err := os.WriteFile(srcFile, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir dst: %v", err)
}
stats, err := CopyStats(srcFile)
if err != nil {
t.Fatalf("copy stats: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = MovePathWithProgressContext(ctx, srcFile, dstDir, false, stats, nil)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
if _, statErr := os.Stat(srcFile); statErr != nil {
t.Fatalf("expected source to remain in place, stat err=%v", statErr)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcFile))
if _, statErr := os.Stat(targetPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected destination file to be absent, stat err=%v", statErr)
}
}
func TestRenamePath(t *testing.T) {
t.Parallel()
root := t.TempDir()
source := filepath.Join(root, "old.txt")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
target, err := RenamePath(source, "new.txt")
if err != nil {
t.Fatalf("rename: %v", err)
}
if filepath.Base(target) != "new.txt" {
t.Fatalf("unexpected target path: %s", target)
}
if _, statErr := os.Stat(target); statErr != nil {
t.Fatalf("expected renamed file to exist, stat err=%v", statErr)
}
if _, statErr := os.Stat(source); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected source to be absent, stat err=%v", statErr)
}
}
func TestRenamePathRejectsExistingTarget(t *testing.T) {
t.Parallel()
root := t.TempDir()
source := filepath.Join(root, "old.txt")
target := filepath.Join(root, "new.txt")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
_, err := RenamePath(source, "new.txt")
if err == nil {
t.Fatalf("expected overwrite error, got nil")
}
if got, want := err.Error(), ErrOverwrite(target).Error(); got != want {
t.Fatalf("expected overwrite error %q, got %q", want, got)
}
}

View file

@ -0,0 +1,693 @@
package vfs
import (
"bytes"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
var sgrRegexp = regexp.MustCompile(`\x1b\[([0-9;:]*)m`)
var sgrNumberRegexp = regexp.MustCompile(`\d+`)
type PreviewKind string
const (
PreviewKindEmpty PreviewKind = "empty"
PreviewKindDirectory PreviewKind = "directory"
PreviewKindText PreviewKind = "text"
PreviewKindImage PreviewKind = "image"
PreviewKindPDF PreviewKind = "pdf"
PreviewKindAudio PreviewKind = "audio"
PreviewKindVideo PreviewKind = "video"
PreviewKindBinary PreviewKind = "binary"
PreviewKindError PreviewKind = "error"
)
type Metadata struct {
Path string
Kind string
Size int64
SizeKnown bool
ModifiedAt string
CreatedAt string
Permissions string
ImageFormat string
ImageSize string
Extension string
// Extended preview metadata
Duration string
Bitrate string
AudioCodec string
VideoCodec string
SampleRate string
Channels string
PageCount string
Dimensions string
}
type Preview struct {
Kind PreviewKind
Title string
Body string
PlainBody string
Metadata Metadata
Entries []Entry
}
type PreviewOptions struct {
ShowHidden bool
DirsFirst bool
SortBy string
SortReverse bool
MaxPreviewBytes int64
DirectoryPreviewLimit int
HumanReadableSize bool
ThemeName string
UseNerdIcons bool
ImagePreviewWidth int
ImagePreviewHeight int
}
func BuildPreview(entry Entry, options PreviewOptions) Preview {
preview := Preview{
Kind: PreviewKindEmpty,
Title: entry.DisplayName(),
Metadata: Metadata{
Path: entry.Path,
Kind: kindLabel(entry),
Permissions: Permissions(entry.Mode),
ModifiedAt: ShortTime(entry.ModifiedAt),
CreatedAt: "n/a",
Extension: entry.Extension,
},
}
if entry.CreatedKnown {
preview.Metadata.CreatedAt = ShortTime(entry.CreatedAt)
}
if entry.IsDir {
preview.Kind = PreviewKindDirectory
preview.Metadata.Size = entry.Size
preview.Metadata.SizeKnown = entry.DirSizeKnown
preview.Body, preview.Entries = buildDirectoryPreview(entry.Path, options)
preview.PlainBody = preview.Body
return preview
}
preview.Metadata.Size = entry.Size
preview.Metadata.SizeKnown = true
file, err := os.Open(entry.Path)
if err != nil {
preview.Kind = PreviewKindError
preview.Body = fmt.Sprintf("Could not open file:\n\n%s", err)
preview.PlainBody = preview.Body
return preview
}
defer file.Close()
buffer := new(bytes.Buffer)
if _, err := io.CopyN(buffer, file, options.MaxPreviewBytes); err != nil && err != io.EOF {
preview.Kind = PreviewKindError
preview.Body = fmt.Sprintf("Could not read preview:\n\n%s", err)
preview.PlainBody = preview.Body
return preview
}
data := buffer.Bytes()
if format, dimensions, ok := DetectImage(data); ok {
preview.Kind = PreviewKindImage
preview.Metadata.ImageFormat = format
preview.Metadata.ImageSize = dimensions
inline := renderImageInlinePreview(entry.Path, options.ImagePreviewWidth, options.ImagePreviewHeight)
if inline != "" {
preview.Body = inline
}
preview.PlainBody = preview.Body
return preview
}
// Extended preview for PDF, audio, video via external utilities
if hasExt(pdfExtensions, entry.Extension) {
return buildPDFPreview(entry, options, preview)
}
if hasExt(audioExtensions, entry.Extension) {
return buildAudioPreview(entry, options, preview)
}
if hasExt(videoExtensions, entry.Extension) {
return buildVideoPreview(entry, options, preview)
}
if IsBinarySample(data) {
preview.Kind = PreviewKindBinary
preview.Body = "Binary file detected.\n\nSafe inline preview is disabled for this file type."
preview.PlainBody = preview.Body
return preview
}
preview.Kind = PreviewKindText
preview.PlainBody = strings.ReplaceAll(string(data), "\t", " ")
preview.Body = highlightText(entry.Path, preview.PlainBody, options.ThemeName)
return preview
}
func highlightText(path string, source string, themeName string) string {
lexer := lexers.Match(path)
if lexer == nil {
lexer = lexers.Analyse(source)
}
if lexer == nil {
return source
}
iterator, err := chroma.Coalesce(lexer).Tokenise(nil, source)
if err != nil {
return source
}
style := styles.Get(chromaStyleName(themeName))
if style == nil {
return source
}
style = styleWithoutBackground(style)
if style == nil {
return source
}
var output bytes.Buffer
if err := formatters.TTY16m.Format(&output, style, iterator); err != nil {
return source
}
return stripBackgroundSGR(output.String())
}
func styleWithoutBackground(base *chroma.Style) *chroma.Style {
if base == nil {
return nil
}
builder := base.Builder().Transform(func(entry chroma.StyleEntry) chroma.StyleEntry {
entry.Background = 0
return entry
})
stripped, err := builder.Build()
if err != nil {
return base
}
return stripped
}
func stripBackgroundSGR(text string) string {
return sgrRegexp.ReplaceAllStringFunc(text, func(seq string) string {
matches := sgrRegexp.FindStringSubmatch(seq)
if len(matches) != 2 {
return seq
}
filtered := filterSGRParams(matches[1])
if filtered == "" {
return ""
}
return "\x1b[" + filtered + "m"
})
}
func filterSGRParams(paramString string) string {
if paramString == "" {
return ""
}
raw := sgrNumberRegexp.FindAllString(paramString, -1)
if len(raw) == 0 {
return ""
}
codes := make([]int, 0, len(raw))
for _, token := range raw {
value, err := strconv.Atoi(token)
if err != nil {
continue
}
codes = append(codes, value)
}
kept := make([]string, 0, len(codes))
for i := 0; i < len(codes); i++ {
code := codes[i]
if code == 0 {
// Do not hard-reset background to terminal default.
// Reset common text attributes + foreground only.
kept = append(kept, "39", "22", "23", "24", "59")
continue
}
if code == 49 || code == 7 || code == 27 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107) {
continue
}
switch code {
case 48:
// Background color payloads:
// 48;5;n or 48;2;r;g;b (also appears in ':' form; parsed as the same int stream).
if i+1 < len(codes) {
mode := codes[i+1]
switch mode {
case 5:
i += 2
case 2:
i += 4
default:
i++
}
}
continue
case 38, 58:
// Preserve foreground (38) and underline color (58) payloads.
kept = append(kept, strconv.Itoa(code))
if i+1 < len(codes) {
mode := codes[i+1]
kept = append(kept, strconv.Itoa(mode))
switch mode {
case 5:
if i+2 < len(codes) {
kept = append(kept, strconv.Itoa(codes[i+2]))
}
i += 2
case 2:
if i+4 < len(codes) {
kept = append(kept,
strconv.Itoa(codes[i+2]),
strconv.Itoa(codes[i+3]),
strconv.Itoa(codes[i+4]),
)
}
i += 4
default:
i++
}
}
continue
}
kept = append(kept, strconv.Itoa(code))
}
return strings.Join(kept, ";")
}
func chromaStyleName(themeName string) string {
switch strings.ToLower(strings.TrimSpace(themeName)) {
case "catppuccin-mocha", "catppuccin-lavender":
return "catppuccin-mocha"
case "tokyo-night":
return "tokyonight-night"
case "gruvbox-dark", "gruvbox":
return "gruvbox"
case "nord-frost", "nord":
return "nord"
case "dracula":
return "dracula"
case "rose-pine":
return "rose-pine"
case "solarized-dark":
return "solarized-dark"
default:
return "catppuccin-mocha"
}
}
func buildDirectoryPreview(path string, options PreviewOptions) (string, []Entry) {
entries, err := ListDir(path, ListOptions{
ShowHidden: options.ShowHidden,
DirsFirst: options.DirsFirst,
SortBy: options.SortBy,
SortReverse: options.SortReverse,
})
if err != nil {
return fmt.Sprintf("Could not list directory:\n\n%s", err), nil
}
if len(entries) == 0 {
return "Directory is empty.", nil
}
// Return all entries as-is for column-based rendering.
// The text body is still generated for terminals that don't support
// the rich rendering, and as a fallback.
var lines []string
for _, entry := range entries {
if entry.IsParent {
continue
}
icon := previewIcon(entry, options.UseNerdIcons)
size := ""
if !entry.IsDir {
if options.HumanReadableSize {
size = HumanSize(entry.Size)
} else {
size = fmt.Sprintf("%d", entry.Size)
}
}
lines = append(lines, fmt.Sprintf("%s %-36s %12s %s", icon, entry.DisplayName(), size, ShortTime(entry.ModifiedAt)))
if len(lines) >= options.DirectoryPreviewLimit {
lines = append(lines, "…")
break
}
}
return strings.Join(lines, "\n"), entries
}
func previewIcon(entry Entry, useNerdIcons bool) string {
if !useNerdIcons {
switch entry.Category() {
case "directory":
return "[D]"
case "config":
return "[C]"
case "text":
return "[T]"
case "image":
return "[I]"
case "executable":
return "[X]"
case "archive":
return "[A]"
default:
return "[F]"
}
}
switch entry.Category() {
case "directory":
return ""
case "config":
return ""
case "text":
return "󰈙"
case "image":
return "󰋩"
case "executable":
return "󰆍"
case "archive":
return ""
default:
return "󰈔"
}
}
func renderImageInlinePreview(path string, width int, height int) string {
return ""
}
func findTool(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
func buildPDFPreview(entry Entry, options PreviewOptions, base Preview) Preview {
pdftotext := findTool("pdftotext")
if pdftotext == "" {
base.Kind = PreviewKindBinary
base.Body = "PDF file detected.\nInstall poppler-utils (pdftotext) for text preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(pdftotext, "-layout", "-nopgbrk", entry.Path, "-")
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("pdftotext error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
text := string(out)
if int64(len(text)) > options.MaxPreviewBytes {
text = text[:options.MaxPreviewBytes]
}
// Get page count via pdfinfo if available
pdfinfo := findTool("pdfinfo")
if pdfinfo != "" {
infoCmd := exec.Command(pdfinfo, entry.Path)
if infoOut, err := infoCmd.Output(); err == nil {
for _, line := range strings.Split(string(infoOut), "\n") {
if strings.HasPrefix(strings.ToLower(line), "pages:") {
base.Metadata.PageCount = strings.TrimSpace(strings.TrimPrefix(line[5:], ":"))
break
}
}
}
}
base.Kind = PreviewKindPDF
base.PlainBody = text
base.Body = highlightText(entry.Path, text, options.ThemeName)
return base
}
func buildAudioPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Audio file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
mins := int(secs) / 60
secsRem := int(secs) % 60
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
if stream.CodecType == "audio" {
base.Metadata.AudioCodec = stream.CodecName
if stream.SampleRate != "" {
base.Metadata.SampleRate = stream.SampleRate + " Hz"
}
switch stream.Channels {
case 1:
base.Metadata.Channels = "mono"
case 2:
base.Metadata.Channels = "stereo"
case 6:
base.Metadata.Channels = "5.1"
case 8:
base.Metadata.Channels = "7.1"
default:
base.Metadata.Channels = fmt.Sprintf("%d ch", stream.Channels)
}
break
}
}
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Codec: %s", base.Metadata.AudioCodec))
}
if base.Metadata.SampleRate != "" {
lines = append(lines, fmt.Sprintf(" Rate: %s", base.Metadata.SampleRate))
}
if base.Metadata.Channels != "" {
lines = append(lines, fmt.Sprintf(" Channels: %s", base.Metadata.Channels))
}
base.Kind = PreviewKindAudio
base.Body = fmt.Sprintf("🎵 Audio File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Audio File\n\nDuration: %s\nBitrate: %s\nCodec: %s\nRate: %s\nChannels: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.AudioCodec,
base.Metadata.SampleRate, base.Metadata.Channels)
return base
}
func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Video file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
hrs := int(secs) / 3600
mins := (int(secs) % 3600) / 60
secsRem := int(secs) % 60
if hrs > 0 {
base.Metadata.Duration = fmt.Sprintf("%d:%02d:%02d", hrs, mins, secsRem)
} else {
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
switch stream.CodecType {
case "video":
base.Metadata.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
base.Metadata.Dimensions = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
if base.Metadata.AudioCodec == "" {
base.Metadata.AudioCodec = stream.CodecName
}
}
}
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.VideoCodec != "" {
lines = append(lines, fmt.Sprintf(" Video: %s", base.Metadata.VideoCodec))
}
if base.Metadata.Dimensions != "" {
lines = append(lines, fmt.Sprintf(" Resolution: %s", base.Metadata.Dimensions))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Audio: %s", base.Metadata.AudioCodec))
}
base.Kind = PreviewKindVideo
base.Body = fmt.Sprintf("🎬 Video File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Video File\n\nDuration: %s\nBitrate: %s\nVideo: %s\nResolution: %s\nAudio: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.VideoCodec,
base.Metadata.Dimensions, base.Metadata.AudioCodec)
return base
}
func DetectImage(data []byte) (string, string, bool) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", "", false
}
return format, fmt.Sprintf("%dx%d", cfg.Width, cfg.Height), true
}
func kindLabel(entry Entry) string {
switch {
case entry.IsParent:
return "parent"
case entry.IsDir:
return "directory"
case entry.Extension != "":
return "file"
default:
return strings.TrimPrefix(filepath.Ext(entry.Name), ".")
}
}

View file

@ -0,0 +1,751 @@
package remote
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// SSHClient wraps an SSH connection and SFTP client for remote filesystem access.
type SSHClient struct {
// Host is the SSH host configuration used to establish the connection.
Host SSHHost
sshConn *ssh.Client
sftpCli *sftp.Client
keepaliveStop chan struct{}
keepaliveWg sync.WaitGroup
}
// Connect establishes an SSH connection to the remote host and opens an SFTP session.
// It uses key-based authentication if IdentityFile is set, otherwise falls back to password auth.
func Connect(host SSHHost) (*SSHClient, error) {
authMethods, err := authMethodsForHost(host)
if err != nil {
return nil, fmt.Errorf("ssh auth: %w", err)
}
user := host.User
if user == "" {
user = os.Getenv("USER")
}
config := &ssh.ClientConfig{
User: user,
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: support known_hosts verification
Timeout: 15 * time.Second,
}
addr := host.Addr()
sshConn, err := ssh.Dial("tcp", addr, config)
if err != nil {
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
}
sftpCli, err := sftp.NewClient(sshConn)
if err != nil {
sshConn.Close()
return nil, fmt.Errorf("sftp client: %w", err)
}
client := &SSHClient{
Host: host,
sshConn: sshConn,
sftpCli: sftpCli,
keepaliveStop: make(chan struct{}),
}
// Start keepalive goroutine — sends keepalive@openssh.com every 30s
// to prevent the SSH server from dropping the connection during inactivity.
client.keepaliveWg.Add(1)
go func() {
defer client.keepaliveWg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_, _, err := sshConn.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
return
}
case <-client.keepaliveStop:
return
}
}
}()
return client, nil
}
// authMethodsForHost returns the appropriate SSH auth methods for the given host.
// For SSH config hosts with IdentityFile, it uses public key authentication.
// For custom hosts with a password, it uses password authentication.
func authMethodsForHost(host SSHHost) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
// Try key-based auth if identity file is specified
if host.IdentityFile != "" {
key, err := os.ReadFile(host.IdentityFile)
if err == nil {
signer, err := ssh.ParsePrivateKey(key)
if err == nil {
methods = append(methods, ssh.PublicKeys(signer))
} else {
// If the key is encrypted, try with empty passphrase or common ones
// For simplicity, we try without passphrase first
// In a real implementation, we might prompt for a passphrase
}
}
}
// Try password auth if password is set
if host.Password != "" {
methods = append(methods, ssh.Password(host.Password))
}
// Always include keyboard-interactive as a fallback (it wraps password)
if host.Password != "" {
methods = append(methods, ssh.KeyboardInteractive(
func(user, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = host.Password
}
return answers, nil
},
))
}
// Always try default SSH agent and default keys as a last resort
// This covers the case where the user has an SSH agent running with loaded keys
// but no IdentityFile is specified in the config.
if host.IdentityFile == "" && host.Password == "" {
// Add default key paths
home, err := os.UserHomeDir()
if err == nil {
defaultKeys := []string{
home + "/.ssh/id_rsa",
home + "/.ssh/id_ed25519",
home + "/.ssh/id_ecdsa",
home + "/.ssh/id_ecdsa_sk",
home + "/.ssh/id_ed25519_sk",
home + "/.ssh/identity",
}
for _, keyPath := range defaultKeys {
if key, err := os.ReadFile(keyPath); err == nil {
if signer, err := ssh.ParsePrivateKey(key); err == nil {
methods = append(methods, ssh.PublicKeys(signer))
}
}
}
}
}
if len(methods) == 0 {
return nil, fmt.Errorf("no authentication methods available for host %q", host.Name)
}
return methods, nil
}
// ReadDir reads the contents of a remote directory and returns os.FileInfo entries.
func (c *SSHClient) ReadDir(dirPath string) ([]os.FileInfo, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.ReadDir(dirPath)
}
// Lstat returns file information without following symlinks.
func (c *SSHClient) Lstat(path string) (os.FileInfo, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Lstat(path)
}
// Stat returns file information following symlinks.
func (c *SSHClient) Stat(path string) (os.FileInfo, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Stat(path)
}
// ReadLink reads the target of a symbolic link.
func (c *SSHClient) ReadLink(linkPath string) (string, error) {
if c.sftpCli == nil {
return "", fmt.Errorf("not connected")
}
return c.sftpCli.ReadLink(linkPath)
}
// RealPath resolves a path to its absolute form on the remote server.
func (c *SSHClient) RealPath(p string) (string, error) {
if c.sftpCli == nil {
return "", fmt.Errorf("not connected")
}
return c.sftpCli.RealPath(p)
}
// ReadFile opens a remote file for reading.
func (c *SSHClient) ReadFile(filePath string) (io.ReadCloser, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Open(filePath)
}
// CreateFile opens a remote file for writing, creating it if it doesn't exist.
func (c *SSHClient) CreateFile(filePath string) (io.WriteCloser, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Create(filePath)
}
// MkdirAll creates a remote directory and any necessary parents.
// If the directory already exists, it returns nil (no error).
func (c *SSHClient) MkdirAll(dirPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
// sftp doesn't have MkdirAll, so we implement it manually
// First check if the path already exists
_, err := c.sftpCli.Stat(dirPath)
if err == nil {
return nil // already exists
}
if !os.IsNotExist(err) {
return err
}
// Ensure parent exists first
parent := path.Dir(dirPath)
if parent != dirPath && parent != "." {
if err := c.MkdirAll(parent); err != nil {
return err
}
}
return c.sftpCli.Mkdir(dirPath)
}
// Mkdir creates a single remote directory.
func (c *SSHClient) Mkdir(dirPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.Mkdir(dirPath)
}
// Remove deletes a remote file.
func (c *SSHClient) Remove(filePath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.Remove(filePath)
}
// RemoveDirectory removes a remote directory (must be empty).
func (c *SSHClient) RemoveDirectory(dirPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.RemoveDirectory(dirPath)
}
// Rename moves/renames a remote file or directory.
func (c *SSHClient) Rename(oldPath, newPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.Rename(oldPath, newPath)
}
// Close closes the SFTP session and SSH connection.
func (c *SSHClient) Close() error {
// Stop the keepalive goroutine first
if c.keepaliveStop != nil {
select {
case <-c.keepaliveStop:
// already closed
default:
close(c.keepaliveStop)
}
c.keepaliveWg.Wait()
}
var firstErr error
if c.sftpCli != nil {
if err := c.sftpCli.Close(); err != nil {
firstErr = err
}
c.sftpCli = nil
}
if c.sshConn != nil {
if err := c.sshConn.Close(); err != nil && firstErr == nil {
firstErr = err
}
c.sshConn = nil
}
return firstErr
}
// IsConnected returns true if the client has an active connection.
func (c *SSHClient) IsConnected() bool {
return c.sftpCli != nil && c.sshConn != nil
}
// Exec runs a shell command on the remote server and returns combined output.
func (c *SSHClient) Exec(cmd string) ([]byte, error) {
if c.sshConn == nil {
return nil, fmt.Errorf("not connected")
}
session, err := c.sshConn.NewSession()
if err != nil {
return nil, fmt.Errorf("open session: %w", err)
}
defer session.Close()
return session.CombinedOutput(cmd)
}
// ExecWithProgress runs a shell command on the remote server and calls onLine
// for each line of stdout output.
func (c *SSHClient) ExecWithProgress(cmd string, onLine func(line string)) error {
if c.sshConn == nil {
return fmt.Errorf("not connected")
}
session, err := c.sshConn.NewSession()
if err != nil {
return fmt.Errorf("open session: %w", err)
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := session.Start(cmd); err != nil {
return fmt.Errorf("start command: %w", err)
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
onLine(scanner.Text())
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
return session.Wait()
}
// SameHostAs returns true if this client and other are connected to the same server.
func (c *SSHClient) SameHostAs(other *SSHClient) bool {
if c == nil || other == nil {
return false
}
return c.Host.SameAs(other.Host)
}
// RemoveRecursive recursively deletes a remote file or directory.
// For directories, it walks and removes all children first.
func (c *SSHClient) RemoveRecursive(path string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
info, err := c.sftpCli.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return c.sftpCli.Remove(path)
}
// Walk directory and collect all paths (files first, then dirs)
var files []string
var dirs []string
err = c.Walk(path, func(walkPath string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if walkPath == path {
return nil // skip root
}
if info.IsDir() {
dirs = append(dirs, walkPath)
} else {
files = append(files, walkPath)
}
return nil
})
if err != nil {
return err
}
// Remove files first, then directories (reverse order for deepest first)
for _, f := range files {
if err := c.sftpCli.Remove(f); err != nil {
return err
}
}
for i := len(dirs) - 1; i >= 0; i-- {
if err := c.sftpCli.RemoveDirectory(dirs[i]); err != nil {
return err
}
}
// Finally remove the root directory
return c.sftpCli.RemoveDirectory(path)
}
// CopyFileToRemote copies a local file to a remote destination via SFTP.
// It creates parent directories as needed.
func (c *SSHClient) CopyFileToRemote(localPath, remotePath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
localFile, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open local: %w", err)
}
defer localFile.Close()
// Ensure parent directory exists
parent := path.Dir(remotePath)
if err := c.MkdirAll(parent); err != nil {
return fmt.Errorf("mkdir remote: %w", err)
}
remoteFile, err := c.sftpCli.Create(remotePath)
if err != nil {
return fmt.Errorf("create remote: %w", err)
}
defer remoteFile.Close()
_, err = io.Copy(remoteFile, localFile)
if err != nil {
return fmt.Errorf("copy to remote: %w", err)
}
return nil
}
// CopyFileFromRemote copies a remote file to a local destination via SFTP.
// It creates parent directories as needed.
func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
remoteFile, err := c.sftpCli.Open(remotePath)
if err != nil {
return fmt.Errorf("open remote: %w", err)
}
defer remoteFile.Close()
// Ensure parent directory exists
parent := filepath.Dir(localPath)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("mkdir local: %w", err)
}
localFile, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("create local: %w", err)
}
defer localFile.Close()
_, err = io.Copy(localFile, remoteFile)
if err != nil {
return fmt.Errorf("copy from remote: %w", err)
}
return nil
}
// DownloadFile downloads a remote file to a local path via SFTP.
func (c *SSHClient) DownloadFile(remotePath, localPath string) error {
return c.CopyFileFromRemote(remotePath, localPath)
}
// CopyDirToRemote recursively copies a local directory to a remote path.
func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error {
return c.copyDirToRemote(localDir, remoteDir, nil, nil)
}
// CopyDirToRemoteProgress is like CopyDirToRemote but calls onFile after each copy.
func (c *SSHClient) CopyDirToRemoteProgress(localDir, remoteDir string, onFile func(path string, done, total int), ctx context.Context) error {
return c.copyDirToRemote(localDir, remoteDir, onFile, ctx)
}
func (c *SSHClient) copyDirToRemote(localDir, remoteDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return filepath.Walk(localDir, func(localPath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
relPath, _ := filepath.Rel(localDir, localPath)
remotePath := path.Join(remoteDir, relPath)
if info.IsDir() {
return c.MkdirAll(remotePath)
}
if err := c.CopyFileToRemote(localPath, remotePath); err != nil {
return err
}
done++
if onFile != nil {
onFile(remotePath, done, 0)
}
return nil
})
}
// CopyDirFromRemote recursively copies a remote directory to a local path.
func (c *SSHClient) CopyDirFromRemote(remoteDir, localDir string) error {
return c.copyDirFromRemote(remoteDir, localDir, nil, nil)
}
// CopyDirFromRemoteProgress is like CopyDirFromRemote but calls onFile after each copy.
func (c *SSHClient) CopyDirFromRemoteProgress(remoteDir, localDir string, onFile func(path string, done, total int), ctx context.Context) error {
return c.copyDirFromRemote(remoteDir, localDir, onFile, ctx)
}
func (c *SSHClient) copyDirFromRemote(remoteDir, localDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return c.Walk(remoteDir, func(remotePath string, info os.FileInfo, err error) error {
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
if err != nil {
return err
}
relPath, _ := filepath.Rel(remoteDir, remotePath)
localPath := filepath.Join(localDir, relPath)
if info.IsDir() {
return os.MkdirAll(localPath, 0o755)
}
if err := c.CopyFileFromRemote(remotePath, localPath); err != nil {
return err
}
done++
if onFile != nil {
onFile(localPath, done, 0)
}
return nil
})
}
// CopyFileBetweenRemotes copies a single file from one remote host to another
// by streaming the file contents through the local machine. Both SFTP connections
// must be active (connected).
func CopyFileBetweenRemotes(srcClient, dstClient *SSHClient, srcPath, dstPath string) error {
if srcClient.sftpCli == nil {
return fmt.Errorf("source client not connected")
}
if dstClient.sftpCli == nil {
return fmt.Errorf("destination client not connected")
}
srcFile, err := srcClient.sftpCli.Open(srcPath)
if err != nil {
return fmt.Errorf("open remote source %s: %w", srcPath, err)
}
defer srcFile.Close()
// Ensure parent directory exists on the destination
parent := path.Dir(dstPath)
if err := dstClient.MkdirAll(parent); err != nil {
return fmt.Errorf("mkdir remote dest %s: %w", parent, err)
}
dstFile, err := dstClient.sftpCli.Create(dstPath)
if err != nil {
return fmt.Errorf("create remote dest %s: %w", dstPath, err)
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("copy remote to remote %s → %s: %w", srcPath, dstPath, err)
}
return nil
}
// CopyDirBetweenRemotes recursively copies a directory from one remote host to another.
func CopyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string) error {
return copyDirBetweenRemotes(srcClient, dstClient, srcDir, dstDir, nil, nil)
}
func copyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return srcClient.Walk(srcDir, func(remotePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
relPath, _ := filepath.Rel(srcDir, remotePath)
dstPath := path.Join(dstDir, relPath)
if info.IsDir() {
return dstClient.MkdirAll(dstPath)
}
if err := CopyFileBetweenRemotes(srcClient, dstClient, remotePath, dstPath); err != nil {
return err
}
done++
if onFile != nil {
onFile(remotePath, done, 0)
}
return nil
})
}
// CopyDirBetweenRemotesProgress is like CopyDirBetweenRemotes with progress and context support.
func CopyDirBetweenRemotesProgress(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
return copyDirBetweenRemotes(srcClient, dstClient, srcDir, dstDir, onFile, ctx)
}
// Walk walks the remote filesystem tree rooted at root, calling walkFn for each file/dir.
// This is a simplified version of filepath.Walk for SFTP.
type walkFunc func(path string, info os.FileInfo, err error) error
func (c *SSHClient) Walk(root string, walkFn walkFunc) error {
return c.walk(root, walkFn, nil)
}
func (c *SSHClient) walk(dirPath string, walkFn walkFunc, info os.FileInfo) error {
if info == nil {
var err error
info, err = c.sftpCli.Stat(dirPath)
if err != nil {
return walkFn(dirPath, nil, err)
}
}
err := walkFn(dirPath, info, nil)
if err != nil {
if err == filepathSkipDir {
return nil
}
return err
}
if !info.IsDir() {
return nil
}
entries, err := c.sftpCli.ReadDir(dirPath)
if err != nil {
return walkFn(dirPath, info, err)
}
for _, entry := range entries {
childPath := path.Join(dirPath, entry.Name())
if entry.IsDir() {
err = c.walk(childPath, walkFn, entry)
} else {
err = walkFn(childPath, entry, nil)
}
if err != nil {
return err
}
}
return nil
}
// filepathSkipDir is used as a return value from Walk to skip a directory.
var filepathSkipDir = fmt.Errorf("skip this directory")
// DirectorySize recursively walks a remote directory and sums up file sizes.
func (c *SSHClient) DirectorySize(dirPath string) (int64, error) {
var total int64
err := c.Walk(dirPath, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
if err != nil {
return 0, err
}
return total, nil
}
// SftpToFileInfo converts an os.FileInfo to a vfs-compatible file info.
// This is used for consistent file information handling across local and remote.
func SftpToFileInfo(name string, info os.FileInfo) (os.FileInfo, error) {
return info, nil
}
// WalkDirEntry wraps os.FileInfo with the file name for directory listings.
type WalkDirEntry struct {
os.FileInfo
entryName string
}
func (e *WalkDirEntry) Name() string {
return e.entryName
}
// NewWalkDirEntry creates a new WalkDirEntry with an overridden name.
func NewWalkDirEntry(info os.FileInfo, name string) *WalkDirEntry {
return &WalkDirEntry{FileInfo: info, entryName: name}
}
// DialTimeout is the timeout for establishing SSH connections.
const DialTimeout = 15 * time.Second
// DefaultPort is the default SSH port.
const DefaultPort = "22"
// ResolveAddr returns the SSH address for the given host, applying the default port if needed.
func ResolveAddr(hostname, port string) string {
host := strings.TrimSpace(hostname)
if port == "" || port == "0" {
port = DefaultPort
}
return net.JoinHostPort(host, port)
}

View file

@ -0,0 +1,209 @@
package remote
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// ParseSSHConfig parses ~/.ssh/config and returns a list of SSH hosts.
// It handles the most common SSH config directives: Host, HostName, Port, User, IdentityFile.
func ParseSSHConfig() []SSHHost {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configPath := filepath.Join(home, ".ssh", "config")
return parseSSHConfigFile(configPath)
}
func parseSSHConfigFile(path string) []SSHHost {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var hosts []SSHHost
var current *SSHHost
var currentNames []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Remove inline comments (everything after # that's not in quotes)
if idx := strings.Index(line, "#"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
if line == "" {
continue
}
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
keyword := strings.ToLower(parts[0])
value := strings.Join(parts[1:], " ")
switch keyword {
case "host":
// Save previous host block
if current != nil && len(currentNames) > 0 {
for _, name := range currentNames {
if !isWildcardPattern(name) {
host := *current
host.Name = name
hosts = append(hosts, host)
}
}
}
// Start new host block
current = &SSHHost{
Port: "22",
FromSSHConfig: true,
}
currentNames = strings.Fields(value)
case "hostname":
if current != nil {
current.HostName = value
}
case "port":
if current != nil {
current.Port = value
}
case "user":
if current != nil {
current.User = value
}
case "identityfile":
if current != nil {
// Handle ~ expansion and relative paths
resolved := resolveIdentityPath(value)
if resolved != "" {
current.IdentityFile = resolved
}
}
}
}
// Save last host block
if current != nil && len(currentNames) > 0 {
for _, name := range currentNames {
if !isWildcardPattern(name) {
host := *current
host.Name = name
hosts = append(hosts, host)
}
}
}
return hosts
}
// isWildcardPattern returns true if the pattern contains wildcard characters.
func isWildcardPattern(pattern string) bool {
return strings.ContainsAny(pattern, "*?")
}
// resolveIdentityPath resolves a path from SSH config (handles ~ and relative paths).
func resolveIdentityPath(path string) string {
if path == "" {
return ""
}
// Handle ~/ or $HOME/
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return path
}
path = filepath.Join(home, path[2:])
}
// Handle relative paths (relative to ~/.ssh/)
if !filepath.IsAbs(path) {
home, err := os.UserHomeDir()
if err != nil {
return path
}
path = filepath.Join(home, ".ssh", path)
}
return filepath.Clean(path)
}
// SSHConfigPath returns the path to the user's SSH config file.
func SSHConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh", "config")
}
// HostsFilePath returns the path to the custom hosts data file.
func HostsFilePath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "vcom", "hosts.dat")
}
// GetSSHDir returns the path to the .ssh directory.
func GetSSHDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh")
}
// KnownHostsPath returns the path to known_hosts.
func KnownHostsPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh", "known_hosts")
}
// ConfigFileExists checks if the SSH config file exists.
func ConfigFileExists() bool {
path := SSHConfigPath()
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// ValidateHost checks if a host entry has the minimum required fields.
func ValidateHost(host SSHHost) error {
if strings.TrimSpace(host.Name) == "" {
return fmt.Errorf("host name is required")
}
if strings.TrimSpace(host.HostName) == "" {
return fmt.Errorf("hostname/address is required")
}
if strings.TrimSpace(host.User) == "" {
return fmt.Errorf("username is required")
}
return nil
}

View file

@ -0,0 +1,311 @@
package remote
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// SSHHost represents a single SSH host configuration.
type SSHHost struct {
// Name is the host alias (e.g. "myserver").
Name string `json:"name"`
// HostName is the actual hostname or IP address.
HostName string `json:"hostname"`
// Port is the SSH port (default 22).
Port string `json:"port,omitempty"`
// User is the SSH username.
User string `json:"user,omitempty"`
// IdentityFile is the path to the private key file (for key-based auth).
IdentityFile string `json:"identity_file,omitempty"`
// Password is stored encrypted (for password-based auth, user-added hosts).
Password string `json:"password,omitempty"`
// FromSSHConfig indicates this host came from ~/.ssh/config.
FromSSHConfig bool `json:"from_ssh_config"`
}
// DisplayName returns the host display name.
func (h SSHHost) DisplayName() string {
addr := h.HostName
if h.Port != "" && h.Port != "22" {
addr = fmt.Sprintf("%s:%s", addr, h.Port)
}
if h.User != "" {
return fmt.Sprintf("%s (%s@%s)", h.Name, h.User, addr)
}
return fmt.Sprintf("%s (%s)", h.Name, addr)
}
// Addr returns the SSH address string (host:port).
func (h SSHHost) Addr() string {
if h.Port == "" || h.Port == "22" {
return h.HostName + ":22"
}
return h.HostName + ":" + h.Port
}
// SameAs returns true if two hosts point to the same server.
func (h SSHHost) SameAs(other SSHHost) bool {
return h.HostName == other.HostName &&
(h.Port == other.Port || (h.Port == "" && other.Port == "22") || (h.Port == "22" && other.Port == ""))
}
// HostStore manages SSH hosts from both ~/.ssh/config and user-added hosts.
type HostStore struct {
customHosts []SSHHost
configPath string
cipherKey []byte
}
// NewHostStore creates a new HostStore.
func NewHostStore() (*HostStore, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("home dir: %w", err)
}
store := &HostStore{
configPath: filepath.Join(home, ".config", "vcom", "hosts.dat"),
}
// Load or create encryption key
keyPath := filepath.Join(home, ".config", "vcom", ".hosts-key")
store.cipherKey, err = loadOrCreateKey(keyPath)
if err != nil {
return nil, fmt.Errorf("encryption key: %w", err)
}
// Load custom hosts
if err := store.load(); err != nil {
// Ignore load errors for missing file
if !os.IsNotExist(err) {
return nil, err
}
}
return store, nil
}
// loadOrCreateKey loads an existing AES key or creates a new one.
func loadOrCreateKey(path string) ([]byte, error) {
if data, err := os.ReadFile(path); err == nil {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
if err != nil {
return nil, err
}
if len(key) == 32 {
return key, nil
}
}
// Generate new 32-byte key for AES-256
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
return nil, fmt.Errorf("write key: %w", err)
}
return key, nil
}
type storedHosts struct {
Hosts []storedHost `json:"hosts"`
}
type storedHost struct {
Name string `json:"name"`
HostName string `json:"hostname"`
Port string `json:"port,omitempty"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"` // encrypted
IdentityFile string `json:"identity_file,omitempty"`
}
func (s *HostStore) load() error {
data, err := os.ReadFile(s.configPath)
if err != nil {
return err
}
// Decrypt
decrypted, err := decrypt(data, s.cipherKey)
if err != nil {
return fmt.Errorf("decrypt hosts: %w", err)
}
var stored storedHosts
if err := json.Unmarshal(decrypted, &stored); err != nil {
return fmt.Errorf("parse hosts: %w", err)
}
s.customHosts = make([]SSHHost, len(stored.Hosts))
for i, h := range stored.Hosts {
password := ""
if h.Password != "" {
pwd, err := decrypt([]byte(h.Password), s.cipherKey)
if err == nil {
password = string(pwd)
}
}
s.customHosts[i] = SSHHost{
Name: h.Name,
HostName: h.HostName,
Port: h.Port,
User: h.User,
Password: password,
IdentityFile: h.IdentityFile,
FromSSHConfig: false,
}
}
return nil
}
// Save persists custom hosts to disk (encrypted).
func (s *HostStore) Save() error {
stored := storedHosts{
Hosts: make([]storedHost, len(s.customHosts)),
}
for i, h := range s.customHosts {
password := ""
if h.Password != "" {
enc, err := encrypt([]byte(h.Password), s.cipherKey)
if err == nil {
password = string(enc)
}
}
stored.Hosts[i] = storedHost{
Name: h.Name,
HostName: h.HostName,
Port: h.Port,
User: h.User,
Password: password,
IdentityFile: h.IdentityFile,
}
}
data, err := json.Marshal(stored)
if err != nil {
return fmt.Errorf("marshal hosts: %w", err)
}
encrypted, err := encrypt(data, s.cipherKey)
if err != nil {
return fmt.Errorf("encrypt hosts: %w", err)
}
dir := filepath.Dir(s.configPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
return os.WriteFile(s.configPath, encrypted, 0o600)
}
// AddHost adds a custom host and saves.
func (s *HostStore) AddHost(host SSHHost) error {
host.FromSSHConfig = false
s.customHosts = append(s.customHosts, host)
return s.Save()
}
// RemoveHost removes a custom host by name.
func (s *HostStore) RemoveHost(name string) error {
for i, h := range s.customHosts {
if h.Name == name {
s.customHosts = append(s.customHosts[:i], s.customHosts[i+1:]...)
return s.Save()
}
}
return fmt.Errorf("host %q not found", name)
}
// AllHosts returns all hosts (from ssh config + custom).
func (s *HostStore) AllHosts() []SSHHost {
sshConfigHosts := ParseSSHConfig()
result := make([]SSHHost, 0, len(sshConfigHosts)+len(s.customHosts))
// Build a set of names from ssh config to avoid duplicates
seen := make(map[string]bool)
for _, h := range sshConfigHosts {
lower := strings.ToLower(h.Name)
seen[lower] = true
result = append(result, h)
}
for _, h := range s.customHosts {
lower := strings.ToLower(h.Name)
if !seen[lower] {
result = append(result, h)
seen[lower] = true
}
}
return result
}
// FindByName looks up a host by its Name field. Returns nil if not found.
func (s *HostStore) FindByName(name string) *SSHHost {
all := s.AllHosts()
for i := range all {
if strings.EqualFold(all[i].Name, name) {
return &all[i]
}
}
return nil
}
func encrypt(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func decrypt(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}

View file

@ -0,0 +1,275 @@
package vfs
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
type ListOptions struct {
ShowHidden bool
DirsFirst bool
SortBy string
SortReverse bool
}
func ListDir(path string, options ListOptions) ([]Entry, error) {
resolvedPath := path
if resolvedPath == "" {
resolvedPath = "."
}
dirEntries, err := os.ReadDir(resolvedPath)
if err != nil {
return nil, fmt.Errorf("read dir %s: %w", resolvedPath, err)
}
entries := make([]Entry, 0, len(dirEntries)+1)
if parent := filepath.Dir(resolvedPath); parent != resolvedPath {
entries = append(entries, Entry{
Name: "..",
Path: parent,
IsDir: true,
IsParent: true,
})
}
for _, dirEntry := range dirEntries {
name := dirEntry.Name()
hidden := strings.HasPrefix(name, ".")
if hidden && !options.ShowHidden {
continue
}
fullPath := filepath.Join(resolvedPath, name)
info, err := dirEntry.Info()
if err != nil {
continue
}
entry := Entry{
Name: name,
Path: fullPath,
Extension: ext(name),
Mode: info.Mode(),
Size: info.Size(),
ModifiedAt: info.ModTime(),
IsDir: info.IsDir(),
IsHidden: hidden,
}
if createdAt, ok := statBirthTime(fullPath); ok {
entry.CreatedAt = createdAt
entry.CreatedKnown = true
}
entries = append(entries, entry)
}
sort.SliceStable(entries, func(i, j int) bool {
left, right := entries[i], entries[j]
if left.IsParent != right.IsParent {
return left.IsParent
}
if options.DirsFirst && left.IsDir != right.IsDir {
return left.IsDir
}
comparison := compareEntries(left, right, options.SortBy)
if options.SortReverse {
return comparison > 0
}
return comparison < 0
})
return entries, nil
}
func compareEntries(left Entry, right Entry, sortBy string) int {
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "size":
if left.Size != right.Size {
return cmpInt64(left.Size, right.Size)
}
case "modified":
if !left.ModifiedAt.Equal(right.ModifiedAt) {
return cmpTimeDesc(left.ModifiedAt, right.ModifiedAt)
}
case "created":
if left.CreatedKnown != right.CreatedKnown {
if left.CreatedKnown {
return -1
}
return 1
}
if !left.CreatedAt.Equal(right.CreatedAt) {
return cmpTimeDesc(left.CreatedAt, right.CreatedAt)
}
case "extension":
if left.Extension != right.Extension {
return strings.Compare(left.Extension, right.Extension)
}
}
return strings.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name))
}
func cmpInt64(left int64, right int64) int {
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
func cmpTimeDesc(left time.Time, right time.Time) int {
switch {
case left.Equal(right):
return 0
case left.After(right):
return -1
default:
return 1
}
}
func statBirthTime(path string) (time.Time, bool) {
var stx unix.Statx_t
if err := unix.Statx(unix.AT_FDCWD, path, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_BTIME, &stx); err == nil {
if stx.Mask&unix.STATX_BTIME != 0 {
return time.Unix(int64(stx.Btime.Sec), int64(stx.Btime.Nsec)), true
}
}
info, err := os.Lstat(path)
if err != nil {
return time.Time{}, false
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return time.Time{}, false
}
seconds := int64(stat.Ctim.Sec)
nanos := int64(stat.Ctim.Nsec)
if seconds == 0 && nanos == 0 {
return time.Time{}, false
}
return time.Unix(seconds, nanos), true
}
func DirectorySize(path string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(current string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if err != nil {
return 0, err
}
return total, nil
}
func FindSelected(entries []Entry, key string) int {
for idx, entry := range entries {
if entry.MatchKey() == key {
return idx
}
}
return 0
}
func HumanSize(size int64) string {
if size < 0 {
return "?"
}
if size < 1024 {
return fmt.Sprintf("%d B", size)
}
units := []string{"KB", "MB", "GB", "TB"}
value := float64(size)
for _, unit := range units {
value /= 1024
if value < 1024 {
return fmt.Sprintf("%.1f %s", value, unit)
}
}
return fmt.Sprintf("%.1f PB", value/1024)
}
func ShortTime(t time.Time) string {
if t.IsZero() {
return "n/a"
}
return t.Format("2006-01-02 15:04")
}
func CompactTime(t time.Time) string {
if t.IsZero() {
return "n/a"
}
return t.Format("01-02 15:04")
}
func Permissions(mode fs.FileMode) string {
return mode.String()
}
func IsBinarySample(data []byte) bool {
if len(data) == 0 {
return false
}
var controls int
for _, b := range data {
if b == 0 {
return true
}
if b < 9 || (b > 13 && b < 32) {
controls++
}
}
return controls > len(data)/10
}
func SafeBase(path string) string {
base := filepath.Base(path)
if base == "." || base == string(filepath.Separator) {
return path
}
return base
}
func JoinPath(path string, name string) string {
return filepath.Join(path, name)
}
func ErrOverwrite(path string) error {
return fmt.Errorf("target already exists: %s", path)
}
func IsNotExist(err error) bool {
return errors.Is(err, os.ErrNotExist)
}