From 5634a0aa28f3b5d3c704ec9dcd4a14c57c9b8e01 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 14 Sep 2026 11:01:32 +0200 Subject: [PATCH] posibility to connect to remove ssh servers --- README.md | 36 ++ config.example.toml | 1 + go.mod | 8 +- go.sum | 26 +- internal/actions/actions_test.go | 507 +++++++++++++++++++ internal/actions/copy.go | 173 ++++--- internal/actions/copy_test.go | 4 +- internal/actions/crossfs_test.go | 255 ++++++++++ internal/actions/delete.go | 16 +- internal/actions/mkdir.go | 21 +- internal/actions/move.go | 96 ++-- internal/app/app.go | 310 ++++++++++-- internal/app/app_test.go | 599 +++++++++++++++++++++++ internal/app/commands.go | 55 ++- internal/app/connect.go | 212 ++++++++ internal/app/keymap.go | 66 +-- internal/app/remotefile.go | 208 ++++++++ internal/bookmark/bookmark.go | 8 +- internal/config/config.go | 4 + internal/remote/actions_sftp_test.go | 351 +++++++++++++ internal/remote/agent_unix.go | 34 ++ internal/remote/agent_windows.go | 29 ++ internal/remote/authchain_test.go | 113 +++++ internal/remote/conn.go | 434 ++++++++++++++++ internal/remote/hostkey.go | 143 ++++++ internal/remote/remote_test.go | 339 +++++++++++++ internal/remote/sshconfig.go | 78 +++ internal/remote/sshconfig_test.go | 142 ++++++ internal/remote/store.go | 178 +++++++ internal/remote/store_test.go | 141 ++++++ internal/remote/testserver/testserver.go | 242 +++++++++ internal/ui/bookmarks/bookmarks.go | 16 +- internal/ui/dialog/dialog.go | 117 ++++- internal/ui/dialog/dialog_test.go | 118 +++++ internal/ui/fuzzy/fuzzy.go | 24 +- internal/ui/help/help.go | 1 + internal/ui/menubar/menubar.go | 7 +- internal/ui/menubar/menubar_test.go | 73 +++ internal/ui/panel/column.go | 2 +- internal/ui/panel/panel.go | 239 ++++----- internal/ui/panel/panel_view.go | 14 +- internal/ui/quickview/quickview.go | 77 ++- internal/ui/quickview/quickview_test.go | 95 +++- internal/ui/servers/servers.go | 525 ++++++++++++++++++++ internal/ui/servers/servers_test.go | 241 +++++++++ internal/ui/theme/loader.go | 10 +- internal/vfs/local/local.go | 19 +- internal/vfs/location.go | 194 ++++++++ internal/vfs/location_test.go | 190 +++++++ internal/vfs/memfs/memfs.go | 318 ++++++++++++ internal/vfs/sftpfs/sftpfs.go | 177 +++++++ internal/vfs/stub_test.go | 10 + internal/vfs/vfs.go | 18 + internal/vfs/walk.go | 58 +++ 54 files changed, 6944 insertions(+), 428 deletions(-) create mode 100644 internal/actions/actions_test.go create mode 100644 internal/actions/crossfs_test.go create mode 100644 internal/app/app_test.go create mode 100644 internal/app/connect.go create mode 100644 internal/app/remotefile.go create mode 100644 internal/remote/actions_sftp_test.go create mode 100644 internal/remote/agent_unix.go create mode 100644 internal/remote/agent_windows.go create mode 100644 internal/remote/authchain_test.go create mode 100644 internal/remote/conn.go create mode 100644 internal/remote/hostkey.go create mode 100644 internal/remote/remote_test.go create mode 100644 internal/remote/sshconfig.go create mode 100644 internal/remote/sshconfig_test.go create mode 100644 internal/remote/store.go create mode 100644 internal/remote/store_test.go create mode 100644 internal/remote/testserver/testserver.go create mode 100644 internal/ui/dialog/dialog_test.go create mode 100644 internal/ui/menubar/menubar_test.go create mode 100644 internal/ui/servers/servers.go create mode 100644 internal/ui/servers/servers_test.go create mode 100644 internal/vfs/location.go create mode 100644 internal/vfs/location_test.go create mode 100644 internal/vfs/memfs/memfs.go create mode 100644 internal/vfs/sftpfs/sftpfs.go create mode 100644 internal/vfs/stub_test.go create mode 100644 internal/vfs/walk.go diff --git a/README.md b/README.md index e4e7536..4a9cd6d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Try online: 0 { if _, werr := dstFile.Write(buf[:n]); werr != nil { - return fmt.Errorf("write %s: %w", dst, werr) + return fmt.Errorf("write %s: %w", dst.Path, werr) } p.FileDoneBytes += int64(n) p.DoneBytes += int64(n) @@ -132,10 +174,24 @@ func copyFile(ctx context.Context, src, dst string, info fs.FileInfo, p *Progres break } if rerr != nil { - return fmt.Errorf("read %s: %w", src, rerr) + return fmt.Errorf("read %s: %w", src.Path, rerr) } } + // Over SFTP the write only completes when the handle closes. + if cerr := dstFile.Close(); cerr != nil { + return fmt.Errorf("close %s: %w", dst.Path, cerr) + } + closed = true + + // SFTP servers reject a rename over an existing path, unlike POSIX. + _ = w.Remove(dst.Path) + if err := w.Rename(partPath, dst.Path); err != nil { + return fmt.Errorf("finalise %s: %w", dst.Path, err) + } + completed = true + applyMode(w, dst, info.Mode()) + p.DoneFiles++ if progressFn != nil { progressFn(*p) @@ -144,28 +200,33 @@ func copyFile(ctx context.Context, src, dst string, info fs.FileInfo, p *Progres return nil } -func copyDir(ctx context.Context, src, dst string, p *Progress, progressFn func(Progress)) error { +func copyDir(ctx context.Context, src, dst vfs.FileRef, p *Progress, progressFn func(Progress)) error { if err := ctx.Err(); err != nil { return ErrCancelled } - srcInfo, err := os.Stat(src) + srcInfo, err := src.FS.Stat(src.Path) if err != nil { return err } - if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { - return fmt.Errorf("mkdir %s: %w", dst, err) + w, err := writableAt(dst) + if err != nil { + return err } + if err := w.MkdirAll(dst.Path, srcInfo.Mode().Perm()); err != nil { + return fmt.Errorf("mkdir %s: %w", dst.Path, err) + } + applyMode(w, dst, srcInfo.Mode()) - entries, err := os.ReadDir(src) + entries, err := src.FS.ReadDir(src.Path) if err != nil { - return fmt.Errorf("readdir %s: %w", src, err) + return fmt.Errorf("readdir %s: %w", src.Path, err) } for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - dstPath := filepath.Join(dst, entry.Name()) + srcChild := src.Join(entry.Name()) + dstChild := dst.Join(entry.Name()) info, err := entry.Info() if err != nil { @@ -173,11 +234,11 @@ func copyDir(ctx context.Context, src, dst string, p *Progress, progressFn func( } if entry.IsDir() { - if err := copyDir(ctx, srcPath, dstPath, p, progressFn); err != nil { + if err := copyDir(ctx, srcChild, dstChild, p, progressFn); err != nil { return err } } else { - if err := copyFile(ctx, srcPath, dstPath, info, p, progressFn); err != nil { + if err := copyFile(ctx, srcChild, dstChild, info, p, progressFn); err != nil { return err } } @@ -186,21 +247,23 @@ func copyDir(ctx context.Context, src, dst string, p *Progress, progressFn func( return nil } -func countFilesAndBytes(paths []string) (int, int64) { +// countFilesAndBytes skips remote sources: a recursive stat over SFTP costs +// seconds before the first byte moves. A zero total makes the dialog show +// files completed instead of a percentage. +func countFilesAndBytes(refs []vfs.FileRef) (int, int64) { var files int var bytes int64 - for _, path := range paths { - _ = filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - files++ - if info, err := d.Info(); err == nil { - bytes += info.Size() - } - return nil - }) + for _, ref := range refs { + if ref.Kind == vfs.KindSSH { + return 0, 0 + } + } + + for _, ref := range refs { + f, b := vfs.CountFilesAndBytes(ref) + files += f + bytes += b } return files, bytes diff --git a/internal/actions/copy_test.go b/internal/actions/copy_test.go index f8f6583..85be098 100644 --- a/internal/actions/copy_test.go +++ b/internal/actions/copy_test.go @@ -31,7 +31,7 @@ func TestCopyReportsProgress(t *testing.T) { } } - if err := Copy(context.Background(), []string{src}, dst, progressFn); err != nil { + if err := Copy(context.Background(), refs(src), ref(dst), progressFn); err != nil { t.Fatalf("copy: %v", err) } if lastBytes != 2*(1<<16) { @@ -54,7 +54,7 @@ func TestCopyCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel before starting - err := Copy(ctx, []string{src}, dst, nil) + err := Copy(ctx, refs(src), ref(dst), nil) if !errors.Is(err, ErrCancelled) { t.Fatalf("want ErrCancelled, got %v", err) } diff --git a/internal/actions/crossfs_test.go b/internal/actions/crossfs_test.go new file mode 100644 index 0000000..e6eecb9 --- /dev/null +++ b/internal/actions/crossfs_test.go @@ -0,0 +1,255 @@ +package actions + +import ( + "context" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "testing" + + "github.com/kooler/MiddayCommander/internal/vfs" + "github.com/kooler/MiddayCommander/internal/vfs/memfs" +) + +// Operations whose two sides are different filesystems. memfs stands in for a +// remote backend, so no network is needed. + +func memRef(f *memfs.FS, p string) vfs.FileRef { + return vfs.FileRef{FS: f, Path: p, Kind: vfs.KindSSH} +} + +func seedMem(t *testing.T, f *memfs.FS, files map[string]string) { + t.Helper() + for name, content := range files { + if dir := path.Dir(name); dir != "." { + if err := f.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + w, err := f.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(w.(io.Writer), content); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + } +} + +func readMem(t *testing.T, f *memfs.FS, p string) string { + t.Helper() + file, err := f.Open(p) + if err != nil { + t.Fatalf("open %s: %v", p, err) + } + defer file.Close() + b, err := io.ReadAll(file) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func memTree(t *testing.T, f *memfs.FS, root string) map[string]string { + t.Helper() + got := map[string]string{} + err := vfs.WalkDir(memRef(f, root), func(r vfs.FileRef, d fs.DirEntry) error { + if r.Path == root { + return nil + } + if d.IsDir() { + got[r.Path+"/"] = "" + } else { + got[r.Path] = readMem(t, f, r.Path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return got +} + +func TestCopyLocalToRemote(t *testing.T) { + src := t.TempDir() + tree(t, src, map[string]string{ + "payload/a.txt": "alpha", + "payload/sub/b.txt": "bravo", + }) + + remote := memfs.New() + if err := remote.MkdirAll("dest", 0o755); err != nil { + t.Fatal(err) + } + + err := Copy(context.Background(), + refs(filepath.Join(src, "payload")), + memRef(remote, "dest"), nil) + if err != nil { + t.Fatalf("copy: %v", err) + } + + want := map[string]string{ + "/dest/payload/": "", + "/dest/payload/a.txt": "alpha", + "/dest/payload/sub/": "", + "/dest/payload/sub/b.txt": "bravo", + } + got := memTree(t, remote, "/dest") + for k, v := range want { + if got[k] != v { + t.Errorf("%q: want %q, got %q", k, v, got[k]) + } + } + if len(got) != len(want) { + t.Errorf("want %d entries, got %d: %v", len(want), len(got), got) + } +} + +func TestCopyRemoteToLocal(t *testing.T) { + remote := memfs.New() + seedMem(t, remote, map[string]string{ + "src/a.txt": "alpha", + "src/sub/b.txt": "bravo", + }) + + dst := t.TempDir() + if err := Copy(context.Background(), []vfs.FileRef{memRef(remote, "src")}, ref(dst), nil); err != nil { + t.Fatalf("copy: %v", err) + } + + wantTree(t, dst, map[string]string{ + "src/": "", + "src/a.txt": "alpha", + "src/sub/": "", + "src/sub/b.txt": "bravo", + }) +} + +func TestCopyRemoteSkipsPreCount(t *testing.T) { + remote := memfs.New() + seedMem(t, remote, map[string]string{"src/a.txt": "alpha"}) + + dst := t.TempDir() + var final Progress + err := Copy(context.Background(), []vfs.FileRef{memRef(remote, "src")}, ref(dst), + func(p Progress) { final = p }) + if err != nil { + t.Fatalf("copy: %v", err) + } + + // Counting a remote tree up front costs a full recursive stat, so the + // totals stay zero and the dialog shows files done. + if final.TotalFiles != 0 || final.TotalBytes != 0 { + t.Errorf("want zero pre-count totals for a remote source, got %d files / %d bytes", + final.TotalFiles, final.TotalBytes) + } + if final.DoneFiles != 1 { + t.Errorf("want DoneFiles=1, got %d", final.DoneFiles) + } +} + +func TestMoveAcrossFilesystemsCopiesThenDeletes(t *testing.T) { + src := t.TempDir() + tree(t, src, map[string]string{"box/a.txt": "alpha"}) + + remote := memfs.New() + if err := remote.MkdirAll("dest", 0o755); err != nil { + t.Fatal(err) + } + + err := Move(context.Background(), + refs(filepath.Join(src, "box")), + memRef(remote, "dest"), nil) + if err != nil { + t.Fatalf("move: %v", err) + } + + if got := readMem(t, remote, "dest/box/a.txt"); got != "alpha" { + t.Errorf("want %q on the destination, got %q", "alpha", got) + } + if _, err := os.Stat(filepath.Join(src, "box")); !os.IsNotExist(err) { + t.Errorf("want source removed after a cross-filesystem move, stat gave %v", err) + } +} + +func TestMoveWithinRemoteUsesRename(t *testing.T) { + remote := memfs.New() + seedMem(t, remote, map[string]string{"a/f.txt": "body"}) + if err := remote.MkdirAll("b", 0o755); err != nil { + t.Fatal(err) + } + + err := Move(context.Background(), + []vfs.FileRef{memRef(remote, "a")}, memRef(remote, "b"), nil) + if err != nil { + t.Fatalf("move: %v", err) + } + + if got := readMem(t, remote, "b/a/f.txt"); got != "body" { + t.Errorf("want %q after rename, got %q", "body", got) + } + if _, err := remote.Stat("a"); err == nil { + t.Error("want the original path gone after a rename") + } +} + +func TestDeleteAndMkdirOnRemote(t *testing.T) { + remote := memfs.New() + seedMem(t, remote, map[string]string{"doomed/a.txt": "x", "keep.txt": "y"}) + + if err := Mkdir(memRef(remote, "fresh")); err != nil { + t.Fatalf("mkdir: %v", err) + } + if info, err := remote.Stat("fresh"); err != nil || !info.IsDir() { + t.Fatalf("want a new directory, got %v (%v)", info, err) + } + + if err := Delete(context.Background(), []vfs.FileRef{memRef(remote, "doomed")}, nil); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := remote.Stat("doomed"); err == nil { + t.Error("want the deleted tree gone") + } + if _, err := remote.Stat("keep.txt"); err != nil { + t.Errorf("delete should not have touched keep.txt: %v", err) + } +} + +func TestRenameOnRemote(t *testing.T) { + remote := memfs.New() + seedMem(t, remote, map[string]string{"dir/before.txt": "body"}) + + if err := Rename(memRef(remote, "dir/before.txt"), "after.txt"); err != nil { + t.Fatalf("rename: %v", err) + } + + if got := readMem(t, remote, "dir/after.txt"); got != "body" { + t.Errorf("want %q at the new name, got %q", "body", got) + } +} + +func TestCopyToReadOnlyFilesystemFails(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "f"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + ro := vfs.FileRef{FS: readOnlyFS{memfs.New()}, Path: "/", Kind: vfs.KindArchive} + err := Copy(context.Background(), refs(filepath.Join(src, "f")), ro, nil) + if err == nil { + t.Fatal("want an error when copying into a read-only filesystem") + } +} + +// readOnlyFS hides the write methods, as the archive backend does. +type readOnlyFS struct{ inner *memfs.FS } + +func (r readOnlyFS) Open(name string) (fs.File, error) { return r.inner.Open(name) } +func (r readOnlyFS) ReadDir(name string) ([]fs.DirEntry, error) { return r.inner.ReadDir(name) } +func (r readOnlyFS) Stat(name string) (fs.FileInfo, error) { return r.inner.Stat(name) } diff --git a/internal/actions/delete.go b/internal/actions/delete.go index 4208be2..f71f5a4 100644 --- a/internal/actions/delete.go +++ b/internal/actions/delete.go @@ -2,27 +2,27 @@ package actions import ( "context" - "os" - "path/filepath" + + "github.com/kooler/MiddayCommander/internal/vfs" ) -// Delete removes all specified paths. -func Delete(ctx context.Context, paths []string, progressFn func(Progress)) error { +// Delete removes each ref and everything beneath it. +func Delete(ctx context.Context, refs []vfs.FileRef, progressFn func(Progress)) error { p := Progress{ Op: OpDelete, - TotalFiles: len(paths), + TotalFiles: len(refs), } - for _, path := range paths { + for _, ref := range refs { if err := ctx.Err(); err != nil { return ErrCancelled } - p.Current = filepath.Base(path) + p.Current = ref.Base() if progressFn != nil { progressFn(p) } - if err := os.RemoveAll(path); err != nil { + if err := removeRef(ref); err != nil { return err } diff --git a/internal/actions/mkdir.go b/internal/actions/mkdir.go index 1d00a1e..35d3f58 100644 --- a/internal/actions/mkdir.go +++ b/internal/actions/mkdir.go @@ -1,8 +1,21 @@ package actions -import "os" +import ( + "io/fs" -// Mkdir creates a directory at the given path. -func Mkdir(path string) error { - return os.Mkdir(path, 0755) + "github.com/kooler/MiddayCommander/internal/vfs" +) + +const dirPerm fs.FileMode = 0o755 + +func Mkdir(ref vfs.FileRef) error { + w, err := writableAt(ref) + if err != nil { + return err + } + if err := w.Mkdir(ref.Path, dirPerm); err != nil { + return err + } + applyMode(w, ref, dirPerm) + return nil } diff --git a/internal/actions/move.go b/internal/actions/move.go index 18f89fc..cc4454e 100644 --- a/internal/actions/move.go +++ b/internal/actions/move.go @@ -3,13 +3,13 @@ package actions import ( "context" "fmt" - "os" - "path/filepath" + + "github.com/kooler/MiddayCommander/internal/vfs" ) -// Move moves sources to destDir. Tries os.Rename first (fast, same device), -// falls back to copy+delete for cross-device moves. -func Move(ctx context.Context, sources []string, destDir string, progressFn func(Progress)) error { +// Move moves sources into destDir, renaming when both sides share a +// filesystem and falling back to copy-and-delete when they do not. +func Move(ctx context.Context, sources []vfs.FileRef, destDir vfs.FileRef, progressFn func(Progress)) error { // Precompute totals so the progress dialog has stable denominators even // when some sources are renamed and others fall back to copy. totalFiles, totalBytes := countFilesAndBytes(sources) @@ -24,14 +24,19 @@ func Move(ctx context.Context, sources []string, destDir string, progressFn func return ErrCancelled } - destPath := filepath.Join(destDir, filepath.Base(src)) + dst := destDir.Join(src.Base()) + + // Without this the rename fails on servers that implement it as a + // hard link, and the copy-and-delete fallback deletes the source. + if vfs.SamePath(src, dst) { + return fmt.Errorf("source and destination are the same: %s", src.Path) + } - // Try rename first (instant if same filesystem). - if err := os.Rename(src, destPath); err == nil { - files, bytes := countFilesAndBytes([]string{destPath}) + if tryRename(src, dst) == nil { + files, bytes := countFilesAndBytes([]vfs.FileRef{dst}) agg.DoneFiles += files agg.DoneBytes += bytes - agg.Current = filepath.Base(src) + agg.Current = src.Base() agg.FileTotalBytes = 0 agg.FileDoneBytes = 0 if progressFn != nil { @@ -40,9 +45,8 @@ func Move(ctx context.Context, sources []string, destDir string, progressFn func continue } - // Cross-device: copy then delete. Run the copy with a nested Progress - // but forward updates into the aggregate totals. - srcFiles, srcBytes := countFilesAndBytes([]string{src}) + // Forward the copy's progress into the aggregate totals. + srcFiles, srcBytes := countFilesAndBytes([]vfs.FileRef{src}) startDoneFiles := agg.DoneFiles startDoneBytes := agg.DoneBytes @@ -57,11 +61,11 @@ func Move(ctx context.Context, sources []string, destDir string, progressFn func } } - if err := Copy(ctx, []string{src}, destDir, forward); err != nil { - return fmt.Errorf("move (copy phase) %s: %w", src, err) + if err := Copy(ctx, []vfs.FileRef{src}, destDir, forward); err != nil { + return fmt.Errorf("move (copy phase) %s: %w", src.Path, err) } - if err := os.RemoveAll(src); err != nil { - return fmt.Errorf("move (delete phase) %s: %w", src, err) + if err := removeRef(src); err != nil { + return fmt.Errorf("move (delete phase) %s: %w", src.Path, err) } // Ensure aggregate reflects completion of this source even if Copy // finished without a final progress tick at 100%. @@ -74,37 +78,33 @@ func Move(ctx context.Context, sources []string, destDir string, progressFn func // MoveAs moves a single source to destPath (a full path, not a directory). // Used for single-item move where the user may have renamed the target. -// Tries os.Rename first (fast, same device), falls back to copy+delete for -// cross-device moves. -func MoveAs(ctx context.Context, source, destPath string, progressFn func(Progress)) error { +func MoveAs(ctx context.Context, source, destPath vfs.FileRef, progressFn func(Progress)) error { if err := ctx.Err(); err != nil { return ErrCancelled } - if absEq(source, destPath) { - return fmt.Errorf("source and destination are the same: %s", source) + if vfs.SamePath(source, destPath) { + return fmt.Errorf("source and destination are the same: %s", source.Path) } - totalFiles, totalBytes := countFilesAndBytes([]string{source}) + totalFiles, totalBytes := countFilesAndBytes([]vfs.FileRef{source}) agg := Progress{ Op: OpMove, TotalFiles: totalFiles, TotalBytes: totalBytes, } - // Try rename first (instant if same filesystem). - if err := os.Rename(source, destPath); err == nil { + if tryRename(source, destPath) == nil { agg.DoneFiles = totalFiles agg.DoneBytes = totalBytes - agg.Current = filepath.Base(destPath) + agg.Current = destPath.Base() if progressFn != nil { progressFn(agg) } return nil } - // Cross-device: copy then delete. Forward copy progress as a move op so - // the dialog keeps showing "Moving". + // Forward copy progress as a move so the dialog keeps showing "Moving". forward := func(p Progress) { agg.Current = p.Current agg.FileTotalBytes = p.FileTotalBytes @@ -116,17 +116,41 @@ func MoveAs(ctx context.Context, source, destPath string, progressFn func(Progre } } if err := CopyAs(ctx, source, destPath, forward); err != nil { - return fmt.Errorf("move (copy phase) %s: %w", source, err) + return fmt.Errorf("move (copy phase) %s: %w", source.Path, err) } - if err := os.RemoveAll(source); err != nil { - return fmt.Errorf("move (delete phase) %s: %w", source, err) + if err := removeRef(source); err != nil { + return fmt.Errorf("move (delete phase) %s: %w", source.Path, err) } return nil } -// Rename renames a single file or directory. -func Rename(oldPath, newName string) error { - dir := filepath.Dir(oldPath) - newPath := filepath.Join(dir, newName) - return os.Rename(oldPath, newPath) +// tryRename fails when the two refs are not on one filesystem, which tells +// the caller to fall back to copy-and-delete. +func tryRename(src, dst vfs.FileRef) error { + if !vfs.SameFS(src, dst) { + return fmt.Errorf("different filesystems") + } + w, err := writableAt(dst) + if err != nil { + return err + } + return w.Rename(src.Path, dst.Path) +} + +func removeRef(ref vfs.FileRef) error { + w, err := writableAt(ref) + if err != nil { + return err + } + return w.RemoveAll(ref.Path) +} + +// Rename renames an entry within its own directory. +func Rename(ref vfs.FileRef, newName string) error { + w, err := writableAt(ref) + if err != nil { + return err + } + target := ref.Parent().Join(newName) + return w.Rename(ref.Path, target.Path) } diff --git a/internal/app/app.go b/internal/app/app.go index b8a8c36..d776cd5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/kooler/MiddayCommander/internal/actions" "github.com/kooler/MiddayCommander/internal/bookmark" "github.com/kooler/MiddayCommander/internal/config" + "github.com/kooler/MiddayCommander/internal/remote" "github.com/kooler/MiddayCommander/internal/ui/bookmarks" "github.com/kooler/MiddayCommander/internal/ui/cmdexec" "github.com/kooler/MiddayCommander/internal/ui/copypath" @@ -26,8 +28,10 @@ import ( "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/panel" "github.com/kooler/MiddayCommander/internal/ui/quickview" + "github.com/kooler/MiddayCommander/internal/ui/servers" "github.com/kooler/MiddayCommander/internal/ui/theme" "github.com/kooler/MiddayCommander/internal/ui/themepicker" + "github.com/kooler/MiddayCommander/internal/vfs" "github.com/kooler/MiddayCommander/internal/vfs/local" ) @@ -41,17 +45,21 @@ const ( // Dialog tags identify which operation triggered the dialog. const ( - tagCopy = "copy" - tagCopyAs = "copyas" - tagMove = "move" - tagMoveAs = "moveas" - tagDelete = "delete" - tagMkdir = "mkdir" - tagRename = "rename" + tagCopy = "copy" + tagCopyAs = "copyas" + tagMove = "move" + tagMoveAs = "moveas" + tagDelete = "delete" + tagMkdir = "mkdir" + tagRename = "rename" tagGoTo = "goto" tagExecute = "execute" tagSelectGroup = "selectgroup" tagDeselectGroup = "deselectgroup" + tagConnect = "connect" + tagTrustHost = "trusthost" + tagPassphrase = "passphrase" + tagStage = "stage" ) // Model is the root application model. @@ -71,6 +79,7 @@ type Model struct { dialog *dialog.Model fuzzy *fuzzy.Model bookmarks *bookmarks.Model + servers *servers.Model help *help.Model themePicker *themepicker.Model cmdExec *cmdexec.Model @@ -88,9 +97,27 @@ type Model struct { // Bookmark store bookmarkStore *bookmark.Store + // Saved servers, live connections, and the panel each one serves. + serverStore *remote.Store + connRegistry *remote.Registry + panelConns map[FocusTarget]*remote.Conn + + // Kept while a host key or passphrase dialog is open, so the attempt can + // be retried with the answer. + pendingServer remote.Server + pendingRemotePath string + pendingSide FocusTarget + pendingCreds remote.Credentials + pendingFingerprint string + + // A remote file downloaded for $EDITOR or $PAGER, held until it is + // written back. + pendingStaged stagedFile + pendingStagedEdit bool + // Pending operation state (saved while dialog is open) - pendingSources []string - pendingDest string + pendingSources []vfs.FileRef + pendingDest vfs.FileRef pendingExecutePath string // In-flight file operation state @@ -140,6 +167,9 @@ func New(version string) Model { menuItems: menubar.DefaultItems(cfg), shiftMenuItems: menubar.ShiftItems(cfg), bookmarkStore: bookmark.LoadStore(), + serverStore: remote.LoadStore(), + connRegistry: remote.NewRegistry(), + panelConns: map[FocusTarget]*remote.Conn{}, } } @@ -178,14 +208,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.leftPanel.HandleDirLoaded(msg) m.rightPanel.HandleDirLoaded(msg) if m.quickview != nil && !m.quickFocus { - m.syncQuickView() + return m, m.syncQuickView() } return m, nil case panel.RestoreCursorMsg: m.activePanel().RestoreCursor(msg.Name) if m.quickview != nil && !m.quickFocus { - m.syncQuickView() + return m, m.syncQuickView() + } + return m, nil + + case quickview.FileLoadedMsg: + if m.quickview != nil { + m.quickview.HandleFileLoaded(msg) } return m, nil @@ -233,13 +269,59 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Bookmark messages case bookmarks.SelectMsg: m.bookmarks = nil + if srv, remotePath, ok := m.openServerTarget(msg.Path); ok { + return m.startConnect(srv, remotePath) + } m.activePanel().SetPath(msg.Path) + m.releaseUnusedConnections() return m, m.activePanel().LoadDir() case bookmarks.DismissMsg: m.bookmarks = nil return m, nil + case servers.ConnectMsg: + m.servers = nil + return m.startConnect(msg.Server, msg.Server.Dir) + + case servers.DismissMsg: + m.servers = nil + return m, nil + + case connectedMsg: + return m.handleConnected(msg) + + case stagedReadyMsg: + cancelled := m.dialog != nil && + m.dialog.Kind() == dialog.KindProgress && + m.dialog.CancelRequested() + m.dialog = nil + if m.opCancel != nil { + m.opCancel() + m.opCancel = nil + } + if msg.err != nil { + if errors.Is(msg.err, context.Canceled) { + return m, nil // the user pressed Esc + } + return m.showError("Download failed", msg.err) + } + if cancelled { + return m, discardStagedCmd(msg.staged) + } + m.pendingStaged = msg.staged + m.pendingStagedEdit = msg.edit + if msg.edit { + return m, editFileCmd(msg.staged.tmpPath) + } + return m, viewFileCmd(msg.staged.tmpPath) + + case stagedDoneMsg: + if msg.err != nil { + return m.showError("Upload failed", msg.err) + } + return m, m.refreshBothPanels() + case copypath.DismissMsg: m.copyPath = nil return m, nil @@ -287,9 +369,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // File action messages from panel (configurable behavior) case panel.OpenFileMsg: + // The path belongs to the server, so handing it to a local editor + // would open this machine's file of the same name. + if !m.activePanel().IsLocal() { + return m.startStaged(m.cfg.Behavior.EnterAction != "preview") + } return m, m.fileActionCmd(msg.Path, m.cfg.Behavior.EnterAction) case panel.ExecuteFileMsg: + if !m.activePanel().IsLocal() { + return m, nil // a remote file cannot be run on this machine + } if m.cfg.Behavior.ConfirmExecute == nil || *m.cfg.Behavior.ConfirmExecute { m.pendingExecutePath = msg.Path d := dialog.NewConfirm("Execute file", fmt.Sprintf("Run %s?", filepath.Base(msg.Path)), tagExecute) @@ -350,6 +440,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.activePanel().LoadDir() case externalDoneMsg: + // An edited remote file goes back; a viewed one is discarded. The slot + // is cleared here, so a second file staged while this one uploads is + // not mistaken for it. + if m.pendingStaged.tmpPath != "" { + staged := m.pendingStaged + edit := m.pendingStagedEdit + m.pendingStaged = stagedFile{} + m.pendingStagedEdit = false + if edit { + return m, uploadStagedCmd(staged) + } + return m, discardStagedCmd(staged) + } return m, m.refreshBothPanels() case dialog.Result: @@ -401,6 +504,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.servers != nil { + newSV, cmd := m.servers.Update(msg) + m.servers = &newSV + return m, cmd + } + // Copy path overlay gets priority when active if m.copyPath != nil { newCP, cmd := m.copyPath.Update(msg) @@ -485,7 +594,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "esc" { now := time.Now() if now.Sub(m.lastEsc) < 400*time.Millisecond { - return m, tea.Quit + return m, m.quit() } m.lastEsc = now return m, nil @@ -494,11 +603,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Global keybindings switch { case key.Matches(msg, m.keyMap.Quit): - return m, tea.Quit + return m, m.quit() case key.Matches(msg, m.keyMap.QuickView): - m.openQuickView() - return m, nil + return m, m.openQuickView() case key.Matches(msg, m.keyMap.TogglePanel): m.toggleFocus() @@ -509,12 +617,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil // swap disabled while previewing } m.leftPanel, m.rightPanel = m.rightPanel, m.leftPanel + m.swapPanelConns() m.recalcLayout() return m, nil case key.Matches(msg, m.keyMap.SameDir): + if !m.activePanel().IsLocal() { + return m, nil // a remote path means nothing to the other panel + } p := m.inactivePanelModel() p.SetPath(m.activePanel().Path()) + m.releaseUnusedConnections() return m, p.LoadDir() case key.Matches(msg, m.keyMap.Copy): @@ -544,6 +657,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.FuzzyFind): return m.startFuzzyFind() + case key.Matches(msg, m.keyMap.Servers): + return m.startServers() case key.Matches(msg, m.keyMap.Bookmarks): return m.startBookmarks() @@ -557,7 +672,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.startCmdExec() case key.Matches(msg, m.keyMap.Terminal): - return m, startTerminalCmd(m.activePanel().Path()) + // The shell runs here, so it needs a local directory. + return m, startTerminalCmd(m.activePanel().LocalPath()) case key.Matches(msg, m.keyMap.ToggleHidden): m.leftPanel.ToggleHidden() @@ -581,9 +697,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Delegate to active panel cmd := m.activePanel().Update(msg) + // ".." out of a server leaves its connection unused. + m.releaseUnusedConnections() // If quick view is following the cursor, re-load when the selection moved. if m.quickview != nil && !m.quickFocus { - m.syncQuickView() + cmd = tea.Batch(cmd, m.syncQuickView()) } return m, cmd } @@ -628,6 +746,10 @@ func (m Model) View() string { box := m.bookmarks.View(m.theme, m.width, m.height) bw, bh := m.bookmarks.BoxSize(m.width, m.height) screen = overlay.Place(screen, box, m.width, m.height, bw, bh) + } else if m.servers != nil { + box := m.servers.View(m.theme, m.width, m.height) + bw, bh := m.servers.BoxSize(m.width, m.height) + screen = overlay.Place(screen, box, m.width, m.height, bw, bh) } else if m.themePicker != nil { box := m.themePicker.View(m.theme, m.width, m.height) bw, bh := m.themePicker.BoxSize(m.width, m.height) @@ -654,7 +776,7 @@ func (m Model) dispatchKey(raw string) (tea.Model, tea.Cmd) { cfg := m.cfg.Keys switch { case contains(cfg.Quit, raw): - return m, tea.Quit + return m, m.quit() case contains(cfg.Copy, raw): return m.startCopy() case contains(cfg.Move, raw): @@ -675,6 +797,8 @@ func (m Model) dispatchKey(raw string) (tea.Model, tea.Cmd) { return m.startGoTo() case contains(cfg.Help, raw): return m.startHelp() + case contains(cfg.Servers, raw): + return m.startServers() case contains(cfg.Bookmarks, raw): return m.startBookmarks() case contains(cfg.FuzzyFind, raw): @@ -729,18 +853,18 @@ func (m Model) startCopy() (tea.Model, tea.Cmd) { if len(sources) == 0 { return m, nil } - dest := m.inactivePanel() + dest := m.inactiveRef() m.pendingSources = sources m.pendingDest = dest if len(sources) == 1 { - defaultPath := filepath.Join(dest, filepath.Base(sources[0])) + defaultPath := dest.Join(sources[0].Base()).Path d := dialog.NewInput("Copy", "Copy to:", defaultPath, tagCopyAs) m.dialog = &d return m, nil } - msg := fmt.Sprintf("Copy %d item(s) to %s?", len(sources), dest) + msg := fmt.Sprintf("Copy %d item(s) to %s?", len(sources), m.inactivePanel()) d := dialog.NewConfirm("Copy", msg, tagCopy) m.dialog = &d return m, nil @@ -751,18 +875,18 @@ func (m Model) startMove() (tea.Model, tea.Cmd) { if len(sources) == 0 { return m, nil } - dest := m.inactivePanel() + dest := m.inactiveRef() m.pendingSources = sources m.pendingDest = dest if len(sources) == 1 { - defaultPath := filepath.Join(dest, filepath.Base(sources[0])) + defaultPath := dest.Join(sources[0].Base()).Path d := dialog.NewInput("Move", "Move to:", defaultPath, tagMoveAs) m.dialog = &d return m, nil } - msg := fmt.Sprintf("Move %d item(s) to %s?", len(sources), dest) + msg := fmt.Sprintf("Move %d item(s) to %s?", len(sources), m.inactivePanel()) d := dialog.NewConfirm("Move", msg, tagMove) m.dialog = &d return m, nil @@ -815,6 +939,23 @@ func (m Model) startGoTo() (tea.Model, tea.Cmd) { return m, nil } +// resolveTarget reads Copy/Move dialog text as a ref on the destination +// filesystem. A relative path is taken from the destination directory. +func (m Model) resolveTarget(text string) vfs.FileRef { + dest := m.pendingDest + if dest.IsLocal() { + target := expandHome(text) + if filepath.IsAbs(target) { + return dest.WithPath(target) + } + return dest.Join(target) + } + if strings.HasPrefix(text, "/") { + return dest.WithPath(path.Clean(text)) + } + return dest.Join(text) +} + func (m Model) startHelp() (tea.Model, tea.Cmd) { h := help.New(m.cfg.Keys, m.version, m.width, m.height) m.help = &h @@ -822,11 +963,20 @@ func (m Model) startHelp() (tea.Model, tea.Cmd) { } func (m Model) startBookmarks() (tea.Model, tea.Cmd) { - bm := bookmarks.New(m.bookmarkStore, m.activePanel().Path(), m.width, m.height) + // A server directory is bookmarked in ssh:// form, so picking the + // bookmark later reconnects. + p := m.activePanel() + bm := bookmarks.New(m.bookmarkStore, p.Location().URLFor(p.Path()), m.width, m.height) m.bookmarks = &bm return m, nil } +func (m Model) startServers() (tea.Model, tea.Cmd) { + sv := servers.New(m.serverStore, m.width, m.height) + m.servers = &sv + return m, nil +} + func (m Model) startThemePicker() (tea.Model, tea.Cmd) { m.themeBeforePick = m.theme available := theme.ListAvailable() @@ -852,21 +1002,29 @@ func (m Model) startCopyPath() (tea.Model, tea.Cmd) { if e == nil || e.Name() == ".." { return m, nil } - if m.activePanel().InArchive() { - return m, nil // archive entries aren't real files + p := m.activePanel() + if p.Location().Kind == vfs.KindArchive { + return m, nil // archive entries have no addressable path } - cp := copypath.New(m.currentFilePath(), m.width, m.height) + cp := copypath.New(p.Location().URLFor(m.currentFilePath()), m.width, m.height) m.copyPath = &cp return m, nil } func (m Model) startCmdExec() (tea.Model, tea.Cmd) { + if !m.activePanel().IsLocal() { + return m, nil // the shell runs here, not on the server + } ce := cmdexec.New(m.activePanel().Path(), m.width, m.height) m.cmdExec = &ce return m, nil } func (m Model) startFuzzyFind() (tea.Model, tea.Cmd) { + if !m.activePanel().IsLocal() { + // A remote tree needs a bounded, cancellable search of its own. + return m, nil + } f := fuzzy.New(m.activePanel().Path(), m.width, m.height) m.fuzzy = &f return m, f.Init() @@ -877,6 +1035,9 @@ func (m Model) startView() (tea.Model, tea.Cmd) { if e == nil || e.IsDir() { return m, nil } + if !m.activePanel().IsLocal() { + return m.startStaged(false) + } path := m.currentFilePath() if m.cfg.Behavior.ViewMode == "system" { return m, openSystemDefaultCmd(path) @@ -889,9 +1050,30 @@ func (m Model) startEdit() (tea.Model, tea.Cmd) { if e == nil || e.IsDir() { return m, nil } + if !m.activePanel().IsLocal() { + return m.startStaged(true) + } return m, editFileCmd(m.currentFilePath()) } +// startStaged downloads the file before an external viewer or editor opens +// it. Archives are excluded: there is no writable side to put an edit back. +func (m Model) startStaged(edit bool) (tea.Model, tea.Cmd) { + p := m.activePanel() + if p.Location().Kind != vfs.KindSSH { + return m, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + m.opCancel = cancel + + d := dialog.NewProgress("Downloading", tagStage) + d.SetConnecting(p.CurrentRef().Base()) + m.dialog = &d + + return m, stageRemoteCmd(ctx, p.CurrentRef(), edit) +} + // startProgressOp opens a progress dialog, creates a cancellable context // and the channel used to stream progress updates back into Update. func (m *Model) startProgressOp(title string) (context.Context, chan actions.Progress) { @@ -930,10 +1112,7 @@ func (m Model) handleDialogResult(result dialog.Result) (tea.Model, tea.Cmd) { } case tagCopyAs: if result.Confirmed && strings.TrimSpace(result.Text) != "" && len(m.pendingSources) == 1 { - target := expandHome(result.Text) - if !filepath.IsAbs(target) { - target = filepath.Join(m.pendingDest, target) - } + target := m.resolveTarget(result.Text) ctx, ch := m.startProgressOp("Copying") return m, tea.Batch( copyAsCmd(ctx, ch, m.pendingSources[0], target), @@ -950,10 +1129,7 @@ func (m Model) handleDialogResult(result dialog.Result) (tea.Model, tea.Cmd) { } case tagMoveAs: if result.Confirmed && strings.TrimSpace(result.Text) != "" && len(m.pendingSources) == 1 { - target := expandHome(result.Text) - if !filepath.IsAbs(target) { - target = filepath.Join(m.pendingDest, target) - } + target := m.resolveTarget(result.Text) ctx, ch := m.startProgressOp("Moving") return m, tea.Batch( moveAsCmd(ctx, ch, m.pendingSources[0], target), @@ -974,18 +1150,32 @@ func (m Model) handleDialogResult(result dialog.Result) (tea.Model, tea.Cmd) { } case tagRename: if result.Confirmed && result.Text != "" { - return m, renameCmd(m.currentFilePath(), result.Text) + return m, renameCmd(m.currentFileRef(), result.Text) } case tagGoTo: if result.Confirmed && result.Text != "" { + if srv, remotePath, ok := m.openServerTarget(result.Text); ok { + return m.startConnect(srv, remotePath) + } path := expandHome(result.Text) m.activePanel().SetPath(path) + m.releaseUnusedConnections() return m, m.activePanel().LoadDir() } case tagExecute: if result.Confirmed { return m, executeFileCmd(m.pendingExecutePath, m.activePanel().Path(), m.cfg.Behavior.PauseAfterExecute) } + case tagTrustHost: + if result.Confirmed { + m.pendingCreds.AcceptFingerprint = m.pendingFingerprint + return m.retryConnect() + } + case tagPassphrase: + if result.Confirmed && result.Text != "" { + m.pendingCreds.Passphrase = result.Text + return m.retryConnect() + } case tagSelectGroup: if result.Confirmed && result.Text != "" { if err := m.activePanel().SelectByPattern(result.Text); err != nil { @@ -1002,6 +1192,12 @@ func (m Model) handleDialogResult(result dialog.Result) (tea.Model, tea.Cmd) { return m, nil } +// quit disconnects cleanly instead of leaving servers with dropped sockets. +func (m Model) quit() tea.Cmd { + m.connRegistry.CloseAll() + return tea.Quit +} + // --- Layout helpers --- func (m *Model) activePanel() *panel.Model { @@ -1011,13 +1207,14 @@ func (m *Model) activePanel() *panel.Model { return &m.rightPanel } -// ActivePanelPath returns the path of the currently focused panel. -// Used after the program exits to capture the final navigation target. +// ActivePanelPath is what the shell wrapper cd's into on exit. An archive or +// server has no path this machine can enter, so the local one is reported. func (m Model) ActivePanelPath() string { - if m.focus == FocusLeft { - return m.leftPanel.Path() + p := m.leftPanel + if m.focus == FocusRight { + p = m.rightPanel } - return m.rightPanel.Path() + return p.LocalPath() } func (m *Model) inactivePanelModel() *panel.Model { @@ -1062,12 +1259,27 @@ func (m *Model) recalcLayout() { // openQuickView turns the inactive pane into a live preview of the active // panel's current selection. Focus stays on the driver (listing) panel. -func (m *Model) openQuickView() { +func (m *Model) openQuickView() tea.Cmd { qv := quickview.New() m.quickview = &qv m.quickFocus = false m.recalcLayout() - m.syncQuickView() + return m.syncQuickView() +} + +// swapPanelConns realigns the connection map after the panels trade places: +// the map is keyed by side, but the panels moved, and the release sweep would +// otherwise close a connection the other panel is still showing. +func (m *Model) swapPanelConns() { + left, right := m.panelConns[FocusLeft], m.panelConns[FocusRight] + delete(m.panelConns, FocusLeft) + delete(m.panelConns, FocusRight) + if right != nil { + m.panelConns[FocusLeft] = right + } + if left != nil { + m.panelConns[FocusRight] = left + } } // closeQuickView restores the inactive pane to its listing. @@ -1078,14 +1290,16 @@ func (m *Model) closeQuickView() { // syncQuickView reloads the preview to match the driver's current selection, // but only when the selection actually changed. -func (m *Model) syncQuickView() { +func (m *Model) syncQuickView() tea.Cmd { p := m.activePanel() path := p.CurrentPath() if path == m.quickview.Path() { - return + return nil } entry := p.CurrentEntry() isDir := entry != nil && entry.IsDir() - available := !p.InArchive() // archive paths are not real OS files - m.quickview.SetFile(path, p.CurrentInfo(), isDir, available) + // Archive entries have no readable stream; local and remote both do. + available := p.Location().Kind != vfs.KindArchive + // A remote file comes back as a quickview.FileLoadedMsg. + return m.quickview.SetFile(p.CurrentRef(), p.CurrentInfo(), isDir, available) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..0027eae --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,599 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kooler/MiddayCommander/internal/actions" + "github.com/kooler/MiddayCommander/internal/remote" + "github.com/kooler/MiddayCommander/internal/remote/testserver" + "github.com/kooler/MiddayCommander/internal/ui/dialog" + "github.com/kooler/MiddayCommander/internal/ui/panel" + "github.com/kooler/MiddayCommander/internal/ui/servers" + "github.com/kooler/MiddayCommander/internal/vfs" +) + +// Driving the root model with messages, as the connection flow really runs. +// No terminal involved. + +func isolate(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("SSH_AUTH_SOCK", "") +} + +func newModel(t *testing.T) Model { + t.Helper() + m := New("test") + m.width, m.height = 100, 30 + m.recalcLayout() + return m +} + +// run feeds one message in. +func run(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) { + t.Helper() + next, cmd := m.Update(msg) + updated, ok := next.(Model) + if !ok { + t.Fatalf("Update returned %T, want app.Model", next) + } + return updated, cmd +} + +// drain runs a command and returns its message. +func drain(t *testing.T, cmd tea.Cmd) tea.Msg { + t.Helper() + if cmd == nil { + t.Fatal("want a command, got nil") + } + return cmd() +} + +// serverConnect is what the server list sends when a server is picked. +func serverConnect(srv remote.Server) tea.Msg { + return servers.ConnectMsg{Server: srv} +} + +func testServerFor(t *testing.T, s *testserver.Server) remote.Server { + t.Helper() + return remote.Server{ + Name: "test", + Host: s.Host, + Port: s.Port, + User: "tester", + KeyPath: s.ClientKey, + } +} + +func TestConnectFlowAsksToTrustThenOpensPanel(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + server := testServerFor(t, srv) + + if err := os.WriteFile(srv.Path("remote-file.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + m := newModel(t) + + // Opening the server starts a connection and shows the waiting dialog. + m, cmd := run(t, m, serverConnect(server)) + if m.dialog == nil || m.dialog.Kind() != dialog.KindProgress { + t.Fatal("want a progress dialog while connecting") + } + + // The first attempt comes back asking about the host key. + m, _ = run(t, m, drain(t, cmd)) + if m.dialog == nil || m.dialog.Kind() != dialog.KindConfirm { + t.Fatalf("want a confirmation dialog for the unknown host, got %v", m.dialog) + } + if m.pendingFingerprint != srv.Fingerprint { + t.Errorf("want the server's fingerprint offered, got %q", m.pendingFingerprint) + } + + // The user accepts, which retries with the fingerprint they confirmed. + m, cmd = run(t, m, dialog.Result{Kind: dialog.KindConfirm, Confirmed: true, Tag: tagTrustHost}) + if m.pendingCreds.AcceptFingerprint != srv.Fingerprint { + t.Errorf("want the accepted fingerprint carried into the retry, got %q", + m.pendingCreds.AcceptFingerprint) + } + + // The retry succeeds and the panel switches to the server. + m, cmd = run(t, m, drain(t, cmd)) + if m.dialog != nil { + t.Errorf("want the dialog closed after connecting, got %v", m.dialog.Kind()) + } + + loc := m.activePanel().Location() + if loc.Kind != vfs.KindSSH { + t.Fatalf("want the panel on an SSH location, got kind %v", loc.Kind) + } + if !strings.HasPrefix(loc.Label, "ssh://tester@") { + t.Errorf("want an ssh:// label, got %q", loc.Label) + } + if m.panelConns[m.focus] == nil { + t.Error("want the connection recorded against the panel") + } + + // Loading the directory reaches the server. + msg := drain(t, cmd) + loaded, ok := msg.(panel.DirLoadedMsg) + if !ok { + t.Fatalf("want a DirLoadedMsg, got %T", msg) + } + if loaded.Err != nil { + t.Fatalf("listing the server failed: %v", loaded.Err) + } +} + +func TestDeclinedHostKeyLeavesPanelLocal(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + m := newModel(t) + before := m.activePanel().Path() + + m, cmd := run(t, m, serverConnect(testServerFor(t, srv))) + m, _ = run(t, m, drain(t, cmd)) + + // Saying no ends the attempt. + m, _ = run(t, m, dialog.Result{Kind: dialog.KindConfirm, Confirmed: false, Tag: tagTrustHost}) + + if !m.activePanel().IsLocal() { + t.Error("want the panel still local after declining the host key") + } + if m.activePanel().Path() != before { + t.Errorf("want the panel unmoved, got %q", m.activePanel().Path()) + } + if m.panelConns[m.focus] != nil { + t.Error("want no connection recorded after declining") + } +} + +func TestChangedHostKeyShowsAnErrorNotAPrompt(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + server := testServerFor(t, srv) + + // Trust the host once. + m := newModel(t) + m, cmd := run(t, m, serverConnect(server)) + m, _ = run(t, m, drain(t, cmd)) + m, cmd = run(t, m, dialog.Result{Kind: dialog.KindConfirm, Confirmed: true, Tag: tagTrustHost}) + m, _ = run(t, m, drain(t, cmd)) + + // The host now answers with a different key. + srv.RotateHostKey(t) + + m2 := newModel(t) + m2, cmd = run(t, m2, serverConnect(server)) + m2, _ = run(t, m2, drain(t, cmd)) + + if m2.dialog == nil { + t.Fatal("want a dialog after a changed host key") + } + if m2.dialog.Kind() != dialog.KindError { + t.Errorf("a changed host key must be an error, not a %v prompt", m2.dialog.Kind()) + } +} + +func TestGoToAcceptsAnSSHAddress(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + m := newModel(t) + // Save the server so the typed address picks up its key file. + m.serverStore.Add(testServerFor(t, srv)) + + addr := "ssh://tester@" + srv.Host + ":" + itoa(srv.Port) + "/tmp" + m, _ = run(t, m, dialog.Result{Kind: dialog.KindInput, Confirmed: true, Text: addr, Tag: tagGoTo}) + + if m.dialog == nil || m.dialog.Kind() != dialog.KindProgress { + t.Fatal("want an ssh:// address in Go To to start a connection") + } + if m.pendingServer.Host != srv.Host { + t.Errorf("want the host parsed out, got %q", m.pendingServer.Host) + } + if m.pendingServer.KeyPath != srv.ClientKey { + t.Errorf("want the saved key file applied, got %q", m.pendingServer.KeyPath) + } + if m.pendingRemotePath != "/tmp" { + t.Errorf("want the path parsed out, got %q", m.pendingRemotePath) + } +} + +func TestGoToStillHandlesLocalPaths(t *testing.T) { + isolate(t) + dir := t.TempDir() + + m := newModel(t) + m, cmd := run(t, m, dialog.Result{Kind: dialog.KindInput, Confirmed: true, Text: dir, Tag: tagGoTo}) + + if m.dialog != nil { + t.Errorf("a local path should not open a dialog, got %v", m.dialog.Kind()) + } + if m.activePanel().Path() != dir { + t.Errorf("want the panel at %q, got %q", dir, m.activePanel().Path()) + } + if cmd == nil { + t.Error("want a directory load command") + } +} + +func TestLeavingAServerReleasesItsConnection(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.MkdirAll(srv.Path("sub"), 0o755); err != nil { + t.Fatal(err) + } + + m := newModel(t) + m, cmd := run(t, m, serverConnect(testServerFor(t, srv))) + m, _ = run(t, m, drain(t, cmd)) + m, cmd = run(t, m, dialog.Result{Kind: dialog.KindConfirm, Confirmed: true, Tag: tagTrustHost}) + m, _ = run(t, m, drain(t, cmd)) + + if m.panelConns[m.focus] == nil { + t.Fatal("want a connection after opening the server") + } + + // Going somewhere local drops it. + m.activePanel().SetPath(t.TempDir()) + m.releaseUnusedConnections() + + if m.panelConns[m.focus] != nil { + t.Error("want the connection released once the panel left the server") + } +} + +func TestRemotePanelDisablesLocalOnlyFeatures(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + m := newModel(t) + m, cmd := run(t, m, serverConnect(testServerFor(t, srv))) + m, _ = run(t, m, drain(t, cmd)) + m, cmd = run(t, m, dialog.Result{Kind: dialog.KindConfirm, Confirmed: true, Tag: tagTrustHost}) + m, _ = run(t, m, drain(t, cmd)) + + if m.activePanel().IsLocal() { + t.Fatal("expected a remote panel for this test") + } + + // Fuzzy find and the command runner both need a local working directory. + if next, _ := m.startFuzzyFind(); next.(Model).fuzzy != nil { + t.Error("fuzzy find should not open on a remote panel") + } + if next, _ := m.startCmdExec(); next.(Model).cmdExec != nil { + t.Error("the command runner should not open on a remote panel") + } + + // The shell still opens, but in the panel's local directory. + if got := m.activePanel().LocalPath(); got == "" || !filepath.IsAbs(got) { + t.Errorf("want an absolute local path for the shell, got %q", got) + } +} + +func TestCopyPathUsesTheSSHURLForRemoteFiles(t *testing.T) { + loc := vfs.Location{Kind: vfs.KindSSH, Label: "ssh://kk@host", Path: "/var/log"} + if got := loc.URLFor("/var/log/app.log"); got != "ssh://kk@host/var/log/app.log" { + t.Errorf("want a full ssh:// URL, got %q", got) + } + + local := vfs.Location{Kind: vfs.KindLocal, Path: "/home/kk"} + if got := local.URLFor("/home/kk/f"); got != "/home/kk/f" { + t.Errorf("want a bare local path, got %q", got) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +// runAll unwraps tea.Batch so the work it holds runs, feeding every message +// back into the model. +func runAll(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + if cmd == nil { + return m + } + msg := cmd() + switch v := msg.(type) { + case tea.BatchMsg: + for _, sub := range v { + m = runAll(t, m, sub) + } + case nil: + default: + m, _ = run(t, m, msg) + } + return m +} + +// loadPanel gives the active panel entries to select. +func loadPanel(t *testing.T, m Model) Model { + t.Helper() + cmd := m.activePanel().LoadDir() + msg := cmd() + m, _ = run(t, m, msg) + return m +} + +// selectEntry moves the cursor onto a named entry. +func selectEntry(t *testing.T, m Model, name string) Model { + t.Helper() + p := m.activePanel() + for i := 0; i < 50; i++ { + if e := p.CurrentEntry(); e != nil && e.Name() == name { + return m + } + p.Update(tea.KeyMsg{Type: tea.KeyDown}) + } + t.Fatalf("could not put the cursor on %q", name) + return m +} + +// connectPanel opens srv in the active panel, answering the host key prompt. +func connectPanel(t *testing.T, m Model, srv remote.Server) Model { + t.Helper() + m, cmd := run(t, m, serverConnect(srv)) + m, _ = run(t, m, drain(t, cmd)) + m, cmd = run(t, m, dialog.Result{Kind: dialog.KindConfirm, Confirmed: true, Tag: tagTrustHost}) + m, cmd = run(t, m, drain(t, cmd)) + if cmd != nil { + m, _ = run(t, m, cmd()) // apply the directory listing + } + return m +} + +func TestCopyFromRemotePanelToLocalPanel(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.WriteFile(srv.Path("payload.txt"), []byte("from the server"), 0o644); err != nil { + t.Fatal(err) + } + + landing := t.TempDir() + + m := newModel(t) + m.inactivePanelModel().SetPath(landing) // the copy destination + m = connectPanel(t, m, testServerFor(t, srv)) + + if m.activePanel().IsLocal() { + t.Fatal("expected the active panel on the server") + } + m = selectEntry(t, m, "payload.txt") + + // F5 on a single selection opens "Copy to:" prefilled with the target. + next, _ := m.startCopy() + m = next.(Model) + if m.dialog == nil || m.dialog.Kind() != dialog.KindInput { + t.Fatal("want the copy input dialog") + } + if len(m.pendingSources) != 1 || m.pendingSources[0].Kind != vfs.KindSSH { + t.Fatalf("want one remote source, got %+v", m.pendingSources) + } + if m.pendingDest.Kind != vfs.KindLocal || m.pendingDest.Path != landing { + t.Fatalf("want the local panel as destination, got %+v", m.pendingDest) + } + + target := filepath.Join(landing, "payload.txt") + m, cmd := run(t, m, dialog.Result{ + Kind: dialog.KindInput, Confirmed: true, Text: target, Tag: tagCopyAs, + }) + m = runAll(t, m, cmd) + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("the file should have landed locally: %v", err) + } + if string(got) != "from the server" { + t.Errorf("want %q, got %q", "from the server", string(got)) + } +} + +func TestCopyFromLocalPanelToRemotePanel(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "upload.txt"), []byte("to the server"), 0o644); err != nil { + t.Fatal(err) + } + + m := newModel(t) + // Connect the right panel, then drive from the left (local) one. + m.focus = FocusRight + m = connectPanel(t, m, testServerFor(t, srv)) + m.focus = FocusLeft + m.activePanel().SetPath(src) + m = loadPanel(t, m) + + if !m.activePanel().IsLocal() { + t.Fatal("expected a local active panel") + } + if m.inactiveRef().Kind != vfs.KindSSH { + t.Fatalf("expected the remote panel as destination, got %+v", m.inactiveRef()) + } + m = selectEntry(t, m, "upload.txt") + + next, _ := m.startCopy() + m = next.(Model) + if m.dialog == nil { + t.Fatal("want the copy dialog") + } + dest := m.pendingDest.Join("upload.txt") + + m, cmd := run(t, m, dialog.Result{ + Kind: dialog.KindInput, Confirmed: true, Text: dest.Path, Tag: tagCopyAs, + }) + m = runAll(t, m, cmd) + + got, err := os.ReadFile(srv.Path("upload.txt")) + if err != nil { + t.Fatalf("the file should have landed on the server: %v", err) + } + if string(got) != "to the server" { + t.Errorf("want %q, got %q", "to the server", string(got)) + } +} + +func TestCancellingAConnectIsSilent(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + m := newModel(t) + m, _ = run(t, m, serverConnect(testServerFor(t, srv))) + + // Esc on the connecting dialog cancels the attempt. + m, _ = run(t, m, connectedMsg{ + side: m.focus, + err: context.Canceled, + }) + + if m.dialog != nil { + t.Errorf("cancelling should not leave a dialog, got %v", m.dialog.Kind()) + } + if !m.activePanel().IsLocal() { + t.Error("want the panel unchanged after cancelling") + } +} + +func TestConnectTimeoutExplainsItself(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + m := newModel(t) + m, _ = run(t, m, serverConnect(testServerFor(t, srv))) + m, _ = run(t, m, connectedMsg{ + side: m.focus, + err: context.DeadlineExceeded, + }) + + if m.dialog == nil || m.dialog.Kind() != dialog.KindError { + t.Fatal("want an error dialog after a timeout") + } +} + +func TestSwappingPanelsKeepsTheRemoteConnection(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + m := newModel(t) + m = connectPanel(t, m, testServerFor(t, srv)) + + conn := m.panelConns[FocusLeft] + if conn == nil { + t.Fatal("want the connection recorded against the left panel") + } + + m, _ = run(t, m, tea.KeyMsg{Type: tea.KeyCtrlU}) + // The release sweep runs on the next panel keypress. + m, _ = run(t, m, tea.KeyMsg{Type: tea.KeyDown}) + + if !m.rightPanel.UsesFS(conn.FS()) { + t.Fatal("the server should have moved to the right panel") + } + if m.panelConns[FocusRight] != conn { + t.Error("want the connection tracked against the panel that now shows it") + } + if _, err := conn.FS().ReadDir(srv.Root); err != nil { + t.Errorf("the connection must survive a panel swap: %v", err) + } +} + +func TestEnterOnARemoteFileStagesItRatherThanOpeningALocalPath(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.WriteFile(srv.Path("notes.txt"), []byte("remote body"), 0o644); err != nil { + t.Fatal(err) + } + + m := newModel(t) + m = connectPanel(t, m, testServerFor(t, srv)) + m = selectEntry(t, m, "notes.txt") + + m, cmd := run(t, m, panel.OpenFileMsg{Path: m.activePanel().CurrentPath()}) + if m.dialog == nil || m.dialog.Kind() != dialog.KindProgress { + t.Fatal("want the download dialog: the remote path means nothing to a local editor") + } + + ready, ok := drain(t, cmd).(stagedReadyMsg) + if !ok { + t.Fatalf("want a staged file, got %T", ready) + } + if ready.err != nil { + t.Fatalf("staging failed: %v", ready.err) + } + got, err := os.ReadFile(ready.staged.tmpPath) + if err != nil { + t.Fatalf("reading the staged copy: %v", err) + } + if string(got) != "remote body" { + t.Errorf("want the server's content staged, got %q", string(got)) + } +} + +func TestEditedRemoteFileGoesBackWithItsMode(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.WriteFile(srv.Path("run.sh"), []byte("old\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(srv.Path("run.sh"), 0o755); err != nil { + t.Fatal(err) + } + + m := newModel(t) + m = connectPanel(t, m, testServerFor(t, srv)) + m = selectEntry(t, m, "run.sh") + + _, cmd := run(t, m, panel.OpenFileMsg{Path: m.activePanel().CurrentPath()}) + ready, ok := drain(t, cmd).(stagedReadyMsg) + if !ok || ready.err != nil { + t.Fatalf("staging failed: %v", ready.err) + } + + // Stand in for the editor. + if err := os.WriteFile(ready.staged.tmpPath, []byte("new content\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := uploadStaged(ready.staged); err != nil { + t.Fatalf("upload: %v", err) + } + + got, err := os.ReadFile(srv.Path("run.sh")) + if err != nil { + t.Fatalf("reading the file back: %v", err) + } + if string(got) != "new content\n" { + t.Errorf("want the edit written back, got %q", string(got)) + } + info, err := os.Stat(srv.Path("run.sh")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("want the executable bit kept, got %v", info.Mode().Perm()) + } + if _, err := os.Stat(srv.Path("run.sh" + actions.PartSuffix)); !os.IsNotExist(err) { + t.Errorf("the scratch file should be gone, stat gave %v", err) + } +} diff --git a/internal/app/commands.go b/internal/app/commands.go index a73f621..f6be9d2 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -12,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kooler/MiddayCommander/internal/actions" + "github.com/kooler/MiddayCommander/internal/vfs" ) // File operation result messages. @@ -58,7 +59,7 @@ func sendProgress(ctx context.Context, ch chan actions.Progress) func(actions.Pr } } -func copyCmd(ctx context.Context, ch chan actions.Progress, sources []string, dest string) tea.Cmd { +func copyCmd(ctx context.Context, ch chan actions.Progress, sources []vfs.FileRef, dest vfs.FileRef) tea.Cmd { return func() tea.Msg { err := actions.Copy(ctx, sources, dest, sendProgress(ctx, ch)) close(ch) @@ -66,7 +67,7 @@ func copyCmd(ctx context.Context, ch chan actions.Progress, sources []string, de } } -func copyAsCmd(ctx context.Context, ch chan actions.Progress, source, destPath string) tea.Cmd { +func copyAsCmd(ctx context.Context, ch chan actions.Progress, source, destPath vfs.FileRef) tea.Cmd { return func() tea.Msg { err := actions.CopyAs(ctx, source, destPath, sendProgress(ctx, ch)) close(ch) @@ -74,7 +75,7 @@ func copyAsCmd(ctx context.Context, ch chan actions.Progress, source, destPath s } } -func moveCmd(ctx context.Context, ch chan actions.Progress, sources []string, dest string) tea.Cmd { +func moveCmd(ctx context.Context, ch chan actions.Progress, sources []vfs.FileRef, dest vfs.FileRef) tea.Cmd { return func() tea.Msg { err := actions.Move(ctx, sources, dest, sendProgress(ctx, ch)) close(ch) @@ -82,7 +83,7 @@ func moveCmd(ctx context.Context, ch chan actions.Progress, sources []string, de } } -func moveAsCmd(ctx context.Context, ch chan actions.Progress, source, destPath string) tea.Cmd { +func moveAsCmd(ctx context.Context, ch chan actions.Progress, source, destPath vfs.FileRef) tea.Cmd { return func() tea.Msg { err := actions.MoveAs(ctx, source, destPath, sendProgress(ctx, ch)) close(ch) @@ -90,24 +91,24 @@ func moveAsCmd(ctx context.Context, ch chan actions.Progress, source, destPath s } } -func deleteCmd(ctx context.Context, ch chan actions.Progress, paths []string) tea.Cmd { +func deleteCmd(ctx context.Context, ch chan actions.Progress, refs []vfs.FileRef) tea.Cmd { return func() tea.Msg { - err := actions.Delete(ctx, paths, sendProgress(ctx, ch)) + err := actions.Delete(ctx, refs, sendProgress(ctx, ch)) close(ch) return deleteDoneMsg{err: err} } } -func mkdirCmd(path string) tea.Cmd { +func mkdirCmd(ref vfs.FileRef) tea.Cmd { return func() tea.Msg { - err := actions.Mkdir(path) + err := actions.Mkdir(ref) return mkdirDoneMsg{err: err} } } -func renameCmd(oldPath, newName string) tea.Cmd { +func renameCmd(ref vfs.FileRef, newName string) tea.Cmd { return func() tea.Msg { - err := actions.Rename(oldPath, newName) + err := actions.Rename(ref, newName) return renameDoneMsg{err: err} } } @@ -243,17 +244,21 @@ func (m *Model) refreshBothPanels() tea.Cmd { return tea.Batch(m.leftPanel.LoadDir(), m.rightPanel.LoadDir()) } -// inactivePanel returns the panel that does NOT have focus. +// inactiveRef returns a reference to the directory shown in the panel that +// does NOT have focus: the destination of a copy or move. +func (m *Model) inactiveRef() vfs.FileRef { + return m.inactivePanelModel().Ref() +} + +// inactivePanel returns the display path of the panel without focus. func (m *Model) inactivePanel() string { - if m.focus == FocusLeft { - return m.rightPanel.Path() - } - return m.leftPanel.Path() + return m.inactivePanelModel().Location().Display() } -// selectedOrCurrent returns the currently selected/tagged paths from the active panel. -func (m *Model) selectedOrCurrent() []string { - return m.activePanel().SelectedPaths() +// selectedOrCurrent returns references to the tagged entries in the active +// panel, or the entry under the cursor when nothing is tagged. +func (m *Model) selectedOrCurrent() []vfs.FileRef { + return m.activePanel().SelectedRefs() } // currentFileName returns just the base name of the file under cursor. @@ -265,14 +270,20 @@ func (m *Model) currentFileName() string { return e.Name() } -// currentFilePath returns the full path of the file under cursor. +// currentFilePath returns the path of the file under cursor, within its own +// filesystem. Only meaningful for local panels. func (m *Model) currentFilePath() string { return m.activePanel().CurrentPath() } -// activePanelMkdir returns the full path for a new directory in the active panel. -func (m *Model) activePanelMkdir(name string) string { - return filepath.Join(m.activePanel().Path(), name) +// currentFileRef returns a reference to the file under the cursor. +func (m *Model) currentFileRef() vfs.FileRef { + return m.activePanel().CurrentRef() +} + +// activePanelMkdir returns the ref for a new directory in the active panel. +func (m *Model) activePanelMkdir(name string) vfs.FileRef { + return m.activePanel().Ref().Join(name) } // expandHome replaces a leading "~" with the user's home directory. diff --git a/internal/app/connect.go b/internal/app/connect.go new file mode 100644 index 0000000..2f48fbf --- /dev/null +++ b/internal/app/connect.go @@ -0,0 +1,212 @@ +package app + +import ( + "context" + "errors" + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kooler/MiddayCommander/internal/remote" + "github.com/kooler/MiddayCommander/internal/ui/dialog" + "github.com/kooler/MiddayCommander/internal/ui/panel" + "github.com/kooler/MiddayCommander/internal/vfs" +) + +// connectTimeout bounds the attempt, so a host that accepts TCP then goes +// quiet cannot leave the panel waiting. +const connectTimeout = 30 * time.Second + +type connectedMsg struct { + conn *remote.Conn + path string + side FocusTarget + err error +} + +// connectCmd dials off the event loop. Every SFTP call runs inside a tea.Cmd +// like this one: from Update, a slow network would freeze the interface. +func connectCmd(ctx context.Context, reg *remote.Registry, srv remote.Server, creds remote.Credentials, remotePath string, side FocusTarget) tea.Cmd { + return func() tea.Msg { + conn, err := reg.Acquire(ctx, srv, creds) + return connectedMsg{ + conn: conn, + path: remotePath, + side: side, + err: err, + } + } +} + +func (m Model) startConnect(srv remote.Server, remotePath string) (tea.Model, tea.Cmd) { + m.pendingServer = srv + m.pendingRemotePath = remotePath + m.pendingSide = m.focus + m.pendingCreds = remote.Credentials{} + return m.dialConnection() +} + +// retryConnect re-dials with whatever the user just supplied. +func (m Model) retryConnect() (tea.Model, tea.Cmd) { + return m.dialConnection() +} + +// dialConnection opens the waiting dialog and starts the attempt. The context +// lives here, not in the command, so Esc on the dialog can cancel it. +func (m Model) dialConnection() (tea.Model, tea.Cmd) { + ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + m.opCancel = cancel + + d := dialog.NewProgress("Connecting", tagConnect) + d.SetConnecting(m.pendingServer.Label()) + m.dialog = &d + + return m, connectCmd(ctx, m.connRegistry, m.pendingServer, m.pendingCreds, + m.pendingRemotePath, m.pendingSide) +} + +// handleConnected opens the panel, or asks the user the next question. +func (m Model) handleConnected(msg connectedMsg) (tea.Model, tea.Cmd) { + // Esc may have landed in the moment between the session coming up and this + // message arriving. + cancelled := m.dialog != nil && + m.dialog.Kind() == dialog.KindProgress && + m.dialog.CancelRequested() + + m.dialog = nil + if m.opCancel != nil { + m.opCancel() + m.opCancel = nil + } + + if msg.err != nil { + return m.handleConnectError(msg.err) + } + if cancelled { + m.connRegistry.Release(msg.conn) + return m, nil + } + + // Release whatever this panel was using before. Acquire took a reference + // even when it handed back the connection this panel already had, so an + // unchanged connection still needs one released. + side := msg.side + if old := m.panelConns[side]; old != nil { + m.connRegistry.Release(old) + } + if m.panelConns == nil { + m.panelConns = map[FocusTarget]*remote.Conn{} + } + m.panelConns[side] = msg.conn + + // The resolved server is the one that was actually dialled: ~/.ssh/config + // may have supplied the user or the real hostname, and the header, the + // copied URL and any bookmark have to match the connection. + srv := msg.conn.Server() + + remotePath := msg.path + if remotePath == "" || remotePath == "/" { + if dir := srv.Dir; dir != "" { + remotePath = dir + } else { + remotePath = msg.conn.FS().Home() + } + } + + p := m.panelFor(side) + p.SetLocation(vfs.Location{ + FS: msg.conn.FS(), + Path: remotePath, + Kind: vfs.KindSSH, + Label: srv.Label(), + Origin: srv.DisplayName(), + }) + + return m, p.LoadDir() +} + +// handleConnectError decides whether the failure is a question or an end. +func (m Model) handleConnectError(err error) (tea.Model, tea.Cmd) { + // The user pressed Esc, so they know what happened. + if errors.Is(err, context.Canceled) { + return m, nil + } + if errors.Is(err, context.DeadlineExceeded) { + return m.showError("Connection failed", + fmt.Errorf("%s did not respond within %s", m.pendingServer.Label(), connectTimeout)) + } + + var unknown *remote.UnknownHostError + if errors.As(err, &unknown) { + m.pendingFingerprint = unknown.Fingerprint + body := fmt.Sprintf( + "%s is not in your known_hosts file.\n\n%s key fingerprint:\n%s\n\n"+ + "Accept this key and continue?", + unknown.Host, unknown.KeyType, unknown.Fingerprint) + d := dialog.NewConfirm("Unknown host", body, tagTrustHost) + m.dialog = &d + return m, nil + } + + var passphrase *remote.PassphraseRequiredError + if errors.As(err, &passphrase) { + d := dialog.NewPassword("Key passphrase", + "Passphrase for "+passphrase.KeyPath+":", tagPassphrase) + m.dialog = &d + return m, nil + } + + // A changed host key, a refused login, an unreachable host. + return m.showError("Connection failed", err) +} + +func (m *Model) panelFor(side FocusTarget) *panel.Model { + if side == FocusLeft { + return &m.leftPanel + } + return &m.rightPanel +} + +// releaseUnusedConnections is how leaving a server with ".." closes it. +func (m *Model) releaseUnusedConnections() { + for side, conn := range m.panelConns { + if conn == nil { + continue + } + if !m.panelFor(side).UsesFS(conn.FS()) { + m.connRegistry.Release(conn) + delete(m.panelConns, side) + } + } +} + +// openServerTarget resolves Go To text: an ssh:// address or a saved name. +func (m Model) openServerTarget(text string) (remote.Server, string, bool) { + if remote.IsURL(text) { + srv, remotePath, err := remote.ParseURL(text) + if err != nil { + return remote.Server{}, "", false + } + // A typed address picks up the key file of a saved entry on the + // same host. + for _, saved := range m.serverStore.Servers { + if saved.Host == srv.Host && (srv.User == "" || saved.User == srv.User) { + if srv.User == "" { + srv.User = saved.User + } + if srv.Port == 0 { + srv.Port = saved.Port + } + srv.KeyPath = saved.KeyPath + break + } + } + return srv, remotePath, true + } + + if srv, ok := m.serverStore.Find(text); ok { + return srv, srv.Dir, true + } + return remote.Server{}, "", false +} diff --git a/internal/app/keymap.go b/internal/app/keymap.go index 65952f1..974f892 100644 --- a/internal/app/keymap.go +++ b/internal/app/keymap.go @@ -8,22 +8,23 @@ import ( // KeyMap defines all global keybindings. type KeyMap struct { - Quit key.Binding - TogglePanel key.Binding - SwapPanels key.Binding - SameDir key.Binding - Copy key.Binding - Move key.Binding - Mkdir key.Binding - Delete key.Binding - Rename key.Binding - View key.Binding - Edit key.Binding - GoTo key.Binding - FuzzyFind key.Binding - Bookmarks key.Binding - Help key.Binding - ThemePicker key.Binding + Quit key.Binding + TogglePanel key.Binding + SwapPanels key.Binding + SameDir key.Binding + Copy key.Binding + Move key.Binding + Mkdir key.Binding + Delete key.Binding + Rename key.Binding + View key.Binding + Edit key.Binding + GoTo key.Binding + FuzzyFind key.Binding + Bookmarks key.Binding + Servers key.Binding + Help key.Binding + ThemePicker key.Binding CmdExec key.Binding Terminal key.Binding ToggleHidden key.Binding @@ -37,22 +38,23 @@ type KeyMap struct { // KeyMapFromConfig builds the global keymap from config. func KeyMapFromConfig(keys config.KeyBindings) KeyMap { return KeyMap{ - Quit: binding(keys.Quit, "quit"), - TogglePanel: binding(keys.TogglePanel, "switch panel"), - SwapPanels: binding(keys.SwapPanels, "swap panels"), - SameDir: binding(keys.SameDir, "same dir"), - Copy: binding(keys.Copy, "copy"), - Move: binding(keys.Move, "move"), - Mkdir: binding(keys.Mkdir, "mkdir"), - Delete: binding(keys.Delete, "delete"), - Rename: binding(keys.Rename, "rename"), - View: binding(keys.View, "view"), - Edit: binding(keys.Edit, "edit"), - GoTo: binding(keys.GoTo, "go to"), - FuzzyFind: binding(keys.FuzzyFind, "find"), - Bookmarks: binding(keys.Bookmarks, "bookmarks"), - Help: binding(keys.Help, "help"), - ThemePicker: binding(keys.ThemePicker, "themes"), + Quit: binding(keys.Quit, "quit"), + TogglePanel: binding(keys.TogglePanel, "switch panel"), + SwapPanels: binding(keys.SwapPanels, "swap panels"), + SameDir: binding(keys.SameDir, "same dir"), + Copy: binding(keys.Copy, "copy"), + Move: binding(keys.Move, "move"), + Mkdir: binding(keys.Mkdir, "mkdir"), + Delete: binding(keys.Delete, "delete"), + Rename: binding(keys.Rename, "rename"), + View: binding(keys.View, "view"), + Edit: binding(keys.Edit, "edit"), + GoTo: binding(keys.GoTo, "go to"), + FuzzyFind: binding(keys.FuzzyFind, "find"), + Bookmarks: binding(keys.Bookmarks, "bookmarks"), + Servers: binding(keys.Servers, "ssh servers"), + Help: binding(keys.Help, "help"), + ThemePicker: binding(keys.ThemePicker, "themes"), CmdExec: binding(keys.CmdExec, "run cmd"), Terminal: binding(keys.Terminal, "terminal"), ToggleHidden: binding(keys.ToggleHidden, "toggle hidden"), diff --git a/internal/app/remotefile.go b/internal/app/remotefile.go new file mode 100644 index 0000000..5109a70 --- /dev/null +++ b/internal/app/remotefile.go @@ -0,0 +1,208 @@ +package app + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kooler/MiddayCommander/internal/actions" + "github.com/kooler/MiddayCommander/internal/vfs" +) + +// $EDITOR and $PAGER need a real file on disk, so a remote file is staged to +// a temporary copy, uploaded back if it changed, then removed. + +// stageChunk is the max bytes moved between cancellation checks. +const stageChunk = 256 * 1024 + +// stagedFile is a remote file downloaded for an external program. +type stagedFile struct { + ref vfs.FileRef + tmpPath string + modTime time.Time + size int64 + mode fs.FileMode +} + +type stagedReadyMsg struct { + staged stagedFile + edit bool // upload the file back afterwards + err error +} + +// stagedDoneMsg names the copy it refers to: a second file can be staged +// while the first is still uploading. +type stagedDoneMsg struct { + tmpPath string + err error +} + +func stageRemoteCmd(ctx context.Context, ref vfs.FileRef, edit bool) tea.Cmd { + return func() tea.Msg { + staged, err := stageRemote(ctx, ref) + return stagedReadyMsg{staged: staged, edit: edit, err: err} + } +} + +func stageRemote(ctx context.Context, ref vfs.FileRef) (stagedFile, error) { + // The upload writes to a fresh file, so the original's permissions have to + // be carried across rather than inherited. + var mode fs.FileMode + if info, err := ref.FS.Stat(ref.Path); err == nil { + mode = info.Mode().Perm() + } + + src, err := ref.FS.Open(ref.Path) + if err != nil { + return stagedFile{}, fmt.Errorf("open %s: %w", ref.Path, err) + } + defer src.Close() + + dir, err := os.MkdirTemp("", "mdc-remote-*") + if err != nil { + return stagedFile{}, err + } + + // Keep the name so the editor picks the right syntax mode. + tmpPath := filepath.Join(dir, ref.Base()) + dst, err := os.Create(tmpPath) + if err != nil { + _ = os.RemoveAll(dir) + return stagedFile{}, err + } + + if err := copyCancellable(ctx, dst, src); err != nil { + _ = dst.Close() + _ = os.RemoveAll(dir) + if ctx.Err() != nil { + return stagedFile{}, ctx.Err() + } + return stagedFile{}, fmt.Errorf("download %s: %w", ref.Path, err) + } + if err := dst.Close(); err != nil { + _ = os.RemoveAll(dir) + return stagedFile{}, err + } + + info, err := os.Stat(tmpPath) + if err != nil { + _ = os.RemoveAll(dir) + return stagedFile{}, err + } + + return stagedFile{ + ref: ref, + tmpPath: tmpPath, + modTime: info.ModTime(), + size: info.Size(), + mode: mode, + }, nil +} + +// copyCancellable copies in chunks so Esc is answered mid-transfer. +func copyCancellable(ctx context.Context, dst io.Writer, src io.Reader) error { + buf := make([]byte, stageChunk) + for { + if err := ctx.Err(); err != nil { + return err + } + n, rerr := src.Read(buf) + if n > 0 { + if _, werr := dst.Write(buf[:n]); werr != nil { + return werr + } + } + if rerr == io.EOF { + return nil + } + if rerr != nil { + return rerr + } + } +} + +// uploadStagedCmd writes the copy back only if it changed. A failed upload +// keeps the temporary file: it holds the only copy of the user's edits. +func uploadStagedCmd(staged stagedFile) tea.Cmd { + return func() tea.Msg { + done := stagedDoneMsg{tmpPath: staged.tmpPath} + + info, err := os.Stat(staged.tmpPath) + if err != nil { + _ = os.RemoveAll(filepath.Dir(staged.tmpPath)) + done.err = err + return done + } + if info.ModTime().Equal(staged.modTime) && info.Size() == staged.size { + _ = os.RemoveAll(filepath.Dir(staged.tmpPath)) + return done // untouched + } + + if err := uploadStaged(staged); err != nil { + done.err = fmt.Errorf("%w\n\nYour edits are kept at %s", err, staged.tmpPath) + return done + } + + _ = os.RemoveAll(filepath.Dir(staged.tmpPath)) + return done + } +} + +func uploadStaged(staged stagedFile) error { + w, ok := staged.ref.Writable() + if !ok { + return fmt.Errorf("%s is read-only", staged.ref.Path) + } + + src, err := os.Open(staged.tmpPath) + if err != nil { + return err + } + defer src.Close() + + // Same scratch-and-rename discipline as a copy: a connection that drops + // mid-upload must not leave the file on the server truncated. + partPath := staged.ref.Path + actions.PartSuffix + dst, err := w.Create(partPath) + if err != nil { + return fmt.Errorf("upload %s: %w", staged.ref.Path, err) + } + if _, err := io.Copy(dst, src); err != nil { + _ = dst.Close() + _ = w.Remove(partPath) + return fmt.Errorf("upload %s: %w", staged.ref.Path, err) + } + // Over SFTP the write only completes when the handle closes. + if err := dst.Close(); err != nil { + _ = w.Remove(partPath) + return fmt.Errorf("upload %s: %w", staged.ref.Path, err) + } + + // SFTP servers reject a rename over an existing path, unlike POSIX. + _ = w.Remove(staged.ref.Path) + if err := w.Rename(partPath, staged.ref.Path); err != nil { + _ = w.Remove(partPath) + return fmt.Errorf("finalise %s: %w", staged.ref.Path, err) + } + + if staged.mode != 0 { + if c, ok := w.(vfs.Chmoder); ok { + _ = c.Chmod(staged.ref.Path, staged.mode) + } + } + return nil +} + +// discardStagedCmd drops a copy that will not be written back. +func discardStagedCmd(staged stagedFile) tea.Cmd { + return func() tea.Msg { + _ = os.RemoveAll(filepath.Dir(staged.tmpPath)) + return stagedDoneMsg{tmpPath: staged.tmpPath} + } +} diff --git a/internal/bookmark/bookmark.go b/internal/bookmark/bookmark.go index fc0a9d2..473505c 100644 --- a/internal/bookmark/bookmark.go +++ b/internal/bookmark/bookmark.go @@ -11,10 +11,10 @@ import ( // Bookmark represents a saved directory bookmark. type Bookmark struct { - Path string `json:"path"` - Name string `json:"name,omitempty"` // optional display name - Count int `json:"count"` // access count - LastUsed time.Time `json:"last_used"` + Path string `json:"path"` + Name string `json:"name,omitempty"` // optional display name + Count int `json:"count"` // access count + LastUsed time.Time `json:"last_used"` } // Store manages bookmarks with persistence and frecency scoring. diff --git a/internal/config/config.go b/internal/config/config.go index 0603f27..f0dfec4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type KeyBindings struct { GoTo StringOrList `toml:"goto"` FuzzyFind StringOrList `toml:"fuzzy_find"` Bookmarks StringOrList `toml:"bookmarks"` + Servers StringOrList `toml:"servers"` Help StringOrList `toml:"help"` ThemePicker StringOrList `toml:"theme_picker"` CmdExec StringOrList `toml:"cmd_exec"` @@ -156,6 +157,7 @@ func DefaultKeyBindings() KeyBindings { GoTo: StringOrList{"ctrl+g"}, FuzzyFind: StringOrList{"f9", "ctrl+p"}, Bookmarks: StringOrList{"f2", "ctrl+b"}, + Servers: StringOrList{"shift+f2"}, Help: StringOrList{"f1"}, ThemePicker: StringOrList{"ctrl+t"}, CmdExec: StringOrList{"ctrl+r"}, @@ -237,6 +239,7 @@ func mergeKeys(dst, src *KeyBindings) { mergeKey(&dst.GoTo, src.GoTo) mergeKey(&dst.FuzzyFind, src.FuzzyFind) mergeKey(&dst.Bookmarks, src.Bookmarks) + mergeKey(&dst.Servers, src.Servers) mergeKey(&dst.Help, src.Help) mergeKey(&dst.ThemePicker, src.ThemePicker) mergeKey(&dst.CmdExec, src.CmdExec) @@ -301,6 +304,7 @@ func normalizeAllKeys(kb *KeyBindings) { normalizeSlice(&kb.GoTo) normalizeSlice(&kb.FuzzyFind) normalizeSlice(&kb.Bookmarks) + normalizeSlice(&kb.Servers) normalizeSlice(&kb.Help) normalizeSlice(&kb.ThemePicker) normalizeSlice(&kb.CmdExec) diff --git a/internal/remote/actions_sftp_test.go b/internal/remote/actions_sftp_test.go new file mode 100644 index 0000000..b284e99 --- /dev/null +++ b/internal/remote/actions_sftp_test.go @@ -0,0 +1,351 @@ +package remote + +import ( + "context" + "os" + "path" + "path/filepath" + "testing" + + "github.com/kooler/MiddayCommander/internal/actions" + "github.com/kooler/MiddayCommander/internal/remote/testserver" + "github.com/kooler/MiddayCommander/internal/vfs" + "github.com/kooler/MiddayCommander/internal/vfs/local" +) + +// End to end: operations driven as the panels drive them, over real SFTP. + +func localRef(p string) vfs.FileRef { + return vfs.FileRef{ + FS: local.New(string(filepath.Separator)), + Path: p, + Kind: vfs.KindLocal, + } +} + +func remoteRef(c *Conn, p string) vfs.FileRef { + return vfs.FileRef{FS: c.FS(), Path: p, Kind: vfs.KindSSH} +} + +func TestCopyLocalTreeToServer(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + _, conn := connect(t, srv) + + src := t.TempDir() + for rel, content := range map[string]string{ + "project/main.go": "package main", + "project/lib/util.go": "package lib", + "project/lib/data.bin": "\x00\x01\x02", + } { + p := filepath.Join(src, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + err := actions.Copy(context.Background(), + []vfs.FileRef{localRef(filepath.Join(src, "project"))}, + remoteRef(conn, srv.Root), nil) + if err != nil { + t.Fatalf("copy to server: %v", err) + } + + for rel, want := range map[string]string{ + "project/main.go": "package main", + "project/lib/util.go": "package lib", + "project/lib/data.bin": "\x00\x01\x02", + } { + got, err := os.ReadFile(srv.Path(filepath.FromSlash(rel))) + if err != nil { + t.Errorf("%s: %v", rel, err) + continue + } + if string(got) != want { + t.Errorf("%s: want %q, got %q", rel, want, string(got)) + } + } +} + +func TestCopyServerTreeToLocal(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + if err := os.MkdirAll(srv.Path("logs/old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srv.Path("logs/app.log"), []byte("line one\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srv.Path("logs/old/app.log.1"), []byte("older\n"), 0o644); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + dst := t.TempDir() + + err := actions.Copy(context.Background(), + []vfs.FileRef{remoteRef(conn, path.Join(srv.Root, "logs"))}, + localRef(dst), nil) + if err != nil { + t.Fatalf("copy from server: %v", err) + } + + for rel, want := range map[string]string{ + "logs/app.log": "line one\n", + "logs/old/app.log.1": "older\n", + } { + got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel))) + if err != nil { + t.Errorf("%s: %v", rel, err) + continue + } + if string(got) != want { + t.Errorf("%s: want %q, got %q", rel, want, string(got)) + } + } +} + +func TestCopyLargeFileToServerReportsProgress(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + _, conn := connect(t, srv) + + src := t.TempDir() + payload := make([]byte, 3<<20) // larger than one copy chunk + for i := range payload { + payload[i] = byte(i % 251) + } + big := filepath.Join(src, "big.bin") + if err := os.WriteFile(big, payload, 0o644); err != nil { + t.Fatal(err) + } + + var reports int + var lastDone int64 + err := actions.Copy(context.Background(), + []vfs.FileRef{localRef(big)}, remoteRef(conn, srv.Root), + func(p actions.Progress) { + reports++ + lastDone = p.DoneBytes + }) + if err != nil { + t.Fatalf("copy: %v", err) + } + + if reports == 0 { + t.Error("want progress reports during a multi-chunk transfer") + } + if lastDone != int64(len(payload)) { + t.Errorf("want %d bytes reported done, got %d", len(payload), lastDone) + } + + got, err := os.ReadFile(srv.Path("big.bin")) + if err != nil { + t.Fatal(err) + } + if len(got) != len(payload) { + t.Fatalf("want %d bytes on the server, got %d", len(payload), len(got)) + } + for i := range got { + if got[i] != payload[i] { + t.Fatalf("content differs at byte %d", i) + } + } +} + +func TestCancelledRemoteCopyStops(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + _, conn := connect(t, srv) + + src := t.TempDir() + big := filepath.Join(src, "big.bin") + if err := os.WriteFile(big, make([]byte, 8<<20), 0o644); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := actions.Copy(ctx, []vfs.FileRef{localRef(big)}, remoteRef(conn, srv.Root), + func(p actions.Progress) { cancel() }) + if err == nil { + t.Fatal("want the copy to stop when cancelled") + } + + // The connection must survive a cancelled transfer. + if _, err := conn.FS().ReadDir(srv.Root); err != nil { + t.Errorf("connection unusable after a cancelled copy: %v", err) + } +} + +func TestMoveFromServerToLocalRemovesRemote(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.WriteFile(srv.Path("report.txt"), []byte("contents"), 0o644); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + dst := t.TempDir() + + err := actions.Move(context.Background(), + []vfs.FileRef{remoteRef(conn, path.Join(srv.Root, "report.txt"))}, + localRef(dst), nil) + if err != nil { + t.Fatalf("move: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dst, "report.txt")) + if err != nil || string(got) != "contents" { + t.Errorf("want the file moved locally, got %q (%v)", string(got), err) + } + if _, err := os.Stat(srv.Path("report.txt")); !os.IsNotExist(err) { + t.Errorf("want the remote original removed, stat gave %v", err) + } +} + +func TestMoveWithinServerUsesRename(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.MkdirAll(srv.Path("from"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(srv.Path("to"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srv.Path("from/f.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + + err := actions.Move(context.Background(), + []vfs.FileRef{remoteRef(conn, path.Join(srv.Root, "from/f.txt"))}, + remoteRef(conn, path.Join(srv.Root, "to")), nil) + if err != nil { + t.Fatalf("move: %v", err) + } + + if _, err := os.Stat(srv.Path("to/f.txt")); err != nil { + t.Errorf("want the file at its new path: %v", err) + } + if _, err := os.Stat(srv.Path("from/f.txt")); !os.IsNotExist(err) { + t.Errorf("want the old path gone, stat gave %v", err) + } +} + +func TestDeleteAndMkdirOnServer(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.MkdirAll(srv.Path("doomed/inner"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srv.Path("doomed/inner/f"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + + if err := actions.Mkdir(remoteRef(conn, path.Join(srv.Root, "created"))); err != nil { + t.Fatalf("mkdir: %v", err) + } + if info, err := os.Stat(srv.Path("created")); err != nil || !info.IsDir() { + t.Errorf("want a directory created on the server: %v", err) + } + + err := actions.Delete(context.Background(), + []vfs.FileRef{remoteRef(conn, path.Join(srv.Root, "doomed"))}, nil) + if err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := os.Stat(srv.Path("doomed")); !os.IsNotExist(err) { + t.Errorf("want the tree removed from the server, stat gave %v", err) + } +} + +func TestRenameOnServer(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.WriteFile(srv.Path("before.txt"), []byte("body"), 0o644); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + + err := actions.Rename(remoteRef(conn, path.Join(srv.Root, "before.txt")), "after.txt") + if err != nil { + t.Fatalf("rename: %v", err) + } + + if _, err := os.Stat(srv.Path("after.txt")); err != nil { + t.Errorf("want the file renamed on the server: %v", err) + } +} + +func TestCancelledRemoteCopyLeavesNoPartialFile(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + _, conn := connect(t, srv) + + src := t.TempDir() + big := filepath.Join(src, "big.bin") + if err := os.WriteFile(big, make([]byte, 12<<20), 0o644); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := actions.Copy(ctx, []vfs.FileRef{localRef(big)}, remoteRef(conn, srv.Root), + func(p actions.Progress) { cancel() }) + if err == nil { + t.Fatal("want the copy to stop when cancelled") + } + + entries, err := os.ReadDir(srv.Root) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + t.Errorf("a cancelled upload left %q on the server", e.Name()) + } +} + +func TestDeletingRemoteSymlinkKeepsItsTarget(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + if err := os.MkdirAll(srv.Path("target"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srv.Path("target/keep.txt"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(srv.Path("target"), srv.Path("link")); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + + err := actions.Delete(context.Background(), + []vfs.FileRef{remoteRef(conn, path.Join(srv.Root, "link"))}, nil) + if err != nil { + t.Fatalf("delete the symlink: %v", err) + } + + if _, err := os.Lstat(srv.Path("link")); !os.IsNotExist(err) { + t.Errorf("want the symlink itself removed, stat gave %v", err) + } + got, err := os.ReadFile(srv.Path("target/keep.txt")) + if err != nil { + t.Fatalf("the link's target must survive: %v", err) + } + if string(got) != "keep" { + t.Errorf("want %q, got %q", "keep", string(got)) + } +} diff --git a/internal/remote/agent_unix.go b/internal/remote/agent_unix.go new file mode 100644 index 0000000..8cfad3e --- /dev/null +++ b/internal/remote/agent_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package remote + +import ( + "errors" + "io" + "net" + "os" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// agentSigners returns the keys ssh-agent holds. The closer must stay open +// until authentication finishes: the signers sign through this connection. +func agentSigners() ([]ssh.Signer, io.Closer, error) { + sock := os.Getenv("SSH_AUTH_SOCK") + if sock == "" { + return nil, nil, errors.New("no ssh-agent: SSH_AUTH_SOCK is not set") + } + + conn, err := net.Dial("unix", sock) + if err != nil { + return nil, nil, err + } + + signers, err := agent.NewClient(conn).Signers() + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return signers, conn, nil +} diff --git a/internal/remote/agent_windows.go b/internal/remote/agent_windows.go new file mode 100644 index 0000000..d20c794 --- /dev/null +++ b/internal/remote/agent_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package remote + +import ( + "io" + "os" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// agentPipe is where the Windows OpenSSH agent listens: there is no +// SSH_AUTH_SOCK, and the pipe opens like a file. +const agentPipe = `\\.\pipe\openssh-ssh-agent` + +func agentSigners() ([]ssh.Signer, io.Closer, error) { + f, err := os.OpenFile(agentPipe, os.O_RDWR, 0) + if err != nil { + return nil, nil, err + } + + signers, err := agent.NewClient(f).Signers() + if err != nil { + _ = f.Close() + return nil, nil, err + } + return signers, f, nil +} diff --git a/internal/remote/authchain_test.go b/internal/remote/authchain_test.go new file mode 100644 index 0000000..b244d87 --- /dev/null +++ b/internal/remote/authchain_test.go @@ -0,0 +1,113 @@ +package remote + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/ssh" + + "github.com/kooler/MiddayCommander/internal/remote/testserver" +) + +// writeThrowawayKey writes an unencrypted key the server will not accept. +func writeThrowawayKey(t *testing.T, path string) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + block, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil { + t.Fatal(err) + } +} + +// A rejected key must not block the one that works. The SSH protocol attempts +// each method name once, so separate ssh.PublicKeys methods would offer only +// the first. A decoy sits ahead of the real key in the default order. +func TestARejectedKeyDoesNotBlockAWorkingOne(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + home := os.Getenv("HOME") + sshDir := filepath.Join(home, ".ssh") + + // A key the server has never heard of, tried first. + writeThrowawayKey(t, filepath.Join(sshDir, "id_ed25519")) + + // The key the server does accept, tried second. + accepted, err := os.ReadFile(srv.ClientKey) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sshDir, "id_rsa"), accepted, 0o600); err != nil { + t.Fatal(err) + } + + // KeyPath is empty, so the default search supplies both, in order. + server := Server{Name: "test", Host: srv.Host, Port: srv.Port, User: "tester"} + + methods, closeAgent, err := authMethods(server, Credentials{}) + if err != nil { + t.Fatalf("authMethods: %v", err) + } + closeAgent() + if len(methods) != 1 { + t.Errorf("want every key in a single publickey method, got %d methods", len(methods)) + } + + r := NewRegistry() + _, err = r.Acquire(context.Background(), server, Credentials{}) + var unknown *UnknownHostError + if !errors.As(err, &unknown) { + t.Fatalf("want the host key prompt, got %v", err) + } + + conn, err := r.Acquire(context.Background(), server, + Credentials{AcceptFingerprint: unknown.Fingerprint}) + if err != nil { + t.Fatalf("the working key should still authenticate behind a rejected one: %v", err) + } + defer r.Release(conn) + + if _, err := conn.FS().ReadDir(srv.Root); err != nil { + t.Errorf("listing over the connection failed: %v", err) + } +} + +func TestNoCredentialsIsAClearError(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + // The isolated HOME has no keys and the agent is hidden. + server := Server{Name: "test", Host: srv.Host, Port: srv.Port, User: "tester"} + + _, err := NewRegistry().Acquire(context.Background(), server, Credentials{}) + if err == nil { + t.Fatal("want an error when there is nothing to authenticate with") + } + if !contains(err.Error(), "no usable credentials") { + t.Errorf("want an error naming the problem, got %v", err) + } +} + +func contains(h, n string) bool { + for i := 0; i+len(n) <= len(h); i++ { + if h[i:i+len(n)] == n { + return true + } + } + return false +} diff --git a/internal/remote/conn.go b/internal/remote/conn.go new file mode 100644 index 0000000..42fcf7c --- /dev/null +++ b/internal/remote/conn.go @@ -0,0 +1,434 @@ +package remote + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + + "github.com/kooler/MiddayCommander/internal/vfs/sftpfs" +) + +const ( + DefaultPort = 22 + + // dialTimeout bounds the connect so a dead host does not hang the panel. + dialTimeout = 15 * time.Second + + // keepaliveInterval catches a connection that died without saying so. + keepaliveInterval = 30 * time.Second +) + +// PassphraseRequiredError asks the UI to prompt and retry. +type PassphraseRequiredError struct { + KeyPath string +} + +func (e *PassphraseRequiredError) Error() string { + return fmt.Sprintf("passphrase required for %s", e.KeyPath) +} + +// Server describes how to reach a host. +type Server struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port,omitempty"` + User string `json:"user,omitempty"` + KeyPath string `json:"key_path,omitempty"` + Dir string `json:"dir,omitempty"` + + // Alias is the ~/.ssh/config name Host came from, shown in the panel + // header because it is what the user typed. Resolved at connect, not saved. + Alias string `json:"-"` +} + +func (s Server) Addr() string { + port := s.Port + if port == 0 { + port = DefaultPort + } + return net.JoinHostPort(s.Host, strconv.Itoa(port)) +} + +// effectiveUser falls back to the current login name. +func (s Server) effectiveUser() string { + if s.User != "" { + return s.User + } + if u := os.Getenv("USER"); u != "" { + return u + } + return os.Getenv("USERNAME") +} + +// Label is the panel header, e.g. "ssh://kk@host", with a non-default port +// appended. An aliased host shows under its alias. +func (s Server) Label() string { + host := s.Host + if s.Alias != "" { + host = s.Alias + } + label := "ssh://" + s.effectiveUser() + "@" + host + if s.Port != 0 && s.Port != DefaultPort { + label += ":" + strconv.Itoa(s.Port) + } + return label +} + +// key identifies a connection for sharing between panels. +func (s Server) key() string { return s.effectiveUser() + "@" + s.Addr() } + +func (s Server) DisplayName() string { + if s.Name != "" { + return s.Name + } + return s.Label() +} + +// Credentials carries what an earlier attempt asked the user for. +type Credentials struct { + Passphrase string + + // AcceptFingerprint is the fingerprint the user confirmed after an + // UnknownHostError. Any other key is then refused. + AcceptFingerprint string +} + +// Conn is a live SSH session and its filesystem. Panels share one, so it is +// reference counted. +type Conn struct { + key string + client *ssh.Client + sftp *sftp.Client + fsys *sftpfs.FS + server Server + + registry *Registry + refs int + closed chan struct{} + closeOnce sync.Once +} + +func (c *Conn) FS() *sftpfs.FS { return c.fsys } + +func (c *Conn) Server() Server { return c.server } + +// Registry shares one connection per server, so opening the same host in the +// second panel is instant. +type Registry struct { + mu sync.Mutex + conns map[string]*Conn +} + +func NewRegistry() *Registry { + return &Registry{conns: map[string]*Conn{}} +} + +// Acquire dials only when no connection is open. The caller must Release it. +func (r *Registry) Acquire(ctx context.Context, srv Server, creds Credentials) (*Conn, error) { + // Resolve before keying so two entries naming one host share a connection. + srv = srv.Resolve() + key := srv.key() + + r.mu.Lock() + if existing, ok := r.conns[key]; ok && existing.alive() { + existing.refs++ + r.mu.Unlock() + return existing, nil + } + r.mu.Unlock() + + conn, err := dial(ctx, srv, creds) + if err != nil { + return nil, err + } + conn.registry = r + conn.key = key + conn.refs = 1 + + r.mu.Lock() + // Another panel may have won the race; prefer the one already recorded. + if existing, ok := r.conns[key]; ok && existing.alive() { + existing.refs++ + r.mu.Unlock() + conn.shutdown() + return existing, nil + } + r.conns[key] = conn + r.mu.Unlock() + + go conn.keepalive() + return conn, nil +} + +// Release closes the connection once nothing holds it. +func (r *Registry) Release(c *Conn) { + if c == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + c.refs-- + if c.refs > 0 { + return + } + // A keepalive failure may already have replaced this entry with a fresh + // connection to the same host; deleting by key alone would orphan it. + if r.conns[c.key] == c { + delete(r.conns, c.key) + } + c.shutdown() +} + +// drop removes a dead connection, leaving any replacement in place. +func (r *Registry) drop(c *Conn) { + r.mu.Lock() + defer r.mu.Unlock() + if r.conns[c.key] == c { + delete(r.conns, c.key) + } +} + +// CloseAll tears down every connection at shutdown. +func (r *Registry) CloseAll() { + r.mu.Lock() + defer r.mu.Unlock() + for key, c := range r.conns { + delete(r.conns, key) + c.shutdown() + } +} + +func (c *Conn) alive() bool { + select { + case <-c.closed: + return false + default: + return true + } +} + +// shutdown is safe to call from both Release and the keepalive goroutine. +func (c *Conn) shutdown() { + c.closeOnce.Do(func() { + close(c.closed) + if c.sftp != nil { + _ = c.sftp.Close() + } + if c.client != nil { + _ = c.client.Close() + } + }) +} + +// keepalive drops the connection once the peer stops answering. +func (c *Conn) keepalive() { + ticker := time.NewTicker(keepaliveInterval) + defer ticker.Stop() + + for { + select { + case <-c.closed: + return + case <-ticker.C: + _, _, err := c.client.SendRequest("keepalive@openssh.com", true, nil) + if err != nil { + if c.registry != nil { + c.registry.drop(c) + } + c.shutdown() + return + } + } + } +} + +// dial opens one SSH session and layers SFTP on it. +func dial(ctx context.Context, srv Server, creds Credentials) (*Conn, error) { + auths, closeAgent, err := authMethods(srv, creds) + if err != nil { + return nil, err + } + defer closeAgent() + if len(auths) == 0 { + return nil, errors.New("no usable credentials: add a key file to this server, " + + "or add a key to your ssh-agent") + } + + var save func() error + var hostKey ssh.HostKeyCallback + if creds.AcceptFingerprint != "" { + hostKey, save = pinnedHostKey(srv.Addr(), creds.AcceptFingerprint) + } else { + hostKey, err = hostKeyCallback() + if err != nil { + return nil, err + } + } + + cfg := &ssh.ClientConfig{ + User: srv.effectiveUser(), + Auth: auths, + HostKeyCallback: hostKey, + Timeout: dialTimeout, + } + + d := net.Dialer{Timeout: dialTimeout} + rawConn, err := d.DialContext(ctx, "tcp", srv.Addr()) + if err != nil { + return nil, err + } + + // Neither the handshake nor the sftp startup takes a context, and + // cfg.Timeout covers only the TCP dial. Closing the socket is what makes + // them return, so a host that accepts TCP and then goes quiet cannot hold + // the panel past the caller's deadline or an Esc. + stopWatch := make(chan struct{}) + defer close(stopWatch) + go func() { + select { + case <-ctx.Done(): + _ = rawConn.Close() + case <-stopWatch: + } + }() + + // cancelled reports the context's error in preference to the connection + // reset that cancelling it caused. + cancelled := func(err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return err + } + + sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, srv.Addr(), cfg) + if err != nil { + _ = rawConn.Close() + return nil, cancelled(err) + } + client := ssh.NewClient(sshConn, chans, reqs) + + // Record the key only once the handshake succeeded. + if save != nil { + if err := save(); err != nil { + _ = client.Close() + return nil, fmt.Errorf("recording host key: %w", err) + } + } + + sftpClient, err := sftp.NewClient(client) + if err != nil { + _ = client.Close() + return nil, cancelled(fmt.Errorf("starting sftp: %w", err)) + } + + // Cancelling just as the session came up must not leave it open. + if err := ctx.Err(); err != nil { + _ = sftpClient.Close() + _ = client.Close() + return nil, err + } + + return &Conn{ + client: client, + sftp: sftpClient, + fsys: sftpfs.New(sftpClient, srv.Label()), + server: srv, + closed: make(chan struct{}), + }, nil +} + +// authMethods offers agent keys first, then key files. The returned func +// closes the agent and must run only after the handshake. +// +// Every signer goes into one publickey method: the protocol attempts each +// method name once, so a second ssh.PublicKeys is never reached. +func authMethods(srv Server, creds Credentials) ([]ssh.AuthMethod, func(), error) { + var signers []ssh.Signer + closeAgent := func() {} + + if agentKeys, closer, err := agentSigners(); err == nil && len(agentKeys) > 0 { + signers = append(signers, agentKeys...) + closeAgent = func() { _ = closer.Close() } + } + + for _, keyPath := range candidateKeys(srv) { + signer, err := loadKey(keyPath, creds.Passphrase) + if err != nil { + var needPass *ssh.PassphraseMissingError + if errors.As(err, &needPass) { + // Only prompt for a key the user named, so an unrelated + // encrypted id_rsa cannot interrupt an agent login. + if srv.KeyPath != "" { + closeAgent() + return nil, nil, &PassphraseRequiredError{KeyPath: keyPath} + } + continue + } + if srv.KeyPath != "" { + closeAgent() + return nil, nil, fmt.Errorf("reading %s: %w", keyPath, err) + } + continue + } + signers = append(signers, signer) + } + + if len(signers) == 0 { + return nil, closeAgent, nil + } + return []ssh.AuthMethod{ssh.PublicKeys(signers...)}, closeAgent, nil +} + +// candidateKeys returns the server's key file, or the usual defaults. +func candidateKeys(srv Server) []string { + if srv.KeyPath != "" { + return []string{expandHome(srv.KeyPath)} + } + + home, err := os.UserHomeDir() + if err != nil { + return nil + } + var out []string + for _, name := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} { + p := filepath.Join(home, ".ssh", name) + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out +} + +func loadKey(path, passphrase string) (ssh.Signer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if passphrase != "" { + return ssh.ParsePrivateKeyWithPassphrase(data, []byte(passphrase)) + } + return ssh.ParsePrivateKey(data) +} + +func expandHome(p string) string { + if !strings.HasPrefix(p, "~") { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + return filepath.Join(home, strings.TrimPrefix(p, "~")) +} diff --git a/internal/remote/hostkey.go b/internal/remote/hostkey.go new file mode 100644 index 0000000..d475c21 --- /dev/null +++ b/internal/remote/hostkey.go @@ -0,0 +1,143 @@ +package remote + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +// UnknownHostError means the user must confirm the fingerprint first. +type UnknownHostError struct { + Host string + Fingerprint string + KeyType string +} + +func (e *UnknownHostError) Error() string { + return fmt.Sprintf("unknown host %s (%s %s)", e.Host, e.KeyType, e.Fingerprint) +} + +// ChangedHostKeyError is never offered as a prompt: it is what interception +// looks like, and is resolved by editing known_hosts. +type ChangedHostKeyError struct { + Host string + Fingerprint string + KeyType string +} + +func (e *ChangedHostKeyError) Error() string { + return fmt.Sprintf( + "host key for %s has changed (now %s %s) — if this is not expected, "+ + "someone may be intercepting the connection; remove the old entry "+ + "from %s once you have verified the new key", + e.Host, e.KeyType, e.Fingerprint, KnownHostsPath()) +} + +func KnownHostsPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "known_hosts" + } + return filepath.Join(home, ".ssh", "known_hosts") +} + +// hostKeyCallback separates an unknown key from a changed one, so the UI can +// ask about the first and refuse the second. +func hostKeyCallback() (ssh.HostKeyCallback, error) { + path := KnownHostsPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + // knownhosts.New fails on a missing file. + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + _ = f.Close() + } + + inner, err := knownhosts.New(path) + if err != nil { + return nil, err + } + + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := inner(hostname, remote, key) + if err == nil { + return nil + } + + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) { + details := struct { + host string + fingerprint string + keyType string + }{ + host: knownhosts.Normalize(hostname), + fingerprint: ssh.FingerprintSHA256(key), + keyType: key.Type(), + } + // An empty Want means the host is not on file; a populated one + // means a different key is already recorded. + if len(keyErr.Want) == 0 { + return &UnknownHostError{ + Host: details.host, + Fingerprint: details.fingerprint, + KeyType: details.keyType, + } + } + return &ChangedHostKeyError{ + Host: details.host, + Fingerprint: details.fingerprint, + KeyType: details.keyType, + } + } + return err + }, nil +} + +// trustHost records a key, only after the user confirmed its fingerprint. +func trustHost(hostname string, key ssh.PublicKey) error { + path := KnownHostsPath() + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + + line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) + _, err = fmt.Fprintln(f, line) + return err +} + +// pinnedHostKey accepts only the key whose fingerprint the user confirmed. +// Taking whatever the retry presents would reopen the gap the prompt closes. +func pinnedHostKey(hostname, wantFingerprint string) (ssh.HostKeyCallback, func() error) { + var accepted ssh.PublicKey + + cb := func(_ string, _ net.Addr, key ssh.PublicKey) error { + got := ssh.FingerprintSHA256(key) + if got != wantFingerprint { + return fmt.Errorf( + "host key changed between the prompt and the connection (expected %s, got %s)", + wantFingerprint, got) + } + accepted = key + return nil + } + + save := func() error { + if accepted == nil { + return errors.New("no host key was presented") + } + return trustHost(hostname, accepted) + } + return cb, save +} diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go new file mode 100644 index 0000000..c8e5dcc --- /dev/null +++ b/internal/remote/remote_test.go @@ -0,0 +1,339 @@ +package remote + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kooler/MiddayCommander/internal/remote/testserver" +) + +// isolate keeps tests away from the real known_hosts, and hides any running +// agent so the key file under test is the only credential offered. +func isolate(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("SSH_AUTH_SOCK", "") +} + +func testSrv(t *testing.T, s *testserver.Server) Server { + t.Helper() + return Server{ + Name: "test", + Host: s.Host, + Port: s.Port, + User: "tester", + KeyPath: s.ClientKey, + } +} + +func TestUnknownHostIsRefusedWithFingerprint(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + r := NewRegistry() + _, err := r.Acquire(context.Background(), testSrv(t, srv), Credentials{}) + + var unknown *UnknownHostError + if !errors.As(err, &unknown) { + t.Fatalf("want *UnknownHostError on first contact, got %v", err) + } + if unknown.Fingerprint != srv.Fingerprint { + t.Errorf("want fingerprint %s, got %s", srv.Fingerprint, unknown.Fingerprint) + } +} + +func TestAcceptingHostKeyRecordsItAndConnects(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + server := testSrv(t, srv) + r := NewRegistry() + + // First contact is refused with the fingerprint to show the user. + _, err := r.Acquire(context.Background(), server, Credentials{}) + var unknown *UnknownHostError + if !errors.As(err, &unknown) { + t.Fatalf("want *UnknownHostError, got %v", err) + } + + // The user confirms, so the retry carries the fingerprint they accepted. + conn, err := r.Acquire(context.Background(), server, + Credentials{AcceptFingerprint: unknown.Fingerprint}) + if err != nil { + t.Fatalf("connect after accepting: %v", err) + } + defer r.Release(conn) + + data, err := os.ReadFile(KnownHostsPath()) + if err != nil { + t.Fatalf("read known_hosts: %v", err) + } + if !strings.Contains(string(data), "ssh-ed25519") { + t.Errorf("want the host key recorded in known_hosts, got %q", string(data)) + } + + // A later connection needs no confirmation. + r.Release(conn) + again, err := r.Acquire(context.Background(), server, Credentials{}) + if err != nil { + t.Fatalf("second connect should trust the recorded key: %v", err) + } + r.Release(again) +} + +func TestRetryRejectsADifferentKeyThanWasConfirmed(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + // The host presents a different key than was confirmed, which is the + // window interception would use. + r := NewRegistry() + _, err := r.Acquire(context.Background(), testSrv(t, srv), Credentials{ + AcceptFingerprint: "SHA256:definitelyNotTheKeyYouWereShown", + }) + if err == nil { + t.Fatal("want an error when the presented key differs from the confirmed one") + } + if _, statErr := os.Stat(KnownHostsPath()); statErr == nil { + data, _ := os.ReadFile(KnownHostsPath()) + if strings.Contains(string(data), "ssh-ed25519") { + t.Error("a rejected key must not be recorded in known_hosts") + } + } +} + +func TestChangedHostKeyIsRefusedNotPrompted(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + server := testSrv(t, srv) + r := NewRegistry() + + _, err := r.Acquire(context.Background(), server, Credentials{}) + var unknown *UnknownHostError + if !errors.As(err, &unknown) { + t.Fatalf("want *UnknownHostError, got %v", err) + } + conn, err := r.Acquire(context.Background(), server, + Credentials{AcceptFingerprint: unknown.Fingerprint}) + if err != nil { + t.Fatalf("initial connect: %v", err) + } + r.Release(conn) + + // The host now answers with a different key. + srv.RotateHostKey(t) + + _, err = NewRegistry().Acquire(context.Background(), server, Credentials{}) + var changed *ChangedHostKeyError + if !errors.As(err, &changed) { + t.Fatalf("want *ChangedHostKeyError, got %v", err) + } + var stillUnknown *UnknownHostError + if errors.As(err, &stillUnknown) { + t.Error("a changed host key must never be offered as a confirmation prompt") + } +} + +func TestRegistrySharesOneConnectionPerServer(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + server := testSrv(t, srv) + r := NewRegistry() + + _, err := r.Acquire(context.Background(), server, Credentials{}) + var unknown *UnknownHostError + if !errors.As(err, &unknown) { + t.Fatalf("want *UnknownHostError, got %v", err) + } + creds := Credentials{AcceptFingerprint: unknown.Fingerprint} + + first, err := r.Acquire(context.Background(), server, creds) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + second, err := r.Acquire(context.Background(), server, Credentials{}) + if err != nil { + t.Fatalf("second acquire: %v", err) + } + + if first != second { + t.Error("want both panels to share one connection to the same server") + } + + // One release leaves it usable for the other holder. + r.Release(second) + if !first.alive() { + t.Fatal("connection closed while still in use by another panel") + } + if _, err := first.FS().ReadDir(srv.Root); err != nil { + t.Errorf("shared connection should still work: %v", err) + } + + // The last release tears it down. + r.Release(first) + if first.alive() { + t.Error("want the connection closed once nothing holds it") + } +} + +// connect is the two-step dance the UI performs, condensed for tests that are +// not about host key handling. +func connect(t *testing.T, srv *testserver.Server) (*Registry, *Conn) { + t.Helper() + + server := testSrv(t, srv) + r := NewRegistry() + + _, err := r.Acquire(context.Background(), server, Credentials{}) + var unknown *UnknownHostError + if !errors.As(err, &unknown) { + t.Fatalf("want *UnknownHostError, got %v", err) + } + + conn, err := r.Acquire(context.Background(), server, + Credentials{AcceptFingerprint: unknown.Fingerprint}) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { r.Release(conn) }) + return r, conn +} + +func TestBrowseAndWriteOverSFTP(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + + if err := os.WriteFile(srv.Path("hello.txt"), []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(srv.Path("nested/deep"), 0o755); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + fsys := conn.FS() + + // SFTP serves the whole filesystem by absolute path, so the test browses + // the served directory by its real path, exactly as a panel would. + base := srv.Root + entries, err := fsys.ReadDir(base) + if err != nil { + t.Fatalf("readdir: %v", err) + } + names := map[string]bool{} + for _, e := range entries { + names[e.Name()] = true + } + if !names["hello.txt"] || !names["nested"] { + t.Errorf("want hello.txt and nested in the listing, got %v", names) + } + + // Create, rename, stat, remove. + w, err := fsys.Create(base + "/made.txt") + if err != nil { + t.Fatalf("create: %v", err) + } + if _, err := w.(interface{ Write([]byte) (int, error) }).Write([]byte("body")); err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if got, err := os.ReadFile(srv.Path("made.txt")); err != nil || string(got) != "body" { + t.Errorf("want %q written through sftp, got %q (%v)", "body", string(got), err) + } + + if err := fsys.Mkdir(base+"/fresh", 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if info, err := os.Stat(srv.Path("fresh")); err != nil || !info.IsDir() { + t.Errorf("want a directory created on the server: %v", err) + } + + if err := fsys.Rename(base+"/made.txt", base+"/renamed.txt"); err != nil { + t.Fatalf("rename: %v", err) + } + if _, err := os.Stat(srv.Path("renamed.txt")); err != nil { + t.Errorf("want the renamed file on the server: %v", err) + } + + if err := fsys.RemoveAll(base + "/nested"); err != nil { + t.Fatalf("removeall: %v", err) + } + if _, err := os.Stat(srv.Path("nested")); !os.IsNotExist(err) { + t.Errorf("want the tree removed, stat gave %v", err) + } +} + +func TestRemotePathsStaySlashSeparated(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + if err := os.MkdirAll(srv.Path("a/b"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(srv.Path("a/b/c.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + _, conn := connect(t, srv) + + // A path built the way the panel builds one must reach the file. On + // Windows a filepath.Join here would produce backslashes and fail. + if _, err := conn.FS().Stat(srv.Root + "/a/b/c.txt"); err != nil { + t.Errorf("stat through a nested remote path: %v", err) + } + + entries, err := conn.FS().ReadDir(srv.Root + "/a/b") + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(entries) != 1 || entries[0].Name() != "c.txt" { + t.Errorf("want one entry named c.txt, got %v", entries) + } +} + +func TestHomeReturnsWorkingDirectory(t *testing.T) { + isolate(t) + srv := testserver.Start(t) + _, conn := connect(t, srv) + + if home := conn.FS().Home(); !strings.HasPrefix(home, "/") { + t.Errorf("want an absolute remote home, got %q", home) + } +} + +func TestServerLabelAndAddr(t *testing.T) { + s := Server{Host: "example.com", User: "kk"} + if got := s.Addr(); got != "example.com:22" { + t.Errorf("want the default port applied, got %q", got) + } + if got := s.Label(); got != "ssh://kk@example.com" { + t.Errorf("want ssh://kk@example.com, got %q", got) + } + + s.Port = 2222 + if got := s.Addr(); got != "example.com:2222" { + t.Errorf("want example.com:2222, got %q", got) + } + if got := s.Label(); got != "ssh://kk@example.com:2222" { + t.Errorf("want the non-default port in the label, got %q", got) + } +} + +func TestExpandHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + if got := expandHome("~/.ssh/id_ed25519"); got != filepath.Join(home, ".ssh", "id_ed25519") { + t.Errorf("want the home directory substituted, got %q", got) + } + if got := expandHome("/etc/keys/id"); got != "/etc/keys/id" { + t.Errorf("want an absolute path left alone, got %q", got) + } +} diff --git a/internal/remote/sshconfig.go b/internal/remote/sshconfig.go new file mode 100644 index 0000000..1dbc4c3 --- /dev/null +++ b/internal/remote/sshconfig.go @@ -0,0 +1,78 @@ +package remote + +import ( + "os" + "path/filepath" + "strconv" + + "github.com/kevinburke/ssh_config" +) + +// Host aliases from ~/.ssh/config. Only settings the user wrote are read: a +// library default for IdentityFile or Port would override the server entry. +// +// ProxyJump is not implemented, so an alias needing a bastion fails to dial +// rather than connecting somewhere unexpected. + +func sshConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".ssh", "config") +} + +// lookupSSHConfig returns "" when the file is absent, unreadable, or silent +// on the alias. +func lookupSSHConfig(alias, key string) string { + path := sshConfigPath() + if path == "" { + return "" + } + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + + cfg, err := ssh_config.Decode(f) + if err != nil { + return "" + } + value, err := cfg.Get(alias, key) + if err != nil { + return "" + } + return value +} + +// Resolve fills unset fields from the matching Host block. Values already on +// the server win. +func (s Server) Resolve() Server { + alias := s.Host + if alias == "" { + return s + } + + if hostName := lookupSSHConfig(alias, "HostName"); hostName != "" && hostName != alias { + s.Alias = alias + s.Host = hostName + } + if s.User == "" { + s.User = lookupSSHConfig(alias, "User") + } + if s.Port == 0 { + if p := lookupSSHConfig(alias, "Port"); p != "" { + if port, err := strconv.Atoi(p); err == nil && port > 0 && port <= 65535 { + s.Port = port + } + } + } + if s.KeyPath == "" { + if id := lookupSSHConfig(alias, "IdentityFile"); id != "" { + s.KeyPath = expandHome(id) + } + } + + return s +} diff --git a/internal/remote/sshconfig_test.go b/internal/remote/sshconfig_test.go new file mode 100644 index 0000000..655a280 --- /dev/null +++ b/internal/remote/sshconfig_test.go @@ -0,0 +1,142 @@ +package remote + +import ( + "os" + "path/filepath" + "testing" +) + +func writeSSHConfig(t *testing.T, body string) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".ssh", "config") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return home +} + +func TestResolveFillsFromSSHConfig(t *testing.T) { + home := writeSSHConfig(t, ` +Host prod + HostName prod.internal.example.com + User deploy + Port 2222 + IdentityFile ~/.ssh/deploy_ed25519 +`) + + got := Server{Name: "prod", Host: "prod"}.Resolve() + + if got.Host != "prod.internal.example.com" { + t.Errorf("HostName: want prod.internal.example.com, got %q", got.Host) + } + if got.Alias != "prod" { + t.Errorf("want the alias remembered, got %q", got.Alias) + } + if got.User != "deploy" { + t.Errorf("User: want deploy, got %q", got.User) + } + if got.Port != 2222 { + t.Errorf("Port: want 2222, got %d", got.Port) + } + want := filepath.Join(home, ".ssh", "deploy_ed25519") + if got.KeyPath != want { + t.Errorf("IdentityFile: want %q, got %q", want, got.KeyPath) + } + + // The panel header shows the alias, not the machine behind it. + if label := got.Label(); label != "ssh://deploy@prod:2222" { + t.Errorf("want the alias in the label, got %q", label) + } +} + +func TestResolveDoesNotOverrideExplicitSettings(t *testing.T) { + writeSSHConfig(t, ` +Host box + HostName box.example.com + User fromconfig + Port 2222 + IdentityFile ~/.ssh/fromconfig +`) + + got := Server{ + Host: "box", + User: "explicit", + Port: 2022, + KeyPath: "/keys/explicit", + }.Resolve() + + if got.User != "explicit" { + t.Errorf("want the configured user kept, got %q", got.User) + } + if got.Port != 2022 { + t.Errorf("want the configured port kept, got %d", got.Port) + } + if got.KeyPath != "/keys/explicit" { + t.Errorf("want the configured key kept, got %q", got.KeyPath) + } + // HostName still applies: it says where the alias points. + if got.Host != "box.example.com" { + t.Errorf("want the alias resolved, got %q", got.Host) + } +} + +func TestResolveAppliesNoDefaults(t *testing.T) { + // A config that mentions the host but sets nothing relevant must not + // introduce the library's built-in defaults (Port 22, ~/.ssh/identity). + writeSSHConfig(t, ` +Host somewhere + Compression yes +`) + + got := Server{Host: "somewhere"}.Resolve() + + if got.Port != 0 { + t.Errorf("want the port left unset, got %d", got.Port) + } + if got.KeyPath != "" { + t.Errorf("want no identity file invented, got %q", got.KeyPath) + } + if got.Alias != "" { + t.Errorf("want no alias when HostName is absent, got %q", got.Alias) + } + if got.Host != "somewhere" { + t.Errorf("want the host unchanged, got %q", got.Host) + } +} + +func TestResolveWithoutAnSSHConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + got := Server{Host: "plain.example.com", User: "kk"}.Resolve() + + if got.Host != "plain.example.com" || got.User != "kk" || got.Port != 0 { + t.Errorf("a missing ssh config should change nothing, got %+v", got) + } +} + +func TestResolveWildcardHostBlock(t *testing.T) { + writeSSHConfig(t, ` +Host *.example.com + User wildcard + +Host specific.example.com + Port 2200 +`) + + got := Server{Host: "specific.example.com"}.Resolve() + + if got.User != "wildcard" { + t.Errorf("want the wildcard block applied, got %q", got.User) + } + if got.Port != 2200 { + t.Errorf("want the specific block applied, got %d", got.Port) + } +} diff --git a/internal/remote/store.go b/internal/remote/store.go new file mode 100644 index 0000000..9d92527 --- /dev/null +++ b/internal/remote/store.go @@ -0,0 +1,178 @@ +package remote + +import ( + "encoding/json" + "errors" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// SavedServer adds the usage counters that order the list. +type SavedServer struct { + Server + Count int `json:"count"` + LastUsed time.Time `json:"last_used"` +} + +// Store persists the saved servers to ~/.config/mdc/servers.json. It holds no +// passwords: authentication is by agent or key file. +type Store struct { + Servers []SavedServer `json:"servers"` + path string +} + +// LoadStore returns an empty store when the file does not exist yet. +func LoadStore() *Store { + s := &Store{path: storePath()} + + data, err := os.ReadFile(s.path) + if err != nil { + return s + } + _ = json.Unmarshal(data, s) + return s +} + +func (s *Store) Save() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0o600) +} + +// Add replaces any server already saved under the same name. +func (s *Store) Add(srv Server) { + for i, existing := range s.Servers { + if existing.Name == srv.Name { + s.Servers[i].Server = srv + return + } + } + s.Servers = append(s.Servers, SavedServer{Server: srv, LastUsed: time.Now()}) +} + +func (s *Store) Remove(name string) { + for i, existing := range s.Servers { + if existing.Name == name { + s.Servers = append(s.Servers[:i], s.Servers[i+1:]...) + return + } + } +} + +func (s *Store) Touch(name string) { + for i, existing := range s.Servers { + if existing.Name == name { + s.Servers[i].Count++ + s.Servers[i].LastUsed = time.Now() + return + } + } +} + +func (s *Store) Find(name string) (Server, bool) { + for _, existing := range s.Servers { + if existing.Name == name { + return existing.Server, true + } + } + return Server{}, false +} + +// Sorted returns the most-used and most-recent first, as bookmarks are. +func (s *Store) Sorted() []SavedServer { + out := make([]SavedServer, len(s.Servers)) + copy(out, s.Servers) + + now := time.Now() + sort.SliceStable(out, func(i, j int) bool { + return frecency(out[i], now) > frecency(out[j], now) + }) + return out +} + +func frecency(s SavedServer, now time.Time) float64 { + hoursSince := now.Sub(s.LastUsed).Hours() + recency := math.Max(0, 100-hoursSince) + return float64(s.Count)*10 + recency +} + +func storePath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "mdc", "servers.json") + } + home, err := os.UserHomeDir() + if err != nil { + return "servers.json" + } + return filepath.Join(home, ".config", "mdc", "servers.json") +} + +// ParseURL reads an ssh://[user@]host[:port][/path] address. +func ParseURL(raw string) (Server, string, error) { + rest, ok := strings.CutPrefix(raw, "ssh://") + if !ok { + return Server{}, "", errors.New("not an ssh:// address") + } + if rest == "" { + return Server{}, "", errors.New("ssh:// address has no host") + } + + // The host part never contains a slash. + hostPart, pathPart := rest, "" + if i := strings.IndexByte(rest, '/'); i >= 0 { + hostPart, pathPart = rest[:i], rest[i:] + } + + var srv Server + if user, hostPort, found := strings.Cut(hostPart, "@"); found { + srv.User = user + hostPart = hostPort + } + + if host, portStr, found := strings.Cut(hostPart, ":"); found { + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + return Server{}, "", errors.New("invalid port in ssh:// address") + } + srv.Host = host + srv.Port = port + } else { + srv.Host = hostPart + } + + if srv.Host == "" { + return Server{}, "", errors.New("ssh:// address has no host") + } + + if pathPart == "" { + pathPart = "/" + } + return srv, pathPart, nil +} + +func IsURL(raw string) bool { + return strings.HasPrefix(raw, "ssh://") +} + +// URL renders the ssh:// address for a path on a server. +func URL(srv Server, remotePath string) string { + if remotePath == "" { + remotePath = "/" + } + if !strings.HasPrefix(remotePath, "/") { + remotePath = "/" + remotePath + } + return srv.Label() + remotePath +} diff --git a/internal/remote/store_test.go b/internal/remote/store_test.go new file mode 100644 index 0000000..2dae22a --- /dev/null +++ b/internal/remote/store_test.go @@ -0,0 +1,141 @@ +package remote + +import ( + "path/filepath" + "testing" +) + +func TestParseURL(t *testing.T) { + tests := []struct { + raw string + wantHost string + wantUser string + wantPort int + wantPath string + wantErr bool + }{ + {raw: "ssh://host", wantHost: "host", wantPath: "/"}, + {raw: "ssh://kk@host", wantHost: "host", wantUser: "kk", wantPath: "/"}, + {raw: "ssh://kk@host:2222", wantHost: "host", wantUser: "kk", wantPort: 2222, wantPath: "/"}, + {raw: "ssh://kk@host/var/log", wantHost: "host", wantUser: "kk", wantPath: "/var/log"}, + {raw: "ssh://host:2222/srv/app", wantHost: "host", wantPort: 2222, wantPath: "/srv/app"}, + {raw: "ssh://kk@host/", wantHost: "host", wantUser: "kk", wantPath: "/"}, + {raw: "/home/kk", wantErr: true}, + {raw: "ssh://", wantErr: true}, + {raw: "ssh://host:notaport", wantErr: true}, + {raw: "ssh://host:99999", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.raw, func(t *testing.T) { + srv, remotePath, err := ParseURL(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("want an error for %q", tc.raw) + } + return + } + if err != nil { + t.Fatalf("parse %q: %v", tc.raw, err) + } + if srv.Host != tc.wantHost { + t.Errorf("host: want %q, got %q", tc.wantHost, srv.Host) + } + if srv.User != tc.wantUser { + t.Errorf("user: want %q, got %q", tc.wantUser, srv.User) + } + if srv.Port != tc.wantPort { + t.Errorf("port: want %d, got %d", tc.wantPort, srv.Port) + } + if remotePath != tc.wantPath { + t.Errorf("path: want %q, got %q", tc.wantPath, remotePath) + } + }) + } +} + +func TestURLRoundTrip(t *testing.T) { + srv := Server{Host: "host", User: "kk", Port: 2222} + raw := URL(srv, "/var/log") + if raw != "ssh://kk@host:2222/var/log" { + t.Fatalf("want ssh://kk@host:2222/var/log, got %q", raw) + } + + back, remotePath, err := ParseURL(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if back.Host != srv.Host || back.User != srv.User || back.Port != srv.Port { + t.Errorf("round trip lost detail: %+v", back) + } + if remotePath != "/var/log" { + t.Errorf("path: want /var/log, got %q", remotePath) + } +} + +func TestStorePersistsServers(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + s := LoadStore() + if len(s.Servers) != 0 { + t.Fatalf("want an empty store, got %d servers", len(s.Servers)) + } + + s.Add(Server{Name: "prod", Host: "prod.example.com", User: "deploy", Dir: "/srv"}) + s.Add(Server{Name: "staging", Host: "staging.example.com"}) + if err := s.Save(); err != nil { + t.Fatalf("save: %v", err) + } + + if _, err := filepath.Glob(filepath.Join(dir, "mdc", "servers.json")); err != nil { + t.Fatal(err) + } + + reloaded := LoadStore() + if len(reloaded.Servers) != 2 { + t.Fatalf("want 2 servers after reload, got %d", len(reloaded.Servers)) + } + got, ok := reloaded.Find("prod") + if !ok { + t.Fatal("want to find the prod server") + } + if got.Host != "prod.example.com" || got.User != "deploy" || got.Dir != "/srv" { + t.Errorf("server did not round trip: %+v", got) + } +} + +func TestStoreAddReplacesByName(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + s := LoadStore() + s.Add(Server{Name: "box", Host: "old.example.com"}) + s.Add(Server{Name: "box", Host: "new.example.com"}) + + if len(s.Servers) != 1 { + t.Fatalf("want one server, got %d", len(s.Servers)) + } + if s.Servers[0].Host != "new.example.com" { + t.Errorf("want the host replaced, got %q", s.Servers[0].Host) + } +} + +func TestStoreRemoveAndSort(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + s := LoadStore() + s.Add(Server{Name: "a", Host: "a.example.com"}) + s.Add(Server{Name: "b", Host: "b.example.com"}) + s.Touch("b") + s.Touch("b") + + sorted := s.Sorted() + if len(sorted) != 2 || sorted[0].Name != "b" { + t.Errorf("want the most-used server first, got %v", sorted) + } + + s.Remove("a") + if _, ok := s.Find("a"); ok { + t.Error("want the removed server gone") + } +} diff --git a/internal/remote/testserver/testserver.go b/internal/remote/testserver/testserver.go new file mode 100644 index 0000000..68962bb --- /dev/null +++ b/internal/remote/testserver/testserver.go @@ -0,0 +1,242 @@ +// Package testserver runs an SSH server with an SFTP subsystem in the test +// process, so the remote code paths need no Docker and no real host. +package testserver + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type Server struct { + Addr string // host:port + Host string + Port int + Root string // directory the SFTP subsystem serves + Fingerprint string // SHA256 fingerprint of the host key + ClientKey string // path to the private key clients authenticate with + + listener net.Listener + hostKey ssh.Signer + wg sync.WaitGroup + mu sync.Mutex + closed bool +} + +// Start serves a fresh temporary directory, and stops when the test ends. +func Start(t *testing.T) *Server { + t.Helper() + + hostKey := mustSigner(t) + clientKeyPath, clientSigner := writeClientKey(t) + authorized := clientSigner.PublicKey() + + cfg := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) == string(authorized.Marshal()) { + return &ssh.Permissions{}, nil + } + return nil, fmt.Errorf("unknown public key") + }, + } + cfg.AddHostKey(hostKey) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + host, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port := 0 + _, _ = fmt.Sscanf(portStr, "%d", &port) + + s := &Server{ + Addr: ln.Addr().String(), + Host: host, + Port: port, + Root: t.TempDir(), + Fingerprint: ssh.FingerprintSHA256(hostKey.PublicKey()), + ClientKey: clientKeyPath, + listener: ln, + hostKey: hostKey, + } + + s.wg.Add(1) + go s.serve(cfg) + + t.Cleanup(s.Close) + return s +} + +// RotateHostKey restarts on the same port with a different host key, which +// is what interception looks like. +func (s *Server) RotateHostKey(t *testing.T) { + t.Helper() + + s.Close() + s.wg.Wait() + + hostKey := mustSigner(t) + _, clientSigner := loadClientKey(t, s.ClientKey) + authorized := clientSigner.PublicKey() + + cfg := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) == string(authorized.Marshal()) { + return &ssh.Permissions{}, nil + } + return nil, fmt.Errorf("unknown public key") + }, + } + cfg.AddHostKey(hostKey) + + ln, err := net.Listen("tcp", s.Addr) + if err != nil { + t.Fatalf("relisten: %v", err) + } + + s.mu.Lock() + s.listener = ln + s.closed = false + s.hostKey = hostKey + s.Fingerprint = ssh.FingerprintSHA256(hostKey.PublicKey()) + s.mu.Unlock() + + s.wg.Add(1) + go s.serve(cfg) + t.Cleanup(s.Close) +} + +// Close stops accepting connections. +func (s *Server) Close() { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + s.closed = true + _ = s.listener.Close() +} + +func (s *Server) serve(cfg *ssh.ServerConfig) { + defer s.wg.Done() + for { + conn, err := s.listener.Accept() + if err != nil { + return // listener closed + } + go s.handle(conn, cfg) + } +} + +func (s *Server) handle(nConn net.Conn, cfg *ssh.ServerConfig) { + defer nConn.Close() + + sshConn, chans, reqs, err := ssh.NewServerConn(nConn, cfg) + if err != nil { + return + } + defer sshConn.Close() + go ssh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(ssh.UnknownChannelType, "only sessions") + continue + } + ch, chReqs, err := newChan.Accept() + if err != nil { + return + } + go s.session(ch, chReqs) + } +} + +func (s *Server) session(ch ssh.Channel, reqs <-chan *ssh.Request) { + for req := range reqs { + if req.Type != "subsystem" || len(req.Payload) < 4 || + string(req.Payload[4:]) != "sftp" { + _ = req.Reply(false, nil) + continue + } + _ = req.Reply(true, nil) + + srv, err := sftp.NewServer(ch, sftp.WithServerWorkingDirectory(s.Root)) + if err != nil { + _ = ch.Close() + return + } + if err := srv.Serve(); err != nil && err != io.EOF { + _ = srv.Close() + } + _ = ch.Close() + return + } +} + +// Path joins a name onto the server's served directory, for assertions made +// from the test's side of the connection. +func (s *Server) Path(name string) string { return filepath.Join(s.Root, name) } + +func mustSigner(t *testing.T) ssh.Signer { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("host signer: %v", err) + } + return signer +} + +// writeClientKey generates a key pair and writes the private half where a +// Server config can point at it. +func writeClientKey(t *testing.T) (string, ssh.Signer) { + t.Helper() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate client key: %v", err) + } + + block, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("marshal client key: %v", err) + } + + path := filepath.Join(t.TempDir(), "id_ed25519") + if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil { + t.Fatalf("write client key: %v", err) + } + + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("client signer: %v", err) + } + return path, signer +} + +func loadClientKey(t *testing.T, path string) (string, ssh.Signer) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read client key: %v", err) + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + t.Fatalf("parse client key: %v", err) + } + return path, signer +} diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index d87e532..6bee4ed 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.go @@ -22,17 +22,17 @@ type DismissMsg struct{} // Model is the bookmark list overlay. type Model struct { - store *bookmark.Store - items []bookmark.Bookmark - cursor int - offset int - width int - height int + store *bookmark.Store + items []bookmark.Bookmark + cursor int + offset int + width int + height int filter string // search/filter query filtering bool // true when filter input is active adding bool // true when prompting for bookmark name - addPath string // path being bookmarked - addName string // name being typed + addPath string // path being bookmarked + addName string // name being typed } // New creates a new bookmark list overlay. diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 2c96e73..0189595 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -50,16 +50,18 @@ type Model struct { inputPos int basePath string suggestions []string + masked bool // render the text as dots, for passphrases // Progress dialog - totalFiles int - doneFiles int - totalBytes int64 - doneBytes int64 - fileTotalBytes int64 - fileDoneBytes int64 - current string + totalFiles int + doneFiles int + totalBytes int64 + doneBytes int64 + fileTotalBytes int64 + fileDoneBytes int64 + current string cancelRequested bool + connecting bool // progress dialog with no measurable progress // State done bool @@ -98,6 +100,19 @@ func NewInputWithBase(title, message, defaultValue, tag, basePath string) Model } } +// NewPassword hides the text on screen, for key passphrases. The value is +// never stored: it goes straight to the connection attempt. +func NewPassword(title, message, tag string) Model { + return Model{ + kind: KindInput, + title: title, + message: message, + tag: tag, + masked: true, + width: 58, + } +} + // NewError creates an error display dialog. func NewError(title, message string) Model { return Model{ @@ -118,6 +133,13 @@ func NewProgress(title, tag string) Model { } } +// SetConnecting drops the bars for a plain waiting notice: a connection has +// no byte count to show. +func (m *Model) SetConnecting(target string) { + m.connecting = true + m.current = target +} + // Done returns true when the dialog has been dismissed. func (m Model) Done() bool { return m.done @@ -225,15 +247,33 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { case "end": m.inputPos = len(m.input) default: - if len(msg.String()) == 1 && msg.String()[0] >= 32 { - m.input = m.input[:m.inputPos] + msg.String() + m.input[m.inputPos:] - m.inputPos++ - m.updateSuggestions() + text := insertableText(msg) + if text == "" { + return nil } + m.input = m.input[:m.inputPos] + text + m.input[m.inputPos:] + m.inputPos += len(text) + m.updateSuggestions() } return nil } +// insertableText returns the characters a key carries, or "" if it is not +// text. A paste arrives as one message of many runes, so the length is not +// limited to one; a length test would drop pastes and non-ASCII characters. +func insertableText(msg tea.KeyMsg) string { + if msg.Alt { + return "" + } + switch msg.Type { + case tea.KeyRunes: + return string(msg.Runes) + case tea.KeySpace: + return " " + } + return "" +} + func (m *Model) updateSuggestions() { if m.tag != "goto" { m.suggestions = nil @@ -296,8 +336,12 @@ func (m Model) BoxSize(screenWidth, screenHeight int) (int, int) { h := 2 + 1 + msgLines + 1 + 1 // borders + blank + content + blank + footer switch m.kind { case KindProgress: - // current file label + file bar + spacer + total label + total bar - h += 5 + if m.connecting { + h++ // just the target line + } else { + // current file label + file bar + spacer + total label + total bar + h += 5 + } } maxH := screenHeight * 3 / 4 if h > maxH { @@ -339,9 +383,15 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { maxInput = 1 } + // One single-byte star per byte keeps the cursor offsets below valid. + shown := m.input + if m.masked { + shown = strings.Repeat("*", len(m.input)) + } + // Determine visible window of text around the cursor. visStart := 0 - visEnd := len(m.input) + visEnd := len(shown) if visEnd-visStart > maxInput { // Keep cursor visible with some context on both sides. visStart = m.inputPos - maxInput/2 @@ -349,8 +399,8 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { visStart = 0 } visEnd = visStart + maxInput - if visEnd > len(m.input) { - visEnd = len(m.input) + if visEnd > len(shown) { + visEnd = len(shown) visStart = visEnd - maxInput if visStart < 0 { visStart = 0 @@ -359,14 +409,14 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } cursorStyle := lipgloss.NewStyle().Background(highlight).Foreground(bg) - before := m.input[visStart:m.inputPos] + before := shown[visStart:m.inputPos] after := "" cursorCh := " " - if m.inputPos < len(m.input) { - cursorCh = string(m.input[m.inputPos]) - after = m.input[m.inputPos+1 : visEnd] - } else if visEnd < len(m.input) { - after = m.input[m.inputPos:visEnd] + if m.inputPos < len(shown) { + cursorCh = string(shown[m.inputPos]) + after = shown[m.inputPos+1 : visEnd] + } else if visEnd < len(shown) { + after = shown[m.inputPos:visEnd] } line := dimStyle.Render(label) + inputStyle.Render(before) + @@ -404,6 +454,12 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { // already rendered above case KindProgress: + if m.connecting { + line := bgStyle.Render(" " + padRight(m.current, innerW-1)) + contentLines = append(contentLines, line) + break + } + barWidth := innerW - 2 if barWidth < 1 { barWidth = 1 @@ -433,8 +489,16 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { // Spacer contentLines = append(contentLines, bgStyle.Render(strings.Repeat(" ", innerW))) - // Total summary line - totalLabel := fmt.Sprintf("Total: %d / %d files", m.doneFiles, m.totalFiles) + // A remote source has no pre-counted total, so show what is done. + var totalLabel string + if m.totalFiles > 0 { + totalLabel = fmt.Sprintf("Total: %d / %d files", m.doneFiles, m.totalFiles) + } else { + totalLabel = fmt.Sprintf("Total: %d files", m.doneFiles) + if m.doneBytes > 0 { + totalLabel += " " + formatBytes(m.doneBytes) + } + } if m.totalBytes > 0 { totalLabel += fmt.Sprintf(" %s / %s", formatBytes(m.doneBytes), formatBytes(m.totalBytes)) @@ -487,9 +551,8 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { footer = keyStyle.Render(" Esc") + dimStyle.Render(":Cancel") } case KindError: - footer = keyStyle.Render(" Enter") + dimStyle.Render(":Close") + - dimStyle.Render(" ") + - keyStyle.Render("Esc") + dimStyle.Render(":Close") + // Enter and q also close; one hint is enough. + footer = keyStyle.Render(" Esc") + dimStyle.Render(":Close") } footerWidth := lipgloss.Width(footer) if footerWidth < innerW { diff --git a/internal/ui/dialog/dialog_test.go b/internal/ui/dialog/dialog_test.go new file mode 100644 index 0000000..22b1d96 --- /dev/null +++ b/internal/ui/dialog/dialog_test.go @@ -0,0 +1,118 @@ +package dialog + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kooler/MiddayCommander/internal/ui/theme" +) + +func runes(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +// A paste arrives as one key message of many runes, which is how an ssh:// +// address reaches Go To. +func TestInputAcceptsPastedText(t *testing.T) { + m := NewInput("Go To", "Path:", "", "goto") + m.Update(runes("ssh://kk@host/var/log")) + + if m.input != "ssh://kk@host/var/log" { + t.Errorf("want the whole pasted address, got %q", m.input) + } + if m.inputPos != len(m.input) { + t.Errorf("want the cursor at the end, got %d of %d", m.inputPos, len(m.input)) + } +} + +func TestInputAcceptsNonASCII(t *testing.T) { + m := NewInput("Rename", "New name:", "", "rename") + m.Update(runes("café")) + + if m.input != "café" { + t.Errorf("want multi-byte characters accepted, got %q", m.input) + } +} + +func TestInputInsertsAtTheCursor(t *testing.T) { + m := NewInput("Go To", "Path:", "/varlog", "goto") + for i := 0; i < 3; i++ { + m.Update(tea.KeyMsg{Type: tea.KeyLeft}) + } + m.Update(runes("/")) + + if m.input != "/var/log" { + t.Errorf("want the text inserted at the cursor, got %q", m.input) + } +} + +func TestInputIgnoresNonTextKeys(t *testing.T) { + m := NewInput("Go To", "Path:", "", "goto") + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s"), Alt: true}) + + if m.input != "" { + t.Errorf("want alt-modified keys ignored, got %q", m.input) + } +} + +func TestPasswordMasksButKeepsTheValue(t *testing.T) { + m := NewPassword("Key passphrase", "Passphrase:", "passphrase") + m.Update(runes("hunter2")) + + if m.input != "hunter2" { + t.Errorf("want the real value kept, got %q", m.input) + } + + out := m.View(theme.Theme{}, 80, 24) + if contains(out, "hunter2") { + t.Error("the passphrase must not appear on screen") + } + if !contains(out, "*******") { + t.Error("want the passphrase shown as stars") + } + + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if r := m.GetResult(); !r.Confirmed || r.Text != "hunter2" { + t.Errorf("want the value returned on Enter, got %+v", r) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 +} + +func indexOf(h, n string) int { + for i := 0; i+len(n) <= len(h); i++ { + if h[i:i+len(n)] == n { + return i + } + } + return -1 +} + +func TestErrorDialogAdvertisesOneCloseKey(t *testing.T) { + m := NewError("Connection failed", "ssh: handshake failed") + out := m.View(theme.Theme{}, 60, 20) + + if !contains(out, "Esc") { + t.Error("want Esc offered as the way to close") + } + if contains(out, "Enter:Close") { + t.Error("Enter and Esc both closing does not need two hints") + } +} + +func TestErrorDialogStillClosesOnEnter(t *testing.T) { + for _, k := range []tea.KeyMsg{ + {Type: tea.KeyEsc}, + {Type: tea.KeyEnter}, + {Type: tea.KeyRunes, Runes: []rune("q")}, + } { + m := NewError("Connection failed", "boom") + m.Update(k) + if !m.Done() { + t.Errorf("%v should close the error dialog", k) + } + } +} diff --git a/internal/ui/fuzzy/fuzzy.go b/internal/ui/fuzzy/fuzzy.go index cbe149e..450f9eb 100644 --- a/internal/ui/fuzzy/fuzzy.go +++ b/internal/ui/fuzzy/fuzzy.go @@ -30,21 +30,21 @@ type FileWalkMsg struct { // Model is the fuzzy finder overlay. type Model struct { - query string - allPaths []string // all discovered paths (accumulated) - matches []match // filtered + scored results - cursor int // selected result index - offset int // scroll offset - rootDir string // directory being searched - walking bool // true while background walker is running - width int - height int + query string + allPaths []string // all discovered paths (accumulated) + matches []match // filtered + scored results + cursor int // selected result index + offset int // scroll offset + rootDir string // directory being searched + walking bool // true while background walker is running + width int + height int } type match struct { - path string - score int - matchIdxs []int // character indices that matched in the display name + path string + score int + matchIdxs []int // character indices that matched in the display name } // New creates a new fuzzy finder searching from rootDir. diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go index 1f0ebb9..268975d 100644 --- a/internal/ui/help/help.go +++ b/internal/ui/help/help.go @@ -109,6 +109,7 @@ func (m Model) rightEntries() []entry { {"Toggle hidden files", fmtKeys(k.ToggleHidden)}, {"Fuzzy find", fmtKeys(k.FuzzyFind)}, {"Bookmarks", fmtKeys(k.Bookmarks)}, + {"SSH servers", fmtKeys(k.Servers)}, {"Quick search", fmtKeys(k.QuickSearch)}, {"Theme picker", fmtKeys(k.ThemePicker)}, {"Run command", fmtKeys(k.CmdExec)}, diff --git a/internal/ui/menubar/menubar.go b/internal/ui/menubar/menubar.go index 159cfe2..9dc9f2d 100644 --- a/internal/ui/menubar/menubar.go +++ b/internal/ui/menubar/menubar.go @@ -13,9 +13,9 @@ import ( // Item represents a single menu bar button. type Item struct { - Key string // display label, e.g. "F5" - Label string // action label, e.g. "Copy" - RawKey string // actual key string for matching clicks, e.g. "f5" + Key string // display label, e.g. "F5" + Label string // action label, e.g. "Copy" + RawKey string // actual key string for matching clicks, e.g. "f5" } // DefaultItems returns the default menu bar items. @@ -60,6 +60,7 @@ func ShiftItems(cfg config.Config) []Item { {cfg.Keys.Quit, "Quit"}, {cfg.Keys.Rename, "Rename"}, {cfg.Keys.CopyPath, "CpPath"}, + {cfg.Keys.Servers, "SSH"}, {cfg.Keys.GoTo, "GoTo"}, {cfg.Keys.TogglePanel, "Panel"}, {cfg.Keys.SwapPanels, "Swap"}, diff --git a/internal/ui/menubar/menubar_test.go b/internal/ui/menubar/menubar_test.go new file mode 100644 index 0000000..a00cfa8 --- /dev/null +++ b/internal/ui/menubar/menubar_test.go @@ -0,0 +1,73 @@ +package menubar + +import ( + "strings" + "testing" + + "github.com/kooler/MiddayCommander/internal/config" +) + +// The bar is built from a hardcoded table, so a new binding can silently miss +// it and leave the action invisible. +func TestEveryShiftFKeyBindingIsLabelled(t *testing.T) { + cfg := config.Default() + items := ShiftItems(cfg) + + bindings := map[string]config.StringOrList{ + "rename": cfg.Keys.Rename, + "copy_path": cfg.Keys.CopyPath, + "servers": cfg.Keys.Servers, + } + + for name, keys := range bindings { + for _, k := range keys { + pos := shiftFKeyPos(k) + if pos < 0 { + continue // not a shift F-key, nothing to show + } + if items[pos].Label == "" { + t.Errorf("%s is bound to %s (slot F%d) but the shift bar shows no label", + name, k, pos+1) + } + } + } +} + +func TestShiftBarLabelsServersOnF2(t *testing.T) { + items := ShiftItems(config.Default()) + + if got := items[1].Label; got == "" { + t.Fatal("want a label on Shift+F2 for the SSH server list") + } + if items[1].RawKey != "f14" { + t.Errorf("want Shift+F2 to map to f14, got %q", items[1].RawKey) + } +} + +func TestShiftBarKeepsUnboundSlotsBlank(t *testing.T) { + items := ShiftItems(config.Default()) + + if len(items) != 10 { + t.Fatalf("want 10 slots, got %d", len(items)) + } + for i, itm := range items { + if itm.Key != "F"+itoa(i+1) { + t.Errorf("slot %d: want key F%d, got %q", i, i+1, itm.Key) + } + } +} + +func TestDefaultBarLabelsEverySlot(t *testing.T) { + for _, itm := range DefaultItems(config.Default()) { + if strings.TrimSpace(itm.Label) == "" { + t.Errorf("slot %q has no label", itm.Key) + } + } +} + +func itoa(n int) string { + if n < 10 { + return string(rune('0' + n)) + } + return string(rune('0'+n/10)) + string(rune('0'+n%10)) +} diff --git a/internal/ui/panel/column.go b/internal/ui/panel/column.go index 718633c..2febaf7 100644 --- a/internal/ui/panel/column.go +++ b/internal/ui/panel/column.go @@ -26,4 +26,4 @@ func FormatTime(t time.Time) string { return t.Format("Jan 02 15:04") } return t.Format("Jan 02 2006") -} \ No newline at end of file +} diff --git a/internal/ui/panel/panel.go b/internal/ui/panel/panel.go index 2bf7735..cca4496 100644 --- a/internal/ui/panel/panel.go +++ b/internal/ui/panel/panel.go @@ -30,8 +30,9 @@ type KeyMap struct { // Model represents a single file panel. type Model struct { - fs vfs.FS - path string // absolute path of current directory + // stack is the chain of locations descended into, the last being on + // screen. An archive or server pushes; ".." at a root pops. + stack []vfs.Location entries []fs.DirEntry // directory contents (sorted) infos []fs.FileInfo // cached FileInfo for each entry cursor int // highlighted entry index @@ -50,21 +51,13 @@ type Model struct { searching bool searchQuery string - // Archive browsing state - inArchive bool // true when browsing inside an archive - archiveFS *archive.FS // the archive VFS (nil when not in archive) - archivePath string // path within the archive - realFS vfs.FS // the original filesystem (to restore when leaving archive) - realPath string // the directory containing the archive file - keyMap KeyMap } // New creates a new panel browsing the given directory. func New(filesystem vfs.FS, path string, km KeyMap, cfg config.Config) Model { return Model{ - fs: filesystem, - path: path, + stack: []vfs.Location{vfs.NewLocation(filesystem, path, vfs.KindLocal, "")}, selected: make(map[int]bool), sortMode: SortByName, showHidden: cfg.Behavior.ShowHidden == nil || *cfg.Behavior.ShowHidden, @@ -83,33 +76,88 @@ func (m Model) ShowHidden() bool { return m.showHidden } -// Path returns the current directory path. +func (m Model) Location() vfs.Location { + return m.stack[len(m.stack)-1] +} + +func (m *Model) setLocation(loc vfs.Location) { + m.stack[len(m.stack)-1] = loc +} + +func (m *Model) push(loc vfs.Location) { + m.stack = append(m.stack, loc) + m.cursor = 0 + m.offset = 0 +} + +// pop leaves the innermost location and names the entry to put the cursor +// back on. No-op at the outermost. +func (m *Model) pop() string { + if len(m.stack) < 2 { + return "" + } + inner := m.stack[len(m.stack)-1] + m.stack = m.stack[:len(m.stack)-1] + m.cursor = 0 + m.offset = 0 + return vfs.BasePath(m.Location().Kind, inner.Origin) +} + func (m Model) Path() string { - return m.path + return m.Location().Path } -// SetPath changes the directory path (call LoadDir after). -func (m *Model) SetPath(path string) { - // If currently in archive, leave it - if m.inArchive { - m.leaveArchive() +func (m Model) Ref() vfs.FileRef { + return m.Location().Ref() +} + +// UsesFS tells the app whether this panel still needs a shared connection. +func (m Model) UsesFS(fsys vfs.FS) bool { + for _, loc := range m.stack { + if loc.FS == fsys { + return true + } + } + return false +} + +// LocalPath is where the panel sits on this machine, even while showing a +// server. +func (m Model) LocalPath() string { + for i := len(m.stack) - 1; i >= 0; i-- { + if m.stack[i].IsLocal() { + return m.stack[i].Path + } } - m.path = path + return m.stack[0].Path +} + +func (m Model) IsLocal() bool { + return m.Location().IsLocal() +} + +// SetPath leaves any archive or server first. +func (m *Model) SetPath(path string) { + m.stack = m.stack[:1] + m.setLocation(m.stack[0].WithPath(path)) m.cursor = 0 m.offset = 0 } +// SetLocation descends from the outermost level, for opening a server. +func (m *Model) SetLocation(loc vfs.Location) { + m.stack = m.stack[:1] + m.push(loc) +} + // InArchive returns whether this panel is browsing inside an archive. func (m Model) InArchive() bool { - return m.inArchive + return m.Location().Kind == vfs.KindArchive } -// ArchiveLabel returns a display string for the archive being browsed, or "". -func (m Model) ArchiveLabel() string { - if !m.inArchive { - return "" - } - return m.archiveFS.ArchivePath() +// LocationLabel is the display string for a nested location, or "". +func (m Model) LocationLabel() string { + return m.Location().Label } // SetSize sets the panel dimensions. @@ -144,42 +192,45 @@ func (m Model) CurrentInfo() fs.FileInfo { return nil } -// CurrentPath returns the full path of the entry under the cursor. -// For archive browsing this returns the path within the archive, not a real filesystem path. +// CurrentPath is within the panel's own filesystem, not necessarily this +// machine. func (m Model) CurrentPath() string { e := m.CurrentEntry() if e == nil { - return m.path + return m.Path() } - if m.inArchive { - if m.path == "." { - return e.Name() - } - return m.path + "/" + e.Name() + return m.Location().Join(e.Name()) +} + +func (m Model) CurrentRef() vfs.FileRef { + e := m.CurrentEntry() + if e == nil { + return m.Ref() } - return filepath.Join(m.path, e.Name()) + return m.Location().Child(e.Name()) } -// SelectedPaths returns full paths of all tagged files. If none are tagged, returns the current entry. -func (m Model) SelectedPaths() []string { - var paths []string +// SelectedRefs returns the tagged entries, or the one under the cursor. +func (m Model) SelectedRefs() []vfs.FileRef { + loc := m.Location() + var out []vfs.FileRef for i, sel := range m.selected { - if sel && i < len(m.entries) { - paths = append(paths, filepath.Join(m.path, m.entries[i].Name())) + if sel && i < len(m.entries) && m.entries[i].Name() != ".." { + out = append(out, loc.Child(m.entries[i].Name())) } } - if len(paths) == 0 { + if len(out) == 0 { if e := m.CurrentEntry(); e != nil && e.Name() != ".." { - paths = append(paths, m.CurrentPath()) + out = append(out, m.CurrentRef()) } } - return paths + return out } // LoadDir reads the current directory and populates entries. func (m *Model) LoadDir() tea.Cmd { - path := m.path - filesystem := m.fs + path := m.Path() + filesystem := m.Location().FS return func() tea.Msg { entries, err := readDir(filesystem, path) return DirLoadedMsg{Path: path, Entries: entries, Err: err} @@ -207,15 +258,16 @@ func (m *Model) HandleDirLoaded(msg DirLoadedMsg) { m.err = msg.Err return } - if msg.Path != m.path { + if msg.Path != m.Path() { return // stale load } m.err = nil - // Prepend ".." entry unless at root + // Prepend ".." unless there is nowhere to go: at the root of a nested + // location it leaves that location rather than the filesystem. var all []fs.DirEntry - if !isRootPath(m.path) { + if !m.Location().IsRoot() || len(m.stack) > 1 { all = append(all, parentEntry{}) } for _, e := range msg.Entries { @@ -394,8 +446,9 @@ func (m *Model) handleEnter() tea.Cmd { return m.enterDir() } - // Check if it's an archive we can browse into (only from real FS, not nested) - if !m.inArchive { + // Only a real file on this machine can be browsed as an archive; a + // remote one would have to be downloaded first. + if m.IsLocal() { fullPath := m.CurrentPath() if archive.IsArchive(fullPath) { return m.enterArchive(fullPath) @@ -413,7 +466,7 @@ func (m *Model) handleEnter() tea.Cmd { func (m *Model) handleSpace() tea.Cmd { e := m.CurrentEntry() - if e == nil || e.IsDir() || m.inArchive { + if e == nil || e.IsDir() || !m.IsLocal() { return nil } // Space on file = preview @@ -428,28 +481,16 @@ func (m *Model) enterArchive(archivePath string) tea.Cmd { return nil } - m.realFS = m.fs - m.realPath = m.path - m.archiveFS = afs - m.inArchive = true - m.fs = afs - m.path = "." - m.archivePath = "." - m.cursor = 0 - m.offset = 0 + m.push(vfs.Location{ + FS: afs, + Path: vfs.KindArchive.RootPath(), + Kind: vfs.KindArchive, + Label: filepath.Base(archivePath) + "://", + Origin: archivePath, + }) return m.LoadDir() } -func (m *Model) leaveArchive() { - m.fs = m.realFS - m.path = m.realPath - m.inArchive = false - m.archiveFS = nil - m.archivePath = "" - m.realFS = nil - m.realPath = "" -} - func (m *Model) enterDir() tea.Cmd { e := m.CurrentEntry() if e == nil { @@ -462,53 +503,29 @@ func (m *Model) enterDir() tea.Cmd { return m.goUp() } - if m.inArchive { - if m.path == "." { - m.path = e.Name() - } else { - m.path = m.path + "/" + e.Name() - } - } else { - m.path = filepath.Join(m.path, e.Name()) - } + loc := m.Location() + m.setLocation(loc.WithPath(loc.Join(e.Name()))) m.cursor = 0 m.offset = 0 return m.LoadDir() } func (m *Model) goUp() tea.Cmd { - if m.inArchive { - // Going up within archive - if m.path == "." || m.path == "" { - // Leave the archive entirely - archiveName := filepath.Base(m.archiveFS.ArchivePath()) - m.leaveArchive() - m.cursor = 0 - m.offset = 0 - return tea.Sequence(m.LoadDir(), func() tea.Msg { - return RestoreCursorMsg{Name: archiveName} - }) - } - // Go up one level within the archive - oldDir := filepath.Base(m.path) - parent := filepath.Dir(m.path) - if parent == "." || parent == "/" { - m.path = "." - } else { - m.path = parent + loc := m.Location() + + // At the top of a nested location, ".." leaves it. + if loc.IsRoot() { + name := m.pop() + if name == "" { + return nil // already at the outermost root } - m.cursor = 0 - m.offset = 0 return tea.Sequence(m.LoadDir(), func() tea.Msg { - return RestoreCursorMsg{Name: oldDir} + return RestoreCursorMsg{Name: name} }) } - if isRootPath(m.path) { - return nil - } - oldDir := filepath.Base(m.path) - m.path = filepath.Dir(m.path) + oldDir := loc.Base() + m.setLocation(loc.WithPath(loc.Parent())) m.cursor = 0 m.offset = 0 @@ -610,16 +627,6 @@ func (m *Model) ChangeSortMode() { SortEntries(m.entries, m.sortMode) } -func isRootPath(path string) bool { - if path == string(filepath.Separator) { - return true - } - if len(path) == 3 && path[1] == ':' { - return true - } - return false -} - // parentEntry is a synthetic ".." directory entry. type parentEntry struct{} diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 0e27d2b..a7dc0db 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -3,7 +3,6 @@ package panel import ( "fmt" "io/fs" - "path/filepath" "strings" "github.com/charmbracelet/lipgloss" @@ -27,16 +26,7 @@ func (m Model) View(th theme.Theme) string { innerWidth := m.width - 2 // account for left+right border chars - // Header: current path (show archive name when inside one) - header := m.path - if m.inArchive { - archName := filepath.Base(m.archiveFS.ArchivePath()) - if m.path == "." { - header = archName + "://" - } else { - header = archName + "://" + m.path - } - } + header := m.Location().Display() if len(header) > innerWidth-4 { header = "..." + header[len(header)-innerWidth+7:] } @@ -70,7 +60,7 @@ func (m Model) View(th theme.Theme) string { footerText = fmt.Sprintf(" Search: %s_ ", m.searchQuery) } else { count := len(m.entries) - if m.entries != nil && !isRootPath(m.path) { + if m.entries != nil && !m.Location().IsRoot() { count-- // exclude ".." } if m.showHidden { diff --git a/internal/ui/quickview/quickview.go b/internal/ui/quickview/quickview.go index e448bd9..0933aaa 100644 --- a/internal/ui/quickview/quickview.go +++ b/internal/ui/quickview/quickview.go @@ -1,21 +1,22 @@ // Package quickview implements an embedded, read-only file preview that can // replace the inactive panel. It mirrors the panel's bordered box so the two // sit side-by-side seamlessly. Content follows the active panel's cursor and -// is loaded synchronously as a bounded head of the file. +// is a bounded head of the file. A local file is read inline; a remote one is +// read by a command, because a network round trip must not block the event +// loop. package quickview import ( "fmt" "io" "io/fs" - "os" - "path/filepath" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/kooler/MiddayCommander/internal/ui/theme" + "github.com/kooler/MiddayCommander/internal/vfs" ) // maxPreviewBytes is how much of a file we read for the preview. We only ever @@ -31,6 +32,7 @@ const ( kindEmpty kindError kindUnavailable + kindLoading ) // Model is the file preview sub-model. @@ -68,12 +70,20 @@ func (m Model) Focused() bool { return m.focused } // Path returns the file currently previewed. func (m Model) Path() string { return m.path } -// SetFile loads the given path into the preview, resetting scroll. isDir marks a -// directory selection; available is false when the path is not a real OS file -// (e.g. inside an archive) and cannot be read. -func (m *Model) SetFile(path string, info fs.FileInfo, isDir, available bool) { - m.path = path - m.name = filepath.Base(path) +// FileLoadedMsg carries a preview read that happened off the event loop. +type FileLoadedMsg struct { + Path string + Data []byte + Truncated bool + Err error +} + +// SetFile points the preview at an entry, resetting scroll. available is false +// when the entry cannot be read at all, as inside an archive. A returned +// command must be run: the content arrives as a FileLoadedMsg. +func (m *Model) SetFile(ref vfs.FileRef, info fs.FileInfo, isDir, available bool) tea.Cmd { + m.path = ref.Path + m.name = ref.Base() m.info = info m.offset = 0 m.truncated = false @@ -85,31 +95,58 @@ func (m *Model) SetFile(path string, info fs.FileInfo, isDir, available bool) { m.kind = kindUnavailable case isDir: m.kind = kindDir + case ref.IsLocal(): + data, truncated, err := readHead(ref) + m.applyContent(data, truncated, err) default: - m.loadFile(path) + m.kind = kindLoading + return loadFileCmd(ref) } + return nil } -func (m *Model) loadFile(path string) { - f, err := os.Open(path) - if err != nil { - m.kind = kindError - m.errMsg = err.Error() +// HandleFileLoaded applies a completed read, ignoring one the cursor has +// already moved past. +func (m *Model) HandleFileLoaded(msg FileLoadedMsg) { + if msg.Path != m.path || m.kind != kindLoading { return } + m.applyContent(msg.Data, msg.Truncated, msg.Err) +} + +func loadFileCmd(ref vfs.FileRef) tea.Cmd { + return func() tea.Msg { + data, truncated, err := readHead(ref) + return FileLoadedMsg{Path: ref.Path, Data: data, Truncated: truncated, Err: err} + } +} + +// readHead reads the bounded head of a file, reporting whether more remained. +func readHead(ref vfs.FileRef) ([]byte, bool, error) { + f, err := ref.FS.Open(ref.Path) + if err != nil { + return nil, false, err + } defer f.Close() // Read one byte past the cap so we can tell whether the file was truncated. buf, err := io.ReadAll(io.LimitReader(f, maxPreviewBytes+1)) + if err != nil { + return nil, false, err + } + if len(buf) > maxPreviewBytes { + return buf[:maxPreviewBytes], true, nil + } + return buf, false, nil +} + +func (m *Model) applyContent(buf []byte, truncated bool, err error) { + m.truncated = truncated if err != nil { m.kind = kindError m.errMsg = err.Error() return } - if len(buf) > maxPreviewBytes { - buf = buf[:maxPreviewBytes] - m.truncated = true - } if len(buf) == 0 { m.kind = kindEmpty return @@ -232,6 +269,8 @@ func (m Model) contentLines(width int, normal lipgloss.Style) []string { return render(m.centered(width, "⟨ preview unavailable ⟩", m.name)) case kindError: return render(m.centered(width, "⟨ cannot preview ⟩", m.errMsg)) + case kindLoading: + return render(m.centered(width, "⟨ loading… ⟩", m.name)) default: return nil } diff --git a/internal/ui/quickview/quickview_test.go b/internal/ui/quickview/quickview_test.go index ccda13d..432085a 100644 --- a/internal/ui/quickview/quickview_test.go +++ b/internal/ui/quickview/quickview_test.go @@ -1,10 +1,15 @@ package quickview import ( + "io" "os" "path/filepath" "strings" "testing" + + "github.com/kooler/MiddayCommander/internal/vfs" + "github.com/kooler/MiddayCommander/internal/vfs/local" + "github.com/kooler/MiddayCommander/internal/vfs/memfs" ) func statOf(t *testing.T, path string) os.FileInfo { @@ -56,7 +61,7 @@ func TestSetFileClassification(t *testing.T) { info = fi } } - m.SetFile(tc.path, info, tc.isDir, tc.available) + m.SetFile(localRef(tc.path), info, tc.isDir, tc.available) if m.kind != tc.want { t.Errorf("kind = %d, want %d", m.kind, tc.want) } @@ -71,7 +76,7 @@ func TestTextLinesLoaded(t *testing.T) { t.Fatal(err) } var m Model - m.SetFile(p, statOf(t, p), false, true) + m.SetFile(localRef(p), statOf(t, p), false, true) if got := len(m.lines); got != 3 { t.Fatalf("lines = %d, want 3", got) } @@ -85,8 +90,92 @@ func TestTruncationFlag(t *testing.T) { t.Fatal(err) } var m Model - m.SetFile(p, statOf(t, p), false, true) + m.SetFile(localRef(p), statOf(t, p), false, true) if !m.truncated { t.Error("expected truncated = true for oversized file") } } + +// localRef is what the panel hands the preview for a local selection. +func localRef(p string) vfs.FileRef { + return vfs.FileRef{ + FS: local.New(string(filepath.Separator)), + Path: p, + Kind: vfs.KindLocal, + } +} + +// remoteRef stands in for a file on a server: a non-local kind is what sends +// the read off the event loop. +func remoteRef(t *testing.T, name, content string) vfs.FileRef { + t.Helper() + f := memfs.New() + w, err := f.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(w.(io.Writer), content); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return vfs.FileRef{FS: f, Path: name, Kind: vfs.KindSSH} +} + +func TestRemotePreviewIsReadByACommand(t *testing.T) { + ref := remoteRef(t, "/notes.txt", "line one\nline two\n") + + var m Model + m.SetSize(40, 10) + + cmd := m.SetFile(ref, nil, false, true) + if cmd == nil { + t.Fatal("a remote read must not happen on the event loop") + } + if m.kind != kindLoading { + t.Errorf("want the preview showing as loading, got kind %v", m.kind) + } + + msg, ok := cmd().(FileLoadedMsg) + if !ok { + t.Fatalf("want a FileLoadedMsg, got %T", msg) + } + m.HandleFileLoaded(msg) + + if m.kind != kindText { + t.Fatalf("want text content after the load, got kind %v", m.kind) + } + if len(m.lines) < 2 || m.lines[0] != "line one" || m.lines[1] != "line two" { + t.Errorf("want the file's lines, got %q", m.lines) + } +} + +func TestRemotePreviewIgnoresAResultTheCursorLeft(t *testing.T) { + first := remoteRef(t, "/first.txt", "first body\n") + second := remoteRef(t, "/second.txt", "second body\n") + + var m Model + m.SetSize(40, 10) + + slow := m.SetFile(first, nil, false, true) + if slow == nil { + t.Fatal("want a command for the first file") + } + stale, ok := slow().(FileLoadedMsg) + if !ok { + t.Fatalf("want a FileLoadedMsg, got %T", stale) + } + + // The cursor moves on before the first read comes back. + quick := m.SetFile(second, nil, false, true) + m.HandleFileLoaded(quick().(FileLoadedMsg)) + m.HandleFileLoaded(stale) + + if m.path != "/second.txt" { + t.Fatalf("want the preview on the second file, got %q", m.path) + } + if len(m.lines) == 0 || m.lines[0] != "second body" { + t.Errorf("a late read for the previous file must be dropped, got %q", m.lines) + } +} diff --git a/internal/ui/servers/servers.go b/internal/ui/servers/servers.go new file mode 100644 index 0000000..8b2e30b --- /dev/null +++ b/internal/ui/servers/servers.go @@ -0,0 +1,525 @@ +// Package servers is the saved SSH server list overlay. +package servers + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/kooler/MiddayCommander/internal/remote" + "github.com/kooler/MiddayCommander/internal/ui/overlay" + "github.com/kooler/MiddayCommander/internal/ui/theme" +) + +type ConnectMsg struct { + Server remote.Server +} + +type DismissMsg struct{} + +type mode int + +const ( + modeList mode = iota + modeFilter + modeForm +) + +// field indexes into the add/edit form. +const ( + fieldName = iota + fieldHost + fieldPort + fieldUser + fieldKey + fieldDir + fieldCount +) + +var fieldLabels = [fieldCount]string{ + fieldName: "Name", + fieldHost: "Host", + fieldPort: "Port", + fieldUser: "User", + fieldKey: "Key file", + fieldDir: "Directory", +} + +var fieldHints = [fieldCount]string{ + fieldName: "shown in this list; defaults to the host", + fieldHost: "required", + fieldPort: "blank for 22", + fieldUser: "blank for your login name", + fieldKey: "blank to use your ssh-agent", + fieldDir: "where the panel opens; blank for your home directory", +} + +type Model struct { + store *remote.Store + items []remote.SavedServer + + mode mode + cursor int + offset int + width int + height int + + filter string + + // Form state + values [fieldCount]string + focused int + editingOld string // name of the server being edited, "" when adding + formErr string +} + +func New(store *remote.Store, width, height int) Model { + return Model{ + store: store, + items: store.Sorted(), + width: width, + height: height, + } +} + +func (m Model) Update(msg tea.KeyMsg) (Model, tea.Cmd) { + switch m.mode { + case modeForm: + return m.updateForm(msg) + case modeFilter: + return m.updateFilter(msg) + default: + return m.updateList(msg) + } +} + +func (m Model) updateList(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m, func() tea.Msg { return DismissMsg{} } + case "enter": + return m.connectAt(m.cursor) + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.clampOffset() + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + m.clampOffset() + } + case "a": + m.startAdd() + case "e": + m.startEdit() + case "d", "delete": + if m.cursor >= 0 && m.cursor < len(m.items) { + m.store.Remove(m.items[m.cursor].Name) + _ = m.store.Save() + m.refilter() + } + case "f": + m.mode = modeFilter + m.filter = "" + case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": + return m.connectAt(int(msg.String()[0] - '0')) + } + return m, nil +} + +func (m Model) connectAt(idx int) (Model, tea.Cmd) { + if idx < 0 || idx >= len(m.items) { + return m, nil + } + srv := m.items[idx].Server + m.store.Touch(srv.Name) + _ = m.store.Save() + return m, func() tea.Msg { return ConnectMsg{Server: srv} } +} + +func (m Model) updateFilter(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.mode = modeList + m.filter = "" + m.refilter() + case "enter": + m.mode = modeList + return m.connectAt(m.cursor) + case "backspace": + if len(m.filter) > 0 { + m.filter = m.filter[:len(m.filter)-1] + m.refilter() + } else { + m.mode = modeList + } + case "up": + if m.cursor > 0 { + m.cursor-- + m.clampOffset() + } + case "down": + if m.cursor < len(m.items)-1 { + m.cursor++ + m.clampOffset() + } + default: + if text := insertableText(msg); text != "" { + m.filter += text + m.refilter() + } + } + return m, nil +} + +func (m *Model) startAdd() { + m.mode = modeForm + m.values = [fieldCount]string{} + m.focused = fieldName + m.editingOld = "" + m.formErr = "" +} + +func (m *Model) startEdit() { + if m.cursor < 0 || m.cursor >= len(m.items) { + return + } + srv := m.items[m.cursor].Server + + m.mode = modeForm + m.focused = fieldName + m.editingOld = srv.Name + m.formErr = "" + m.values = [fieldCount]string{ + fieldName: srv.Name, + fieldHost: srv.Host, + fieldUser: srv.User, + fieldKey: srv.KeyPath, + fieldDir: srv.Dir, + } + if srv.Port != 0 { + m.values[fieldPort] = strconv.Itoa(srv.Port) + } +} + +func (m Model) updateForm(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.mode = modeList + m.formErr = "" + return m, nil + + case "tab", "down": + m.focused = (m.focused + 1) % fieldCount + return m, nil + + case "shift+tab", "up": + m.focused = (m.focused - 1 + fieldCount) % fieldCount + return m, nil + + case "enter": + srv, err := m.buildServer() + if err != nil { + m.formErr = err.Error() + return m, nil + } + // A rename replaces the old entry instead of duplicating it. + if m.editingOld != "" && m.editingOld != srv.Name { + m.store.Remove(m.editingOld) + } + m.store.Add(srv) + _ = m.store.Save() + m.mode = modeList + m.formErr = "" + m.refilter() + return m, nil + + case "backspace": + v := m.values[m.focused] + if len(v) > 0 { + m.values[m.focused] = v[:len(v)-1] + } + return m, nil + + default: + m.values[m.focused] += insertableText(msg) + return m, nil + } +} + +// insertableText returns the characters a key carries, or "" if it is not +// text. A paste arrives as one message of many runes. +func insertableText(msg tea.KeyMsg) string { + if msg.Alt { + return "" + } + switch msg.Type { + case tea.KeyRunes: + return string(msg.Runes) + case tea.KeySpace: + return " " + } + return "" +} + +// buildServer validates the form. +func (m Model) buildServer() (remote.Server, error) { + host := strings.TrimSpace(m.values[fieldHost]) + if host == "" { + return remote.Server{}, fmt.Errorf("Host is required") + } + + srv := remote.Server{ + Name: strings.TrimSpace(m.values[fieldName]), + Host: host, + User: strings.TrimSpace(m.values[fieldUser]), + KeyPath: strings.TrimSpace(m.values[fieldKey]), + Dir: strings.TrimSpace(m.values[fieldDir]), + } + if srv.Name == "" { + srv.Name = host + } + + if portStr := strings.TrimSpace(m.values[fieldPort]); portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + return remote.Server{}, fmt.Errorf("Port must be a number between 1 and 65535") + } + srv.Port = port + } + + return srv, nil +} + +func (m *Model) refilter() { + all := m.store.Sorted() + if m.filter == "" { + m.items = all + } else { + query := strings.ToLower(m.filter) + m.items = nil + for _, s := range all { + target := strings.ToLower(s.Name + " " + s.Host + " " + s.User) + if strings.Contains(target, query) { + m.items = append(m.items, s) + } + } + } + if m.cursor >= len(m.items) { + m.cursor = max(0, len(m.items)-1) + } + m.offset = 0 +} + +func (m Model) BoxSize(screenWidth, screenHeight int) (int, int) { + w := screenWidth * 2 / 3 + if w < 48 { + w = min(48, screenWidth) + } + + h := len(m.items) + 4 + if m.mode == modeForm { + h = fieldCount + 5 // fields, borders, title, footer, error line + } + if h < 9 { + h = 9 + } + maxH := screenHeight * 3 / 4 + if h > maxH { + h = maxH + } + return w, h +} + +func (m Model) resultHeight() int { + _, boxH := m.BoxSize(m.width, m.height) + h := boxH - 4 + if h < 1 { + h = 1 + } + return h +} + +func (m *Model) clampOffset() { + rh := m.resultHeight() + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+rh { + m.offset = m.cursor - rh + 1 + } +} + +func (m Model) View(_ theme.Theme, screenWidth, screenHeight int) string { + boxW, boxH := m.BoxSize(screenWidth, screenHeight) + innerW := boxW - 2 + + bg := lipgloss.Color("#1e1e2e") + fg := lipgloss.Color("#cdd6f4") + subtle := lipgloss.Color("#a6adc8") + accent := lipgloss.Color("#89b4fa") + highlight := lipgloss.Color("#f9e2af") + danger := lipgloss.Color("#f38ba8") + cursorBg := lipgloss.Color("#45475a") + + st := styles{ + base: lipgloss.NewStyle().Background(bg).Foreground(fg), + cursor: lipgloss.NewStyle().Background(cursorBg).Foreground(fg), + dim: lipgloss.NewStyle().Background(bg).Foreground(subtle), + num: lipgloss.NewStyle().Background(bg).Foreground(highlight), + key: lipgloss.NewStyle().Background(bg).Foreground(accent).Bold(true), + err: lipgloss.NewStyle().Background(bg).Foreground(danger), + innerW: innerW, + } + + var contentLines []string + var footer string + title := "SSH Servers" + + if m.mode == modeForm { + title = "Add Server" + if m.editingOld != "" { + title = "Edit Server" + } + contentLines = m.formLines(st) + footer = st.hints( + "Tab", "Next field", + "Enter", "Save", + "Esc", "Cancel", + ) + } else { + contentLines = m.listLines(st) + footer = st.hints( + "a", "Add", "e", "Edit", "d", "Delete", + "f", "Filter", "Enter", "Open", "Esc", "Close", + ) + } + + return overlay.RenderBox(title, contentLines, footer, boxW, boxH, accent, bg, highlight) +} + +func (m Model) formLines(st styles) []string { + var lines []string + + labelW := 0 + for _, l := range fieldLabels { + if len(l) > labelW { + labelW = len(l) + } + } + + for i := 0; i < fieldCount; i++ { + label := pad(fieldLabels[i]+":", labelW+2) + value := m.values[i] + + var row string + if i == m.focused { + row = st.key.Render(" "+label) + st.cursor.Render(pad(value+"_", st.innerW-labelW-3)) + } else { + shown := value + if shown == "" { + shown = fieldHints[i] + row = st.dim.Render(" "+label) + st.dim.Render(pad(shown, st.innerW-labelW-3)) + } else { + row = st.dim.Render(" "+label) + st.base.Render(pad(shown, st.innerW-labelW-3)) + } + } + lines = append(lines, row) + } + + if m.formErr != "" { + lines = append(lines, st.err.Render(pad(" "+m.formErr, st.innerW))) + } + + return lines +} + +func (m Model) listLines(st styles) []string { + var lines []string + + rh := m.resultHeight() + if m.mode == modeFilter { + lines = append(lines, st.key.Render(" Filter: ")+st.base.Render(pad(m.filter+"_", st.innerW-9))) + rh-- + } + + if len(m.items) == 0 { + lines = append(lines, st.dim.Render(pad(" No servers yet. Press 'a' to add one.", st.innerW))) + return lines + } + + end := min(m.offset+rh, len(m.items)) + for i := m.offset; i < end; i++ { + srv := m.items[i] + + prefix := " " + if i < 10 { + prefix = fmt.Sprintf("%d ", i) + } + + detail := srv.Label() + if srv.Dir != "" { + detail += " " + srv.Dir + } + text := srv.DisplayName() + if srv.Name != "" && srv.Name != srv.Host { + text += " " + detail + } else { + text = detail + } + text = truncate(text, st.innerW-len(prefix)) + + if i == m.cursor { + lines = append(lines, st.cursor.Render(pad(prefix+text, st.innerW))) + } else { + lines = append(lines, st.num.Render(prefix)+st.base.Render(pad(text, st.innerW-len(prefix)))) + } + } + + return lines +} + +// styles keeps the render helpers short. +type styles struct { + base lipgloss.Style + cursor lipgloss.Style + dim lipgloss.Style + num lipgloss.Style + key lipgloss.Style + err lipgloss.Style + innerW int +} + +// hints renders alternating key/label pairs. +func (s styles) hints(pairs ...string) string { + var b strings.Builder + for i := 0; i+1 < len(pairs); i += 2 { + if i > 0 { + b.WriteString(s.dim.Render(" ")) + } + b.WriteString(s.key.Render(" " + pairs[i])) + b.WriteString(s.dim.Render(":" + pairs[i+1])) + } + return b.String() +} + +func pad(s string, width int) string { + if width < 0 { + return "" + } + if len(s) >= width { + return s[:width] + } + return s + strings.Repeat(" ", width-len(s)) +} + +func truncate(s string, width int) string { + if width <= 1 || len(s) <= width { + return s + } + return s[:width-1] + "…" +} diff --git a/internal/ui/servers/servers_test.go b/internal/ui/servers/servers_test.go new file mode 100644 index 0000000..99d821b --- /dev/null +++ b/internal/ui/servers/servers_test.go @@ -0,0 +1,241 @@ +package servers + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/kooler/MiddayCommander/internal/remote" + "github.com/kooler/MiddayCommander/internal/ui/theme" +) + +func newStore(t *testing.T, srvs ...remote.Server) *remote.Store { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + s := remote.LoadStore() + for _, srv := range srvs { + s.Add(srv) + } + return s +} + +func key(s string) tea.KeyMsg { + if len(s) == 1 { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func typeIn(m Model, text string) Model { + for _, r := range text { + m, _ = m.Update(key(string(r))) + } + return m +} + +// Narrow sizes are where the padding maths can slice past a string's end. +func TestViewRendersAtAnySize(t *testing.T) { + store := newStore(t, + remote.Server{Name: "prod", Host: "prod.example.com", User: "deploy", Dir: "/srv/app"}, + remote.Server{Name: "a-very-long-server-name-that-overflows", Host: "long.example.com"}, + ) + + sizes := [][2]int{{100, 30}, {60, 20}, {40, 12}, {24, 9}} + for _, size := range sizes { + m := New(store, size[0], size[1]) + if out := m.View(theme.Theme{}, size[0], size[1]); out == "" { + t.Errorf("%dx%d: empty render", size[0], size[1]) + } + + m.startAdd() + if out := m.View(theme.Theme{}, size[0], size[1]); out == "" { + t.Errorf("%dx%d: empty form render", size[0], size[1]) + } + } +} + +func TestEmptyListRenders(t *testing.T) { + m := New(newStore(t), 80, 24) + out := m.View(theme.Theme{}, 80, 24) + if !strings.Contains(out, "No servers yet") { + t.Error("want the empty-list hint") + } +} + +func TestAddServerThroughTheForm(t *testing.T) { + store := newStore(t) + m := New(store, 80, 24) + + m, _ = m.Update(key("a")) + m = typeIn(m, "staging") // Name + m, _ = m.Update(key("tab")) // -> Host + m = typeIn(m, "staging.example.com") + m, _ = m.Update(key("tab")) // -> Port + m = typeIn(m, "2222") + m, _ = m.Update(key("enter")) // save + + saved, ok := store.Find("staging") + if !ok { + t.Fatal("want the server saved") + } + if saved.Host != "staging.example.com" || saved.Port != 2222 { + t.Errorf("server did not save correctly: %+v", saved) + } + if m.mode != modeList { + t.Error("want the form closed after saving") + } +} + +func TestFormRejectsAMissingHost(t *testing.T) { + store := newStore(t) + m := New(store, 80, 24) + + m, _ = m.Update(key("a")) + m = typeIn(m, "nameonly") + m, _ = m.Update(key("enter")) + + if m.mode != modeForm { + t.Error("want the form to stay open when the host is missing") + } + if m.formErr == "" { + t.Error("want an error message explaining what is missing") + } + if len(store.Servers) != 0 { + t.Error("want nothing saved") + } +} + +func TestFormRejectsABadPort(t *testing.T) { + store := newStore(t) + m := New(store, 80, 24) + + m, _ = m.Update(key("a")) + m, _ = m.Update(key("tab")) // -> Host + m = typeIn(m, "example.com") + m, _ = m.Update(key("tab")) // -> Port + m = typeIn(m, "99999") + m, _ = m.Update(key("enter")) + + if m.formErr == "" { + t.Error("want a port validation error") + } + if len(store.Servers) != 0 { + t.Error("want nothing saved with an invalid port") + } +} + +func TestNameDefaultsToHost(t *testing.T) { + store := newStore(t) + m := New(store, 80, 24) + + m, _ = m.Update(key("a")) + m, _ = m.Update(key("tab")) + m = typeIn(m, "box.example.com") + m, _ = m.Update(key("enter")) + + if _, ok := store.Find("box.example.com"); !ok { + t.Error("want the host used as the name when none is given") + } +} + +func TestEnterConnects(t *testing.T) { + store := newStore(t, remote.Server{Name: "prod", Host: "prod.example.com"}) + m := New(store, 80, 24) + + _, cmd := m.Update(key("enter")) + if cmd == nil { + t.Fatal("want a connect command") + } + msg, ok := cmd().(ConnectMsg) + if !ok { + t.Fatalf("want ConnectMsg, got %T", cmd()) + } + if msg.Server.Host != "prod.example.com" { + t.Errorf("want the selected server, got %+v", msg.Server) + } +} + +func TestDeleteRemovesServer(t *testing.T) { + store := newStore(t, remote.Server{Name: "gone", Host: "gone.example.com"}) + m := New(store, 80, 24) + + m, _ = m.Update(key("d")) + + if _, ok := store.Find("gone"); ok { + t.Error("want the server removed") + } + if len(m.items) != 0 { + t.Error("want the list refreshed after deleting") + } +} + +func TestEscDismisses(t *testing.T) { + m := New(newStore(t), 80, 24) + _, cmd := m.Update(key("esc")) + if cmd == nil { + t.Fatal("want a dismiss command") + } + if _, ok := cmd().(DismissMsg); !ok { + t.Errorf("want DismissMsg, got %T", cmd()) + } +} + +func TestFilterNarrowsTheList(t *testing.T) { + store := newStore(t, + remote.Server{Name: "prod", Host: "prod.example.com"}, + remote.Server{Name: "staging", Host: "staging.example.com"}, + ) + m := New(store, 80, 24) + + m, _ = m.Update(key("f")) + m = typeIn(m, "stag") + + if len(m.items) != 1 || m.items[0].Name != "staging" { + t.Errorf("want only the staging server, got %v", m.items) + } +} + +func TestEditPrefillsAndReplaces(t *testing.T) { + store := newStore(t, remote.Server{Name: "box", Host: "old.example.com", Port: 2222}) + m := New(store, 80, 24) + + m, _ = m.Update(key("e")) + if m.values[fieldName] != "box" || m.values[fieldHost] != "old.example.com" { + t.Fatalf("want the form prefilled, got %v", m.values) + } + if m.values[fieldPort] != "2222" { + t.Errorf("want the port prefilled, got %q", m.values[fieldPort]) + } + + // Clear the host and type a new one. + m, _ = m.Update(key("tab")) + for range "old.example.com" { + m, _ = m.Update(key("backspace")) + } + m = typeIn(m, "new.example.com") + m, _ = m.Update(key("enter")) + + saved, ok := store.Find("box") + if !ok { + t.Fatal("want the server still saved under its name") + } + if saved.Host != "new.example.com" { + t.Errorf("want the host updated, got %q", saved.Host) + } + if len(store.Servers) != 1 { + t.Errorf("want one server, not a duplicate: %d", len(store.Servers)) + } +} diff --git a/internal/ui/theme/loader.go b/internal/ui/theme/loader.go index 1feb500..95cefd2 100644 --- a/internal/ui/theme/loader.go +++ b/internal/ui/theme/loader.go @@ -56,10 +56,10 @@ type sectionTOML struct { } type menuTOML struct { - FG string `toml:"fg"` - BG string `toml:"bg"` - FKeyHintFG string `toml:"fkey_hint_fg"` - FKeyHintBG string `toml:"fkey_hint_bg"` + FG string `toml:"fg"` + BG string `toml:"bg"` + FKeyHintFG string `toml:"fkey_hint_fg"` + FKeyHintBG string `toml:"fkey_hint_bg"` FKeyLabelFG string `toml:"fkey_label_fg"` FKeyLabelBG string `toml:"fkey_label_bg"` } @@ -229,7 +229,7 @@ func buildTheme(tf ThemeFile) Theme { FileExec: orDefault(style(tf.Panel.File.ExecFG, tf.Panel.File.ExecBG), def.FileExec), FileSymlink: orDefault(style(tf.Panel.File.SymlinkFG, tf.Panel.File.SymlinkBG), def.FileSymlink), FileCursor: orDefault(style(tf.Panel.File.CursorFG, tf.Panel.File.CursorBG), def.FileCursor), - FileCursorDir: orDefault(boldStyle(tf.Panel.File.CursorDirFG, tf.Panel.File.CursorDirBG, tf.Panel.File.CursorDirBold), def.FileCursorDir), + FileCursorDir: orDefault(boldStyle(tf.Panel.File.CursorDirFG, tf.Panel.File.CursorDirBG, tf.Panel.File.CursorDirBold), def.FileCursorDir), FileSelected: orDefault(boldStyle(tf.Panel.File.SelectedFG, tf.Panel.File.SelectedBG, tf.Panel.File.SelectedBold), def.FileSelected), FileCursorSelected: orDefault(boldStyle(tf.Panel.File.SelectedFG, tf.Panel.File.CursorBG, tf.Panel.File.SelectedBold), def.FileCursorSelected), FileCursorDirSelected: orDefault(boldStyle(tf.Panel.File.SelectedFG, tf.Panel.File.CursorDirBG, tf.Panel.File.CursorDirBold), def.FileCursorDirSelected), diff --git a/internal/vfs/local/local.go b/internal/vfs/local/local.go index 43ebf00..73f12de 100644 --- a/internal/vfs/local/local.go +++ b/internal/vfs/local/local.go @@ -65,5 +65,22 @@ func (f *FS) Rename(oldname, newname string) error { return os.Rename(f.resolve(oldname), f.resolve(newname)) } +func (f *FS) Chmod(name string, mode fs.FileMode) error { + return os.Chmod(f.resolve(name), mode) +} + +func (f *FS) Lstat(name string) (fs.FileInfo, error) { + return os.Lstat(f.resolve(name)) +} + +func (f *FS) ReadLink(name string) (string, error) { + return os.Readlink(f.resolve(name)) +} + // Verify interface compliance at compile time. -var _ vfs.WritableFS = (*FS)(nil) +var ( + _ vfs.WritableFS = (*FS)(nil) + _ vfs.Chmoder = (*FS)(nil) + _ vfs.Lstater = (*FS)(nil) + _ vfs.LinkReader = (*FS)(nil) +) diff --git a/internal/vfs/location.go b/internal/vfs/location.go new file mode 100644 index 0000000..04dea44 --- /dev/null +++ b/internal/vfs/location.go @@ -0,0 +1,194 @@ +package vfs + +import ( + "path" + "path/filepath" +) + +// Kind selects path semantics: local paths use the host separator, everything +// else is slash-separated whatever platform mdc runs on. +type Kind int + +const ( + KindLocal Kind = iota + KindArchive + KindSSH +) + +func (k Kind) IsLocal() bool { return k == KindLocal } + +func (k Kind) slashed() bool { return k != KindLocal } + +func (k Kind) RootPath() string { + switch k { + case KindArchive: + return "." + case KindSSH: + return "/" + default: + return string(filepath.Separator) + } +} + +func JoinPath(k Kind, base, name string) string { + if !k.slashed() { + return filepath.Join(base, name) + } + if base == "" || base == "." { + return name + } + return path.Join(base, name) +} + +// ParentPath returns the directory containing p, or p when p is already root. +func ParentPath(k Kind, p string) string { + if IsRootPath(k, p) { + return p + } + if !k.slashed() { + return filepath.Dir(p) + } + parent := path.Dir(p) + if parent == "." || parent == "/" { + return k.RootPath() + } + return parent +} + +func BasePath(k Kind, p string) string { + if !k.slashed() { + return filepath.Base(p) + } + return path.Base(p) +} + +// IsRootPath reports whether p is the top of its filesystem. +func IsRootPath(k Kind, p string) bool { + switch k { + case KindArchive: + return p == "." || p == "" || p == "/" + case KindSSH: + return p == "/" || p == "" + default: + if p == string(filepath.Separator) { + return true + } + // Windows drive root, e.g. "C:\". + return len(p) == 3 && p[1] == ':' + } +} + +// FileRef pairs a path with the filesystem it lives on: a path alone cannot +// say which machine that is. +type FileRef struct { + FS FS + Path string + Kind Kind +} + +func (r FileRef) IsLocal() bool { return r.Kind.IsLocal() } + +func (r FileRef) Join(name string) FileRef { + return FileRef{FS: r.FS, Path: JoinPath(r.Kind, r.Path, name), Kind: r.Kind} +} + +func (r FileRef) Parent() FileRef { + return FileRef{FS: r.FS, Path: ParentPath(r.Kind, r.Path), Kind: r.Kind} +} + +func (r FileRef) WithPath(p string) FileRef { + return FileRef{FS: r.FS, Path: p, Kind: r.Kind} +} + +func (r FileRef) Base() string { return BasePath(r.Kind, r.Path) } + +// Writable reports whether the backend accepts changes; archives do not. +func (r FileRef) Writable() (WritableFS, bool) { + w, ok := r.FS.(WritableFS) + return w, ok +} + +// SameFS reports whether a rename between two refs is possible. Local refs +// always qualify: local paths are absolute, so any local FS resolves them. +func SameFS(a, b FileRef) bool { + if a.Kind != b.Kind { + return false + } + if a.Kind.IsLocal() { + return true + } + return a.FS == b.FS +} + +func SamePath(a, b FileRef) bool { + if !SameFS(a, b) { + return false + } + if a.Kind.IsLocal() { + aa, ea := filepath.Abs(a.Path) + bb, eb := filepath.Abs(b.Path) + if ea != nil || eb != nil { + return false + } + return filepath.Clean(aa) == filepath.Clean(bb) + } + return path.Clean(a.Path) == path.Clean(b.Path) +} + +// Location is a directory a panel can browse. +type Location struct { + FS FS + Path string + Kind Kind + Label string + + // Origin is the entry in the location above that was opened to get here, + // so leaving can put the cursor back on it. + Origin string +} + +func NewLocation(fsys FS, p string, kind Kind, label string) Location { + return Location{FS: fsys, Path: p, Kind: kind, Label: label} +} + +func (l Location) Ref() FileRef { + return FileRef{FS: l.FS, Path: l.Path, Kind: l.Kind} +} + +func (l Location) Child(name string) FileRef { return l.Ref().Join(name) } + +func (l Location) Join(name string) string { return JoinPath(l.Kind, l.Path, name) } + +func (l Location) Parent() string { return ParentPath(l.Kind, l.Path) } + +func (l Location) Base() string { return BasePath(l.Kind, l.Path) } + +func (l Location) IsRoot() bool { return IsRootPath(l.Kind, l.Path) } + +func (l Location) IsLocal() bool { return l.Kind.IsLocal() } + +func (l Location) WithPath(p string) Location { + l.Path = p + return l +} + +// URLFor returns the addressable form of a path: bare locally, +// "ssh://kk@host/var/log" on a server. +func (l Location) URLFor(p string) string { + if l.IsLocal() || l.Label == "" { + return p + } + return l.Label + p +} + +// Display is the panel header. Labels carry their own separator, so an +// archive reads "src.zip://sub" and a server "ssh://kk@host/var/log". +func (l Location) Display() string { + if l.IsLocal() || l.Label == "" { + return l.Path + } + if l.Kind == KindArchive && IsRootPath(l.Kind, l.Path) { + return l.Label + } + return l.Label + l.Path +} diff --git a/internal/vfs/location_test.go b/internal/vfs/location_test.go new file mode 100644 index 0000000..1efca2e --- /dev/null +++ b/internal/vfs/location_test.go @@ -0,0 +1,190 @@ +package vfs + +import ( + "path/filepath" + "testing" +) + +// Path dispatch fails quietly: filepath on a remote path yields backslashes +// on Windows and looks fine everywhere else. These pin the separators. + +func TestJoinPath(t *testing.T) { + sep := string(filepath.Separator) + + tests := []struct { + name string + kind Kind + base string + add string + want string + }{ + {"local joins with the host separator", KindLocal, sep + "home", "kk", sep + "home" + sep + "kk"}, + {"ssh always joins with a slash", KindSSH, "/var", "log", "/var/log"}, + {"ssh from root", KindSSH, "/", "etc", "/etc"}, + {"archive root is a dot", KindArchive, ".", "src", "src"}, + {"archive nested", KindArchive, "src", "main.go", "src/main.go"}, + {"archive empty base", KindArchive, "", "top", "top"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := JoinPath(tc.kind, tc.base, tc.add); got != tc.want { + t.Errorf("want %q, got %q", tc.want, got) + } + }) + } +} + +func TestParentPath(t *testing.T) { + sep := string(filepath.Separator) + + tests := []struct { + name string + kind Kind + path string + want string + }{ + {"local one level up", KindLocal, sep + "home" + sep + "kk", sep + "home"}, + {"local root stays put", KindLocal, sep, sep}, + {"ssh one level up", KindSSH, "/var/log/nginx", "/var/log"}, + {"ssh stops at root", KindSSH, "/var", "/"}, + {"ssh root stays put", KindSSH, "/", "/"}, + {"archive one level up", KindArchive, "src/ui/panel", "src/ui"}, + {"archive stops at root", KindArchive, "src", "."}, + {"archive root stays put", KindArchive, ".", "."}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ParentPath(tc.kind, tc.path); got != tc.want { + t.Errorf("want %q, got %q", tc.want, got) + } + }) + } +} + +func TestIsRootPath(t *testing.T) { + sep := string(filepath.Separator) + + tests := []struct { + kind Kind + path string + want bool + }{ + {KindLocal, sep, true}, + {KindLocal, sep + "home", false}, + {KindLocal, `C:\`, true}, + {KindSSH, "/", true}, + {KindSSH, "", true}, + {KindSSH, "/var", false}, + {KindArchive, ".", true}, + {KindArchive, "", true}, + {KindArchive, "src", false}, + } + + for _, tc := range tests { + if got := IsRootPath(tc.kind, tc.path); got != tc.want { + t.Errorf("IsRootPath(%v, %q): want %v, got %v", tc.kind, tc.path, tc.want, got) + } + } +} + +func TestSameFS(t *testing.T) { + a, b := stubFS{tag: 1}, stubFS{tag: 2} + + // Local paths are absolute, so any local filesystem resolves them. + l1 := FileRef{FS: a, Path: "/x", Kind: KindLocal} + l2 := FileRef{FS: b, Path: "/y", Kind: KindLocal} + if !SameFS(l1, l2) { + t.Error("want two local refs to count as one filesystem") + } + + // Remote refs need the same instance: a rename cannot cross hosts. + r1 := FileRef{FS: a, Path: "/x", Kind: KindSSH} + r2 := FileRef{FS: b, Path: "/y", Kind: KindSSH} + if SameFS(r1, r2) { + t.Error("want refs on different remote filesystems to differ") + } + if !SameFS(r1, FileRef{FS: a, Path: "/z", Kind: KindSSH}) { + t.Error("want refs on one remote filesystem to match") + } + + // Kinds never mix. + if SameFS(l1, r1) { + t.Error("want a local and a remote ref to differ") + } +} + +func TestSamePath(t *testing.T) { + a := stubFS{tag: 1} + + r1 := FileRef{FS: a, Path: "/var/log", Kind: KindSSH} + r2 := FileRef{FS: a, Path: "/var/log/", Kind: KindSSH} + if !SamePath(r1, r2) { + t.Error("want a trailing slash to compare equal") + } + + if SamePath(r1, FileRef{FS: a, Path: "/var/lib", Kind: KindSSH}) { + t.Error("want different paths to compare unequal") + } +} + +func TestLocationDisplay(t *testing.T) { + tests := []struct { + name string + loc Location + want string + }{ + { + "local shows the bare path", + Location{Path: "/home/kk", Kind: KindLocal}, + "/home/kk", + }, + { + "archive root shows just the label", + Location{Path: ".", Kind: KindArchive, Label: "src.zip://"}, + "src.zip://", + }, + { + "archive nested appends the inner path", + Location{Path: "cmd/mdc", Kind: KindArchive, Label: "src.zip://"}, + "src.zip://cmd/mdc", + }, + { + "server appends the remote path", + Location{Path: "/var/log", Kind: KindSSH, Label: "ssh://kk@host"}, + "ssh://kk@host/var/log", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.loc.Display(); got != tc.want { + t.Errorf("want %q, got %q", tc.want, got) + } + }) + } +} + +func TestLocationNavigation(t *testing.T) { + loc := Location{FS: stubFS{tag: 1}, Path: "/var", Kind: KindSSH, Label: "ssh://host"} + + if got := loc.Join("log"); got != "/var/log" { + t.Errorf("Join: want /var/log, got %q", got) + } + if got := loc.Child("log").Path; got != "/var/log" { + t.Errorf("Child: want /var/log, got %q", got) + } + if got := loc.Parent(); got != "/" { + t.Errorf("Parent: want /, got %q", got) + } + if loc.IsRoot() { + t.Error("/var should not be root") + } + if !loc.WithPath("/").IsRoot() { + t.Error("/ should be root") + } + if loc.IsLocal() { + t.Error("an SSH location is not local") + } +} diff --git a/internal/vfs/memfs/memfs.go b/internal/vfs/memfs/memfs.go new file mode 100644 index 0000000..3b84256 --- /dev/null +++ b/internal/vfs/memfs/memfs.go @@ -0,0 +1,318 @@ +// Package memfs is an in-memory filesystem for exercising cross-filesystem +// operations without a network or a disk. +package memfs + +import ( + "io" + "io/fs" + "path" + "sort" + "strings" + "sync" + "time" + + "github.com/kooler/MiddayCommander/internal/vfs" +) + +type node struct { + name string + data []byte + mode fs.FileMode + modTime time.Time + isDir bool +} + +// FS is an in-memory vfs.WritableFS. Both "/" and "." name the root. +type FS struct { + mu sync.RWMutex + nodes map[string]*node +} + +// New returns a filesystem holding only its root. +func New() *FS { + return &FS{nodes: map[string]*node{ + "": {name: "/", mode: fs.ModeDir | 0o755, isDir: true, modTime: time.Now()}, + }} +} + +// norm is the internal key form: no leading slash, root is "". +func norm(p string) string { + p = strings.TrimPrefix(p, "/") + p = path.Clean(p) + if p == "." || p == "/" { + return "" + } + return strings.TrimPrefix(p, "/") +} + +func (f *FS) get(p string) (*node, bool) { + n, ok := f.nodes[norm(p)] + return n, ok +} + +func (f *FS) pathErr(op, name string, err error) error { + return &fs.PathError{Op: op, Path: name, Err: err} +} + +// --- read side --- + +func (f *FS) Open(name string) (fs.File, error) { + f.mu.RLock() + defer f.mu.RUnlock() + + n, ok := f.get(name) + if !ok { + return nil, f.pathErr("open", name, fs.ErrNotExist) + } + if n.isDir { + return &openFile{n: n}, nil + } + return &openFile{n: n, r: strings.NewReader(string(n.data))}, nil +} + +func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) { + f.mu.RLock() + defer f.mu.RUnlock() + + dir, ok := f.get(name) + if !ok { + return nil, f.pathErr("readdir", name, fs.ErrNotExist) + } + if !dir.isDir { + return nil, f.pathErr("readdir", name, fs.ErrInvalid) + } + + prefix := norm(name) + if prefix != "" { + prefix += "/" + } + + var out []fs.DirEntry + for key, n := range f.nodes { + if key == "" || !strings.HasPrefix(key, prefix) { + continue + } + rest := strings.TrimPrefix(key, prefix) + if rest == "" || strings.Contains(rest, "/") { + continue // not an immediate child + } + out = append(out, fs.FileInfoToDirEntry(&fileInfo{n: n})) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out, nil +} + +func (f *FS) Stat(name string) (fs.FileInfo, error) { + f.mu.RLock() + defer f.mu.RUnlock() + + n, ok := f.get(name) + if !ok { + return nil, f.pathErr("stat", name, fs.ErrNotExist) + } + return &fileInfo{n: n}, nil +} + +// Lstat matches Stat: there are no symlinks here. +func (f *FS) Lstat(name string) (fs.FileInfo, error) { return f.Stat(name) } + +// --- write side --- + +func (f *FS) Create(name string) (vfs.WriteFile, error) { + f.mu.Lock() + defer f.mu.Unlock() + + key := norm(name) + if key == "" { + return nil, f.pathErr("create", name, fs.ErrInvalid) + } + if parent := path.Dir(key); parent != "." { + if p, ok := f.nodes[parent]; !ok || !p.isDir { + return nil, f.pathErr("create", name, fs.ErrNotExist) + } + } + + n := &node{name: path.Base(key), mode: 0o644, modTime: time.Now()} + f.nodes[key] = n + return &writeFile{fs: f, n: n}, nil +} + +func (f *FS) Mkdir(name string, perm fs.FileMode) error { + f.mu.Lock() + defer f.mu.Unlock() + return f.mkdir(name, perm) +} + +func (f *FS) mkdir(name string, perm fs.FileMode) error { + key := norm(name) + if key == "" { + return nil + } + if _, exists := f.nodes[key]; exists { + return f.pathErr("mkdir", name, fs.ErrExist) + } + if parent := path.Dir(key); parent != "." { + if p, ok := f.nodes[parent]; !ok || !p.isDir { + return f.pathErr("mkdir", name, fs.ErrNotExist) + } + } + f.nodes[key] = &node{ + name: path.Base(key), + mode: fs.ModeDir | perm.Perm(), + isDir: true, + modTime: time.Now(), + } + return nil +} + +func (f *FS) MkdirAll(name string, perm fs.FileMode) error { + f.mu.Lock() + defer f.mu.Unlock() + + key := norm(name) + if key == "" { + return nil + } + var built string + for _, part := range strings.Split(key, "/") { + if built == "" { + built = part + } else { + built += "/" + part + } + if n, ok := f.nodes[built]; ok { + if !n.isDir { + return f.pathErr("mkdir", name, fs.ErrExist) + } + continue + } + if err := f.mkdir(built, perm); err != nil { + return err + } + } + return nil +} + +func (f *FS) Remove(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + + key := norm(name) + if _, ok := f.nodes[key]; !ok { + return f.pathErr("remove", name, fs.ErrNotExist) + } + delete(f.nodes, key) + return nil +} + +func (f *FS) RemoveAll(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + + key := norm(name) + if key == "" { + return f.pathErr("removeall", name, fs.ErrInvalid) + } + for k := range f.nodes { + if k == key || strings.HasPrefix(k, key+"/") { + delete(f.nodes, k) + } + } + return nil +} + +func (f *FS) Rename(oldname, newname string) error { + f.mu.Lock() + defer f.mu.Unlock() + + from, to := norm(oldname), norm(newname) + if _, ok := f.nodes[from]; !ok { + return f.pathErr("rename", oldname, fs.ErrNotExist) + } + if from == "" || to == "" { + return f.pathErr("rename", oldname, fs.ErrInvalid) + } + + moved := map[string]*node{} + for k, n := range f.nodes { + if k == from { + moved[to] = n + n.name = path.Base(to) + } else if strings.HasPrefix(k, from+"/") { + moved[to+strings.TrimPrefix(k, from)] = n + } else { + continue + } + delete(f.nodes, k) + } + for k, n := range moved { + f.nodes[k] = n + } + return nil +} + +func (f *FS) Chmod(name string, mode fs.FileMode) error { + f.mu.Lock() + defer f.mu.Unlock() + + n, ok := f.get(name) + if !ok { + return f.pathErr("chmod", name, fs.ErrNotExist) + } + if n.isDir { + n.mode = fs.ModeDir | mode.Perm() + } else { + n.mode = mode.Perm() + } + return nil +} + +// --- file handles --- + +type fileInfo struct{ n *node } + +func (i *fileInfo) Name() string { return i.n.name } +func (i *fileInfo) Size() int64 { return int64(len(i.n.data)) } +func (i *fileInfo) Mode() fs.FileMode { return i.n.mode } +func (i *fileInfo) ModTime() time.Time { return i.n.modTime } +func (i *fileInfo) IsDir() bool { return i.n.isDir } +func (i *fileInfo) Sys() any { return nil } + +type openFile struct { + n *node + r *strings.Reader +} + +func (o *openFile) Stat() (fs.FileInfo, error) { return &fileInfo{n: o.n}, nil } +func (o *openFile) Close() error { return nil } + +func (o *openFile) Read(p []byte) (int, error) { + if o.r == nil { + return 0, io.EOF + } + return o.r.Read(p) +} + +type writeFile struct { + fs *FS + n *node +} + +func (w *writeFile) Stat() (fs.FileInfo, error) { return &fileInfo{n: w.n}, nil } +func (w *writeFile) Close() error { return nil } +func (w *writeFile) Read([]byte) (int, error) { return 0, io.EOF } + +func (w *writeFile) Write(p []byte) (int, error) { + w.fs.mu.Lock() + defer w.fs.mu.Unlock() + w.n.data = append(w.n.data, p...) + w.n.modTime = time.Now() + return len(p), nil +} + +// Verify interface compliance at compile time. +var ( + _ vfs.WritableFS = (*FS)(nil) + _ vfs.Chmoder = (*FS)(nil) + _ vfs.Lstater = (*FS)(nil) +) diff --git a/internal/vfs/sftpfs/sftpfs.go b/internal/vfs/sftpfs/sftpfs.go new file mode 100644 index 0000000..4977601 --- /dev/null +++ b/internal/vfs/sftpfs/sftpfs.go @@ -0,0 +1,177 @@ +// Package sftpfs adapts an SFTP client to the vfs interfaces. +// +// Paths here are interpreted by the server, not by the machine mdc runs on, +// so this package must not use path/filepath. +package sftpfs + +import ( + "errors" + "io/fs" + "path" + "strings" + + "github.com/pkg/sftp" + + "github.com/kooler/MiddayCommander/internal/vfs" +) + +// FS is a vfs.WritableFS over an SFTP session. +type FS struct { + client *sftp.Client + label string +} + +// New wraps an established client. label is the panel header, by convention +// "ssh://user@host". +func New(client *sftp.Client, label string) *FS { + return &FS{client: client, label: label} +} + +func (f *FS) Label() string { return f.label } + +// Client exposes protocol features the vfs interfaces do not cover. +func (f *FS) Client() *sftp.Client { return f.client } + +// clean normalizes a path for the server. Empty and "." mean the root, which +// generic navigation code may hand over. +func clean(name string) string { + if name == "" || name == "." { + return "/" + } + if !strings.HasPrefix(name, "/") { + return "/" + path.Clean(name) + } + return path.Clean(name) +} + +// --- read side --- + +func (f *FS) Open(name string) (fs.File, error) { + file, err := f.client.Open(clean(name)) + if err != nil { + return nil, err + } + return file, nil +} + +func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) { + infos, err := f.client.ReadDir(clean(name)) + if err != nil { + return nil, err + } + entries := make([]fs.DirEntry, 0, len(infos)) + for _, info := range infos { + entries = append(entries, fs.FileInfoToDirEntry(info)) + } + return entries, nil +} + +func (f *FS) Stat(name string) (fs.FileInfo, error) { + return f.client.Stat(clean(name)) +} + +func (f *FS) Lstat(name string) (fs.FileInfo, error) { + return f.client.Lstat(clean(name)) +} + +func (f *FS) ReadLink(name string) (string, error) { + return f.client.ReadLink(clean(name)) +} + +// --- write side --- + +func (f *FS) Create(name string) (vfs.WriteFile, error) { + file, err := f.client.Create(clean(name)) + if err != nil { + return nil, err + } + return file, nil +} + +// Mkdir creates a directory. SFTP's mkdir carries no mode, so the permission +// is a separate best-effort step. +func (f *FS) Mkdir(name string, perm fs.FileMode) error { + p := clean(name) + if err := f.client.Mkdir(p); err != nil { + return err + } + _ = f.client.Chmod(p, perm.Perm()) + return nil +} + +func (f *FS) MkdirAll(name string, perm fs.FileMode) error { + p := clean(name) + if err := f.client.MkdirAll(p); err != nil { + return err + } + _ = f.client.Chmod(p, perm.Perm()) + return nil +} + +func (f *FS) Remove(name string) error { + return f.client.Remove(clean(name)) +} + +// RemoveAll removes name and everything beneath it. The client's own RemoveAll +// decides what to recurse into with a symlink-following stat, so deleting a +// link to a directory would take the target's contents with it. os.RemoveAll +// does not behave that way, and the two backends have to agree. +func (f *FS) RemoveAll(name string) error { + return f.removeAll(clean(name), nil) +} + +// info is the entry's own lstat when the caller already has it, saving a round +// trip per file; nil means look it up. +func (f *FS) removeAll(p string, info fs.FileInfo) error { + if info == nil { + fi, err := f.client.Lstat(p) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil // already gone, like os.RemoveAll + } + return err + } + info = fi + } + + // A symlink is unlinked, never followed. + if !info.IsDir() { + return f.client.Remove(p) + } + + entries, err := f.client.ReadDir(p) + if err != nil { + return err + } + for _, entry := range entries { + if err := f.removeAll(path.Join(p, entry.Name()), entry); err != nil { + return err + } + } + return f.client.RemoveDirectory(p) +} + +func (f *FS) Rename(oldname, newname string) error { + return f.client.Rename(clean(oldname), clean(newname)) +} + +func (f *FS) Chmod(name string, mode fs.FileMode) error { + return f.client.Chmod(clean(name), mode.Perm()) +} + +// Home is where a panel opens when no path is given. +func (f *FS) Home() string { + wd, err := f.client.Getwd() + if err != nil || wd == "" { + return "/" + } + return clean(wd) +} + +// Verify interface compliance at compile time. +var ( + _ vfs.WritableFS = (*FS)(nil) + _ vfs.Chmoder = (*FS)(nil) + _ vfs.Lstater = (*FS)(nil) + _ vfs.LinkReader = (*FS)(nil) +) diff --git a/internal/vfs/stub_test.go b/internal/vfs/stub_test.go new file mode 100644 index 0000000..51d8e6f --- /dev/null +++ b/internal/vfs/stub_test.go @@ -0,0 +1,10 @@ +package vfs + +import "io/fs" + +// stubFS is a comparable FS value, for testing filesystem identity. +type stubFS struct{ tag int } + +func (stubFS) Open(string) (fs.File, error) { return nil, fs.ErrNotExist } +func (stubFS) ReadDir(string) ([]fs.DirEntry, error) { return nil, fs.ErrNotExist } +func (stubFS) Stat(string) (fs.FileInfo, error) { return nil, fs.ErrNotExist } diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go index 732ddbb..c822ca6 100644 --- a/internal/vfs/vfs.go +++ b/internal/vfs/vfs.go @@ -28,3 +28,21 @@ type WriteFile interface { fs.File io.Writer } + +// Backends differ: an archive is read-only, and SFTP sets a directory's mode +// only after creating it. Optional capabilities are therefore separate +// interfaces that callers type-assert and skip when absent. + +type Chmoder interface { + Chmod(name string, mode fs.FileMode) error +} + +// Lstater stats a symlink without following it. +type Lstater interface { + Lstat(name string) (fs.FileInfo, error) +} + +// LinkReader reads a symlink's target. +type LinkReader interface { + ReadLink(name string) (string, error) +} diff --git a/internal/vfs/walk.go b/internal/vfs/walk.go new file mode 100644 index 0000000..e5dbc68 --- /dev/null +++ b/internal/vfs/walk.go @@ -0,0 +1,58 @@ +package vfs + +import ( + "io/fs" + "sort" +) + +// WalkDir walks the tree rooted at ref, calling fn for the root and every +// entry beneath it, in name order. It stops at the first error fn returns. +func WalkDir(ref FileRef, fn func(ref FileRef, d fs.DirEntry) error) error { + info, err := ref.FS.Stat(ref.Path) + if err != nil { + return err + } + return walk(ref, fs.FileInfoToDirEntry(info), fn) +} + +func walk(ref FileRef, d fs.DirEntry, fn func(FileRef, fs.DirEntry) error) error { + if err := fn(ref, d); err != nil { + return err + } + if !d.IsDir() { + return nil + } + + entries, err := ref.FS.ReadDir(ref.Path) + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + for _, e := range entries { + if err := walk(ref.Join(e.Name()), e, fn); err != nil { + return err + } + } + return nil +} + +// CountFilesAndBytes totals the files and bytes beneath ref. An error stops +// the count and returns what was reached so far. +func CountFilesAndBytes(ref FileRef) (int, int64) { + var files int + var bytes int64 + + _ = WalkDir(ref, func(_ FileRef, d fs.DirEntry) error { + if d.IsDir() { + return nil + } + files++ + if info, err := d.Info(); err == nil { + bytes += info.Size() + } + return nil + }) + + return files, bytes +}