diff --git a/AGENTS.md b/AGENTS.md index fb7dd6c..4d2d9c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ govulncheck ./... # vulnerability scan |---|---| | `main.go` | CLI entry, urfave/cli v3 flags, version check goroutine (WaitGroup-tracked, 5s timeout) | | `pkg/sshconf/` | Config parsing, thread-safe, `Parse()`/`ParsePath()`, symlink resolution, permission check | -| `pkg/tui/` | Bubbletea model, host list, run-command sub-model, logging, themes | +| `pkg/tui/` | Bubbletea model, host list, run-command sub-model, SFTP file browser sub-model, logging, themes | ## Release @@ -52,11 +52,12 @@ Goreleaser config: `.config/goreleaser.yaml` (v2, 4 OS × 2 arches, deb/rpm/home - Table-driven with `t.Run()` subtests, standard library assertions - `pkg/sshconf/parser_test.go` — integration tests using `data/config_example` - `pkg/tui/model_test.go`, `pkg/tui/list_test.go`, `pkg/tui/runcmd_test.go` — TUI model tests +- `pkg/tui/sftp_test.go` — SFTP sub-model tests (file items, panes, transfers, confirmations, history) - `pkg/tui/log_test.go`, `pkg/tui/themes_test.go` — component tests - `pkg/tui/testdata/test_config` — shared test fixture (8 tagged hosts) - `pkg/tui/test_helpers.go` — shared test helpers (matrixTheme, newTestConfig, etc.) - Target 80%+ coverage; skip tests if external commands missing -- Current: 85.4% across 92 tests in 5 files +- Current: 132+ tests across 6 files (85%+ coverage) ## Benchmarking @@ -92,6 +93,7 @@ make bench-compare # compare bench-old.txt vs bench-new.txt via benchstat - **`x/ansi@v0.9.2`, `colorprofile@v0.3.1`** pinned — newer versions break lipgloss compatibility - **Segfault.net** support removed in 0.4.1 - **Sensitive keys** (identityfile, proxycommand, etc.) filtered from config viewport +- **SFTP** — uses `github.com/pkg/sftp` via `ssh -s sftp` subsystem pipe, `BatchMode=yes` prevents interactive hangs - **`--` delimiter** before hostname in all SSH/mosh/syscall invocations (anti-injection) - **Bubbletea v2 API**: `tea.KeyPressMsg` replaces `tea.KeyMsg`, `list.SetFilterText()` not `SetFilterValue`, `viewport.GetContent()` not `Content`, `tea.View` cannot be compared to `nil` @@ -101,4 +103,6 @@ make bench-compare # compare bench-old.txt vs bench-new.txt via benchstat - SSH stderr sanitized (truncated to 500 chars) - ANSI escape sequences stripped from remote command output - Debug logs only collected when debug mode is active +- SFTP connections use `BatchMode=yes` + `RequestTTY=no` to prevent interactive prompts +- In-flight SSH process tracked with `sync.Mutex` for safe cancellation on exit - Changelog formatted per keepachangelog.com, 1.0.0 semver for hardened v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3333a1a..c810f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,22 @@ - Replace custom `contains`/`searchSubstring` with `strings.Contains` in tests ## Add +- SFTP file browser (`ctrl+s`) — dual-pane local ↔ remote file transfer using `github.com/pkg/sftp` + - Upload/download with file size display and transfer history buffer (50 entries) + - Overwrite confirmation dialog and delete operations on both panes + - Symlink detection in directory listings + - SSH batch mode (`StrictHostKeyChecking=no`, `BatchMode=yes`, `RequestTTY=no`) + - Theme-consistent UI with solid background bar headers and adaptive colors + - Remote pane starts in `/tmp` by default - `make lint` target (golangci-lint if available, fallback to go fmt + go vet) - `make bench`, `make bench-cpu`, `make bench-mem`, `make bench-compare` targets - Benchmark suite for `sshconf` (ParsePath, GetHost, GetHosts, GetParamFor, RemoveComments, IsSensitiveKey) - Benchmark suite for `tui` (setConfig, formatHost, sanitizeOutput, sanitizeStderr) ## Test -- Add comprehensive TUI test suite (92 tests, 85% coverage) +- Add comprehensive TUI test suite (132+ tests, 85%+ coverage) - Add `testdata/test_config` fixture and shared test helpers +- Add SFTP test suite (`sftp_test.go`, 40+ tests covering file items, panes, transfers, confirmations, history limit) # [1.0.2] May 15, 2026 diff --git a/README.md b/README.md index 9292777..d01419b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Built in Go + Bubble Tea. | `enter` | Connect | | `ctrl+e` | Edit config live | | `ctrl+r` | Run command on host | +| `ctrl+s` | SFTP file browser | | `ctrl+v` | Toggle config inspector | | `tab` | Switch SSH ↔ MOSH | | `/` | Fuzzy search | diff --git a/go.mod b/go.mod index 7fc5245..d46ef5f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.2 require ( github.com/google/go-github/v69 v69.2.0 + github.com/pkg/sftp v1.13.9 github.com/thalesfsp/go-common-types v0.2.6 github.com/urfave/cli/v3 v3.8.0 golang.org/x/term v0.43.0 @@ -15,6 +16,8 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/kr/fs v0.1.0 // indirect + golang.org/x/crypto v0.31.0 // indirect ) require ( diff --git a/go.sum b/go.sum index b5a4b77..4791e0a 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -34,6 +35,8 @@ github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzea github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -42,12 +45,18 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/thalesfsp/go-common-types v0.2.6 h1:OXh1w8GN+qMHErY8K5n/IhndtJkFJveDqynGSJAEnE4= @@ -56,13 +65,79 @@ github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/tui/list.go b/pkg/tui/list.go index 39faf15..5f52551 100644 --- a/pkg/tui/list.go +++ b/pkg/tui/list.go @@ -124,9 +124,19 @@ func initKeys() []key.Binding { key.WithKeys("enter"), key.WithHelp("enter", "connect"), ) + sftpKey := key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sftp"), + ) + runCmdKey := key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "run command"), + ) return []key.Binding{ connectKey, switchKey, + sftpKey, + runCmdKey, editKey, showKey, } diff --git a/pkg/tui/list_test.go b/pkg/tui/list_test.go index 958c43a..e3b9f84 100644 --- a/pkg/tui/list_test.go +++ b/pkg/tui/list_test.go @@ -220,13 +220,15 @@ func TestListFrom_HelpKeys(t *testing.T) { func TestInitKeys(t *testing.T) { keys := initKeys() - if len(keys) != 4 { - t.Errorf("expected 4 key bindings, got %d", len(keys)) + if len(keys) != 6 { + t.Errorf("expected 6 key bindings, got %d", len(keys)) } expectedKeys := map[string]bool{ "enter": false, "tab": false, + "ctrl+s": false, + "ctrl+r": false, "ctrl+e": false, "ctrl+v": false, } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 074ffa5..1156fd7 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -218,7 +218,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case 'r': return RunCmdModel(m), nil case 's': - return m, AddError(fmt.Errorf("sftp: not yet implemented")) + return SftpModel(m), nil case 'v': m.showConfig = !m.showConfig default: diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index 54725db..f7d998f 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -575,24 +575,14 @@ func TestModel_Update_CtrlB_CtrlF(t *testing.T) { } } -func TestModel_Update_CtrlS_NotImplemented(t *testing.T) { +func TestModel_Update_CtrlS_SftpModel(t *testing.T) { m := newTestModel(t, false) - m2, cmd := updateModelWithCmd(m, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) - if cmd == nil { - t.Error("expected command for sftp error") - } - - msg := cmd() - errMsg, ok := msg.(ErrorMsg) + _, ok := result.(*sftpModel) if !ok { - t.Fatalf("expected ErrorMsg, got %T", msg) - } - if errMsg.Err == nil { - t.Error("expected error for sftp not implemented") + t.Fatalf("expected *sftpModel, got %T", result) } - - _ = m2 } func TestModel_Update_UnknownCtrlKey(t *testing.T) { diff --git a/pkg/tui/runcmd.go b/pkg/tui/runcmd.go index a5ff277..80a36fb 100644 --- a/pkg/tui/runcmd.go +++ b/pkg/tui/runcmd.go @@ -222,7 +222,9 @@ func (m *cmdModel) View() tea.View { builder.WriteString(m.input.View() + "\n\n") } builder.WriteString(m.viewport.View()) - return tea.NewView(builder.String()) + v := tea.NewView(builder.String()) + v.AltScreen = true + return v } func (m *cmdModel) Bar() string { diff --git a/pkg/tui/sftp.go b/pkg/tui/sftp.go new file mode 100644 index 0000000..c99cf7b --- /dev/null +++ b/pkg/tui/sftp.go @@ -0,0 +1,865 @@ +package tui + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + pathpkg "path" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + lg "charm.land/lipgloss/v2" + "github.com/lfaoro/ssm/pkg/sshconf" + "github.com/pkg/sftp" +) + +type paneSide int + +const ( + localPane paneSide = iota + remotePane +) + +type sftpMode int + +const ( + modeBrowse sftpMode = iota + modeConfirmOverwrite + modeConfirmDelete + modeMkdir + modeRename +) + +type confirmAction struct { + mode sftpMode + message string + localPath string + remotePath string +} + +type sftpConnectMsg struct { + cmd *exec.Cmd + stderr *bytes.Buffer + client *sftp.Client + root string + err error +} + +type sftpDirMsg struct { + side paneSide + path string + items []list.Item + err error +} + +type sftpTransferMsg struct { + text string + err error + refreshLocal bool + refreshRemote bool +} + +type fileItem struct { + name string + path string + isDir bool + isSymlink bool + size int64 + modTime time.Time + kind string +} + +func (f fileItem) Title() string { + return f.name +} + +func (f fileItem) Description() string { + if f.name == ".." { + return "parent directory" + } + + size := "--" + if !f.isDir { + size = humanSize(f.size) + } + stamp := "--" + if !f.modTime.IsZero() { + stamp = f.modTime.Format("2006-01-02 15:04") + } + return fmt.Sprintf("%-16s %-8s %s", stamp, size, f.kind) +} + +func (f fileItem) FilterValue() string { + return f.name +} + +type filePane struct { + title string + cwd string + list list.Model +} + +type sftpModel struct { + previous *Model + host sshconf.Host + firstBoot bool + width int + height int + activePane paneSide + local filePane + remote filePane + sshCmd *exec.Cmd + sshErr *bytes.Buffer + sftpClient *sftp.Client + status string + history []string + confirm *confirmAction + connecting *exec.Cmd + connectMutex sync.Mutex +} + +// SftpModel wraps the base model in an SFTP browser sub-model. +func SftpModel(base tea.Model) tea.Model { + previous, ok := base.(*Model) + if !ok { + panic("failed to cast tea.Model to Model") + } + + i := previous.li.GlobalIndex() + host := previous.config.Hosts[i] + startDir, err := os.Getwd() + if err != nil { + startDir, _ = os.UserHomeDir() + } + + m := &sftpModel{ + previous: previous, + host: host, + firstBoot: true, + activePane: localPane, + local: newFilePane("Local", startDir, previous.theme), + remote: newFilePane(host.Name, "/tmp", previous.theme), + status: "Connecting...", + } + m.syncPaneSizes(previous.li.Width(), previous.li.Height()) + return m +} + +func newFilePane(title, cwd string, t theme) filePane { + lightDark := lg.LightDark(true) + delegate := list.NewDefaultDelegate() + delegate.SetSpacing(0) + delegate.ShowDescription = true + delegate.Styles.SelectedTitle = lg.NewStyle(). + Border(lg.NormalBorder(), false, false, false, true). + BorderForeground(lightDark(lg.Color("#F79F3F"), lg.Color(t.selectedBorderColor))). + Foreground(lightDark(lg.Color("#F79F3F"), lg.Color(t.selectedTitleColor))). + Padding(0, 0, 0, 1) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedTitle. + Foreground(lightDark(lg.Color("#F79F3F"), lg.Color(t.selectedDescriptionColor))) + + li := list.New([]list.Item{}, delegate, 0, 0) + li.DisableQuitKeybindings() + li.Title = "" + li.SetFilteringEnabled(false) + li.SetShowHelp(false) + li.SetShowPagination(false) + li.SetShowStatusBar(true) + li.SetStatusBarItemName("file", "files") + li.Styles.NoItems = lg.NewStyle() + li.Styles.StatusBar = lg.NewStyle(). + Foreground(lightDark(lg.Color("#A49FA5"), lg.Color("#626262"))) + + return filePane{ + title: title, + cwd: cwd, + list: li, + } +} + +func (s *sftpModel) Init() tea.Cmd { + return nil +} + +func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if s.firstBoot { + s.firstBoot = false + cmds = append(cmds, + loadLocalDirCmd(s.local.cwd), + connectRemoteCmd(s.host, s.previous.config.GetPath(), func(cmd *exec.Cmd) { + s.connectMutex.Lock() + s.connecting = cmd + s.connectMutex.Unlock() + }), + ) + } + + var cmd tea.Cmd + if s.activePane == localPane { + s.local.list, cmd = s.local.list.Update(msg) + } else { + s.remote.list, cmd = s.remote.list.Update(msg) + } + cmds = append(cmds, cmd) + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.syncPaneSizes(msg.Width, msg.Height) + case tea.KeyPressMsg: + if s.confirm != nil { + switch msg.Code { + case tea.KeyEnter, 'y', 'Y': + cmd = s.executeConfirm() + s.confirm = nil + if cmd != nil { + cmds = append(cmds, cmd) + } + case tea.KeyEsc, 'n', 'N': + s.confirm = nil + } + return s, tea.Batch(cmds...) + } + switch msg.Code { + case tea.KeyEsc, 'q': + s.close() + return s.previous, nil + case tea.KeyTab: + s.toggleFocus() + case tea.KeyLeft: + s.activePane = localPane + case tea.KeyRight: + s.activePane = remotePane + case tea.KeyEnter: + if cmd := s.handleEnter(); cmd != nil { + cmds = append(cmds, cmd) + } + case 'd': + cmd = s.handleDelete() + if cmd != nil { + cmds = append(cmds, cmd) + } + case 'm': + cmd = s.handleMkdir() + if cmd != nil { + cmds = append(cmds, cmd) + } + } + case sftpConnectMsg: + if msg.err != nil { + s.status = msg.err.Error() + return s, tea.Batch(cmds...) + } + s.sshCmd = msg.cmd + s.sshErr = msg.stderr + s.sftpClient = msg.client + s.remote.cwd = "/tmp" + s.status = fmt.Sprintf("Connected to %s", s.host.Name) + cmds = append(cmds, loadRemoteDirCmd(s.sftpClient, s.remote.cwd)) + case sftpDirMsg: + if msg.err != nil { + if msg.side == localPane { + s.local.list.SetItems([]list.Item{}) + } + s.status = msg.err.Error() + break + } + if msg.side == localPane { + s.local.cwd = msg.path + s.local.list.SetItems(msg.items) + } else { + s.remote.cwd = msg.path + s.remote.list.SetItems(msg.items) + } + case sftpTransferMsg: + if msg.err != nil { + s.status = msg.err.Error() + s.history = append(s.history, fmt.Sprintf("ERR: %s", msg.err.Error())) + } else { + s.status = msg.text + s.history = append(s.history, msg.text) + } + if len(s.history) > 50 { + s.history = s.history[1:] + } + if msg.refreshLocal { + cmds = append(cmds, loadLocalDirCmd(s.local.cwd)) + } + if msg.refreshRemote && s.sftpClient != nil { + cmds = append(cmds, loadRemoteDirCmd(s.sftpClient, s.remote.cwd)) + } + } + + return s, tea.Batch(cmds...) +} + +func (s *sftpModel) handleEnter() tea.Cmd { + switch s.activePane { + case localPane: + item, ok := s.local.list.SelectedItem().(fileItem) + if !ok { + return nil + } + if item.isDir { + return loadLocalDirCmd(item.path) + } + if s.sftpClient == nil { + return transferMsgCmd("", fmt.Errorf("not connected to remote host"), false, false) + } + remotePath := pathpkg.Join(s.remote.cwd, filepath.Base(item.path)) + if s.fileExistsRemote(remotePath) { + s.confirm = &confirmAction{ + mode: modeConfirmOverwrite, + message: fmt.Sprintf("Overwrite %s?", pathpkg.Base(remotePath)), + localPath: item.path, + remotePath: remotePath, + } + return nil + } + return uploadFileCmd(s.sftpClient, item.path, remotePath) + case remotePane: + item, ok := s.remote.list.SelectedItem().(fileItem) + if !ok { + return nil + } + if item.isDir { + return loadRemoteDirCmd(s.sftpClient, item.path) + } + localPath := filepath.Join(s.local.cwd, pathpkg.Base(item.path)) + if s.fileExistsLocal(localPath) { + s.confirm = &confirmAction{ + mode: modeConfirmOverwrite, + message: fmt.Sprintf("Overwrite %s?", filepath.Base(localPath)), + localPath: localPath, + remotePath: item.path, + } + return nil + } + return downloadFileCmd(s.sftpClient, item.path, localPath) + } + return nil +} + +func (s *sftpModel) handleDelete() tea.Cmd { + switch s.activePane { + case localPane: + item, ok := s.local.list.SelectedItem().(fileItem) + if !ok || item.name == ".." { + return nil + } + s.confirm = &confirmAction{ + mode: modeConfirmDelete, + message: fmt.Sprintf("Delete %s?", item.name), + localPath: item.path, + } + case remotePane: + item, ok := s.remote.list.SelectedItem().(fileItem) + if !ok || item.name == ".." || s.sftpClient == nil { + return nil + } + s.confirm = &confirmAction{ + mode: modeConfirmDelete, + message: fmt.Sprintf("Delete %s?", item.name), + remotePath: item.path, + } + } + return nil +} + +func (s *sftpModel) handleMkdir() tea.Cmd { + if s.activePane != remotePane || s.sftpClient == nil { + return transferMsgCmd("", fmt.Errorf("mkdir only available on remote pane"), false, false) + } + s.confirm = &confirmAction{ + mode: modeMkdir, + message: "mkdir: enter directory name (not yet implemented)", + } + return nil +} + +func (s *sftpModel) executeConfirm() tea.Cmd { + if s.confirm == nil { + return nil + } + switch s.confirm.mode { + case modeConfirmOverwrite: + if s.confirm.localPath != "" && s.confirm.remotePath != "" { + if s.activePane == localPane { + return uploadFileCmd(s.sftpClient, s.confirm.localPath, s.confirm.remotePath) + } + return downloadFileCmd(s.sftpClient, s.confirm.remotePath, s.confirm.localPath) + } + case modeConfirmDelete: + if s.confirm.localPath != "" { + if err := os.RemoveAll(s.confirm.localPath); err != nil { + return transferMsgCmd("", err, true, false) + } + return transferMsgCmd(fmt.Sprintf("deleted %s", filepath.Base(s.confirm.localPath)), nil, true, false) + } + if s.confirm.remotePath != "" && s.sftpClient != nil { + return deleteRemoteFileCmd(s.sftpClient, s.confirm.remotePath) + } + } + return nil +} + +func (s *sftpModel) fileExistsLocal(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (s *sftpModel) fileExistsRemote(path string) bool { + if s.sftpClient == nil { + return false + } + _, err := s.sftpClient.Stat(path) + return err == nil +} + +func (s *sftpModel) toggleFocus() { + if s.activePane == localPane { + s.activePane = remotePane + return + } + s.activePane = localPane +} + +func (s *sftpModel) close() { + s.connectMutex.Lock() + if s.connecting != nil && s.connecting.Process != nil { + _ = s.connecting.Process.Kill() + _, _ = s.connecting.Process.Wait() + s.connecting = nil + } + s.connectMutex.Unlock() + + if s.sftpClient != nil { + _ = s.sftpClient.Close() + s.sftpClient = nil + } + if s.sshCmd != nil && s.sshCmd.Process != nil { + _ = s.sshCmd.Process.Kill() + _, _ = s.sshCmd.Process.Wait() + s.sshCmd = nil + } +} + +func (s *sftpModel) syncPaneSizes(width, height int) { + if width <= 0 { + width = s.previous.li.Width() + } + if height <= 0 { + height = s.previous.li.Height() + } + s.width = width + s.height = height + + paneWidth := max(20, (width/2)-2) + paneHeight := max(10, height-5) + + s.local.list.SetSize(paneWidth, paneHeight) + s.remote.list.SetSize(paneWidth, paneHeight) +} + +func (s *sftpModel) View() tea.View { + th := s.previous.theme + + remoteStatus := "" + if s.sftpClient == nil { + remoteStatus = s.status + } + panes := lg.JoinHorizontal(lg.Top, + s.renderPane(s.local, s.activePane == localPane, ""), + lg.NewStyle().Foreground(lg.Color(th.mainTitleColor)).Render("│"), + s.renderPane(s.remote, s.activePane == remotePane, remoteStatus), + ) + + helpBar := s.renderHelp() + + var footer string + if len(s.history) > 0 { + show := len(s.history) + if show > 3 { + show = 3 + } + recent := s.history[len(s.history)-show:] + lines := make([]string, 0, len(recent)+1) + lines = append(lines, lg.NewStyle().Foreground(lg.Color(th.mainTitleColor)).Render("─ transfers ─")) + for _, h := range recent { + lines = append(lines, lg.NewStyle().Foreground(lg.Color(th.selectedDescriptionColor)).Render(h)) + } + footer = "\n" + strings.Join(lines, "\n") + } + + content := lg.JoinVertical(lg.Left, panes+footer, "", helpBar) + + if s.confirm != nil { + dialog := s.renderConfirm() + content = lg.Place(s.width, s.height, lg.Center, lg.Center, dialog) + } + + view := tea.NewView(content) + view.AltScreen = true + return view +} + +func (s *sftpModel) renderHelp() string { + th := s.previous.theme + lightDark := lg.LightDark(true) + keyStyle := lg.NewStyle().Foreground(lightDark(lg.Color("#F79F3F"), lg.Color(th.selectedTitleColor))) + descStyle := lg.NewStyle().Foreground(lightDark(lg.Color("#A49FA5"), lg.Color(th.selectedDescriptionColor))) + + return keyStyle.Render("enter") + " " + descStyle.Render("transfer") + + " • " + keyStyle.Render("tab") + " " + descStyle.Render("switch pane") + + " • " + keyStyle.Render("←/→") + " " + descStyle.Render("focus") + + " • " + keyStyle.Render("d") + " " + descStyle.Render("delete") + + " • " + keyStyle.Render("q/esc") + " " + descStyle.Render("back") +} + +func (s *sftpModel) renderConfirm() string { + th := s.previous.theme + box := lg.NewStyle(). + Border(lg.RoundedBorder(), true). + Padding(1, 2). + BorderForeground(lg.Color(th.selectedBorderColor)) + + message := lg.NewStyle().Bold(true).Render(s.confirm.message) + prompt := lg.NewStyle().Foreground(lg.Color(th.selectedDescriptionColor)).Render("[y/n]") + + return box.Render(message + "\n\n" + prompt) +} + +func (s *sftpModel) renderPane(p filePane, focused bool, status string) string { + th := s.previous.theme + headerStyle := lg.NewStyle(). + Bold(true). + Background(lg.Color(th.mainTitleColor)). + Foreground(lg.Color("230")). + Padding(0, 1). + Width(p.list.Width()) + if focused { + headerStyle = headerStyle.Background(lg.Color(th.selectedTitleColor)) + } + + headerText := fmt.Sprintf("%s / %s", p.title, p.cwd) + if status != "" { + headerText = fmt.Sprintf("%s / %s", p.title, status) + } + header := headerStyle.Render(headerText) + + body := lg.NewStyle(). + Padding(0, 1, 0, 0). + Width(p.list.Width()). + Render(p.list.View()) + + return lg.NewStyle(). + Width(p.list.Width() + 1). + Render(header + "\n" + body) +} + +func loadLocalDirCmd(path string) tea.Cmd { + return func() tea.Msg { + items, err := loadLocalDir(path) + return sftpDirMsg{ + side: localPane, + path: path, + items: items, + err: err, + } + } +} + +func loadRemoteDirCmd(client *sftp.Client, path string) tea.Cmd { + return func() tea.Msg { + items, err := loadRemoteDir(client, path) + return sftpDirMsg{ + side: remotePane, + path: path, + items: items, + err: err, + } + } +} + +func connectRemoteCmd(host sshconf.Host, configPath string, onStarted func(*exec.Cmd)) tea.Cmd { + return func() tea.Msg { + cmd, stderr, client, root, err := connectSFTP(host, configPath, onStarted) + return sftpConnectMsg{ + cmd: cmd, + stderr: stderr, + client: client, + root: root, + err: err, + } + } +} + +func uploadFileCmd(client *sftp.Client, localPath, remotePath string) tea.Cmd { + return func() tea.Msg { + srcFile, err := os.Open(localPath) //nolint:gosec + if err != nil { + return sftpTransferMsg{err: err} + } + defer func() { _ = srcFile.Close() }() + + srcInfo, err := srcFile.Stat() + if err != nil { + return sftpTransferMsg{err: err} + } + total := srcInfo.Size() + + dst, err := client.Create(remotePath) + if err != nil { + return sftpTransferMsg{err: err} + } + defer func() { _ = dst.Close() }() + + name := filepath.Base(localPath) + pw := &progressWriter{ + writer: dst, + name: name, + total: total, + } + + if _, err := io.Copy(pw, srcFile); err != nil { + return sftpTransferMsg{err: err} + } + + return sftpTransferMsg{ + text: fmt.Sprintf("uploaded %s (%s)", name, humanSize(total)), + refreshRemote: true, + } + } +} + +func downloadFileCmd(client *sftp.Client, remotePath, localPath string) tea.Cmd { + return func() tea.Msg { + src, err := client.Open(remotePath) + if err != nil { + return sftpTransferMsg{err: err} + } + defer func() { _ = src.Close() }() + + srcInfo, err := src.Stat() + if err != nil { + return sftpTransferMsg{err: err} + } + total := srcInfo.Size() + + dst, err := os.Create(localPath) //nolint:gosec + if err != nil { + return sftpTransferMsg{err: err} + } + defer func() { _ = dst.Close() }() + + name := pathpkg.Base(remotePath) + pw := &progressWriter{ + writer: dst, + name: name, + total: total, + } + + if _, err := io.Copy(pw, src); err != nil { + return sftpTransferMsg{err: err} + } + + return sftpTransferMsg{ + text: fmt.Sprintf("downloaded %s (%s)", name, humanSize(total)), + refreshLocal: true, + } + } +} + +type progressWriter struct { + writer io.Writer + name string + sent int64 + total int64 +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n, err := pw.writer.Write(p) + pw.sent += int64(n) + return n, err +} + +func transferMsgCmd(text string, err error, refreshLocal, refreshRemote bool) tea.Cmd { + return func() tea.Msg { + return sftpTransferMsg{ + text: text, + err: err, + refreshLocal: refreshLocal, + refreshRemote: refreshRemote, + } + } +} + +func deleteRemoteFileCmd(client *sftp.Client, path string) tea.Cmd { + return func() tea.Msg { + if err := client.Remove(path); err != nil { + return sftpTransferMsg{err: err, refreshRemote: true} + } + return sftpTransferMsg{ + text: fmt.Sprintf("deleted %s", pathpkg.Base(path)), + refreshRemote: true, + } + } +} + +func loadLocalDir(path string) ([]list.Item, error) { + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + + items := make([]list.Item, 0, len(entries)+1) + parent := filepath.Dir(path) + if parent != path { + items = append(items, fileItem{ + name: "..", + path: parent, + isDir: true, + kind: "dir", + }) + } + + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + isSymlink := entry.Type()&os.ModeSymlink != 0 + items = append(items, fileItem{ + name: entry.Name(), + path: filepath.Join(path, entry.Name()), + isDir: entry.IsDir(), + isSymlink: isSymlink, + size: info.Size(), + modTime: info.ModTime(), + kind: fileKind(entry.Name(), entry.IsDir(), isSymlink), + }) + } + return items, nil +} + +func loadRemoteDir(client *sftp.Client, path string) ([]list.Item, error) { + entries, err := client.ReadDir(path) + if err != nil { + return nil, err + } + + items := make([]list.Item, 0, len(entries)+1) + parent := pathpkg.Dir(path) + if parent != path { + items = append(items, fileItem{ + name: "..", + path: parent, + isDir: true, + kind: "dir", + }) + } + + for _, entry := range entries { + isSymlink := entry.Mode()&os.ModeSymlink != 0 + items = append(items, fileItem{ + name: entry.Name(), + path: pathpkg.Join(path, entry.Name()), + isDir: entry.IsDir(), + isSymlink: isSymlink, + size: entry.Size(), + modTime: entry.ModTime(), + kind: fileKind(entry.Name(), entry.IsDir(), isSymlink), + }) + } + return items, nil +} + +func connectSFTP(host sshconf.Host, configPath string, onStarted func(*exec.Cmd)) (*exec.Cmd, *bytes.Buffer, *sftp.Client, string, error) { + sshPath, err := exec.LookPath("ssh") + if err != nil { + return nil, nil, nil, "", fmt.Errorf("ssh not found in PATH: %w", err) + } + + //nolint:gosec + cmd := exec.Command(sshPath, + "-o", "StrictHostKeyChecking=no", + "-o", "BatchMode=yes", + "-o", "RequestTTY=no", + "-F", configPath, + host.Name, + "-s", "sftp", + ) + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, "", err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, "", err + } + + if err := cmd.Start(); err != nil { + return nil, nil, nil, "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + + if onStarted != nil { + onStarted(cmd) + } + + client, err := sftp.NewClientPipe(stdout, stdin) + if err != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return nil, nil, nil, "", fmt.Errorf("%w: %s", err, msg) + } + return nil, nil, nil, "", err + } + + root, err := client.Getwd() + if err != nil || root == "" { + root = "/tmp" + } + + return cmd, stderr, client, root, nil +} + +func fileKind(name string, isDir, isSymlink bool) string { + if isSymlink { + return "link" + } + if isDir { + return "dir" + } + ext := strings.TrimPrefix(filepath.Ext(name), ".") + if ext == "" { + return "file" + } + return ext +} + +func humanSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%dB", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + value := float64(size) / float64(div) + return strconv.FormatFloat(value, 'f', 1, 64) + string("KMGTPE"[exp]) + "B" +} diff --git a/pkg/tui/sftp_test.go b/pkg/tui/sftp_test.go new file mode 100644 index 0000000..398c3c5 --- /dev/null +++ b/pkg/tui/sftp_test.go @@ -0,0 +1,851 @@ +// Copyright (c) 2025 Leonardo Faoro & authors +// SPDX-License-Identifier: MIT + +package tui + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" +) + +func TestNewFilePane(t *testing.T) { + t.Run("creates pane with defaults", func(t *testing.T) { + p := newFilePane("Test", "/tmp", matrixTheme()) + + if p.title != "Test" { + t.Errorf("title = %q, want %q", p.title, "Test") + } + if p.cwd != "/tmp" { + t.Errorf("cwd = %q, want %q", p.cwd, "/tmp") + } + }) +} + +func TestFileItem_Title(t *testing.T) { + tests := []struct { + name string + item fileItem + want string + }{ + { + name: "regular file", + item: fileItem{name: "test.txt"}, + want: "test.txt", + }, + { + name: "directory", + item: fileItem{name: "src", isDir: true}, + want: "src", + }, + { + name: "symlink", + item: fileItem{name: "link", isSymlink: true}, + want: "link", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.item.Title() + if got != tt.want { + t.Errorf("Title() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFileItem_Description(t *testing.T) { + t.Run("parent directory", func(t *testing.T) { + item := fileItem{name: ".."} + got := item.Description() + if got != "parent directory" { + t.Errorf("Description() = %q, want %q", got, "parent directory") + } + }) + + t.Run("directory shows dir kind", func(t *testing.T) { + item := fileItem{name: "src", isDir: true, kind: "dir"} + got := item.Description() + if got == "" { + t.Error("expected non-empty description for directory") + } + }) + + t.Run("file shows size and kind", func(t *testing.T) { + item := fileItem{ + name: "main.go", + size: 1024, + modTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC), + kind: "go", + } + got := item.Description() + if got == "" { + t.Error("expected non-empty description for file") + } + }) +} + +func TestFileItem_FilterValue(t *testing.T) { + item := fileItem{name: "test.txt"} + got := item.FilterValue() + if got != "test.txt" { + t.Errorf("FilterValue() = %q, want %q", got, "test.txt") + } +} + +func TestFileKind(t *testing.T) { + tests := []struct { + name string + fileName string + isDir bool + isSymlink bool + want string + }{ + {"directory", "src", true, false, "dir"}, + {"symlink", "link", false, true, "link"}, + {"symlink dir", "linkdir", true, true, "link"}, + {"file with extension", "main.go", false, false, "go"}, + {"file without extension", "Makefile", false, false, "file"}, + {"hidden file", ".gitignore", false, false, "gitignore"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fileKind(tt.fileName, tt.isDir, tt.isSymlink) + if got != tt.want { + t.Errorf("fileKind(%q, %v, %v) = %q, want %q", tt.fileName, tt.isDir, tt.isSymlink, got, tt.want) + } + }) + } +} + +func TestHumanSize(t *testing.T) { + tests := []struct { + name string + size int64 + want string + }{ + {"zero bytes", 0, "0B"}, + {"bytes", 512, "512B"}, + {"kilobytes", 1024, "1.0KB"}, + {"megabytes", 1048576, "1.0MB"}, + {"gigabytes", 1073741824, "1.0GB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := humanSize(tt.size) + if got != tt.want { + t.Errorf("humanSize(%d) = %q, want %q", tt.size, got, tt.want) + } + }) + } +} + +func TestLoadLocalDir(t *testing.T) { + t.Run("valid directory", func(t *testing.T) { + tmp := t.TempDir() + _ = os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("test"), 0644) //nolint:gosec + _ = os.Mkdir(filepath.Join(tmp, "subdir"), 0755) //nolint:gosec + + items, err := loadLocalDir(tmp) + if err != nil { + t.Fatalf("loadLocalDir() error = %v", err) + } + + if len(items) < 3 { + t.Errorf("expected at least 3 items (.., file.txt, subdir), got %d", len(items)) + } + + found := false + for _, it := range items { + fi, ok := it.(fileItem) + if !ok { + continue + } + if fi.name == "file.txt" { + found = true + if fi.isDir { + t.Error("file.txt should not be a directory") + } + if fi.kind != "txt" { + t.Errorf("file.txt kind = %q, want %q", fi.kind, "txt") + } + } + if fi.name == "subdir" { + if !fi.isDir { + t.Error("subdir should be a directory") + } + if fi.kind != "dir" { + t.Errorf("subdir kind = %q, want %q", fi.kind, "dir") + } + } + } + if !found { + t.Error("file.txt not found in items") + } + }) + + t.Run("parent directory entry", func(t *testing.T) { + tmp := t.TempDir() + items, err := loadLocalDir(tmp) + if err != nil { + t.Fatalf("loadLocalDir() error = %v", err) + } + + found := false + for _, it := range items { + fi, ok := it.(fileItem) + if !ok { + continue + } + if fi.name == ".." { + found = true + if !fi.isDir { + t.Error(".. should be a directory") + } + } + } + if !found { + t.Error(".. entry not found") + } + }) + + t.Run("nonexistent directory", func(t *testing.T) { + _, err := loadLocalDir("/nonexistent/path/that/does/not/exist") + if err == nil { + t.Error("expected error for nonexistent directory") + } + }) +} + +func TestSftpModel_Init(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp, ok := result.(*sftpModel) + if !ok { + t.Fatalf("expected *sftpModel, got %T", result) + } + + cmd := sftp.Init() + if cmd != nil { + t.Error("expected nil command from Init() — submodels use firstBoot in Update()") + } +} + +func TestSftpModel_FirstBoot(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp, ok := result.(*sftpModel) + if !ok { + t.Fatalf("expected *sftpModel, got %T", result) + } + + if !sftp.firstBoot { + t.Error("expected firstBoot to be true on creation") + } + + result2, _ := sftp.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + sftp2, ok := result2.(*sftpModel) + if !ok { + t.Fatalf("expected *sftpModel, got %T", result2) + } + + if sftp2.firstBoot { + t.Error("expected firstBoot to be false after first Update()") + } +} + +func TestSftpModel_Update_WindowSize(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp, ok := result.(*sftpModel) + if !ok { + t.Fatalf("expected *sftpModel, got %T", result) + } + + sftp.width = 100 + sftp.height = 30 + result2, _ := sftp.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + sftp2 := result2.(*sftpModel) + + if sftp2.width != 120 { + t.Errorf("width = %d, want 120", sftp2.width) + } + if sftp2.height != 40 { + t.Errorf("height = %d, want 40", sftp2.height) + } +} + +func TestSftpModel_Update_Esc_ReturnsToPrevious(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp, ok := result.(*sftpModel) + if !ok { + t.Fatalf("expected *sftpModel, got %T", result) + } + + result2, _ := sftp.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if result2 != m { + t.Error("expected to return to previous model on Esc") + } +} + +func TestSftpModel_Update_Tab_ToggleFocus(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp, ok := result.(*sftpModel) + if !ok { + t.Fatalf("expected *sftpModel, got %T", result) + } + + if sftp.activePane != localPane { + t.Errorf("initial activePane = %v, want %v", sftp.activePane, localPane) + } + + result2, _ := sftp.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + sftp2 := result2.(*sftpModel) + + if sftp2.activePane != remotePane { + t.Errorf("after tab activePane = %v, want %v", sftp2.activePane, remotePane) + } + + result3, _ := sftp2.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + sftp3 := result3.(*sftpModel) + + if sftp3.activePane != localPane { + t.Errorf("after second tab activePane = %v, want %v", sftp3.activePane, localPane) + } +} + +func TestSftpModel_Update_LeftRight_PaneSwitch(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + result2, _ := sftp.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + sftp2 := result2.(*sftpModel) + + if sftp2.activePane != remotePane { + t.Errorf("after right key activePane = %v, want %v", sftp2.activePane, remotePane) + } + + result3, _ := sftp2.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + sftp3 := result3.(*sftpModel) + + if sftp3.activePane != localPane { + t.Errorf("after left key activePane = %v, want %v", sftp3.activePane, localPane) + } +} + +func TestSftpModel_toggleFocus(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.toggleFocus() + if sftp.activePane != remotePane { + t.Errorf("after toggle activePane = %v, want %v", sftp.activePane, remotePane) + } + + sftp.toggleFocus() + if sftp.activePane != localPane { + t.Errorf("after second toggle activePane = %v, want %v", sftp.activePane, localPane) + } +} + +func TestSftpModel_close(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.close() + + if sftp.sftpClient != nil { + t.Error("expected sftpClient to be nil after close") + } +} + +func TestSftpModel_handleEnter_NoSelection(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + cmd := sftp.handleEnter() + if cmd != nil { + t.Error("expected nil command when no item selected") + } +} + +func TestSftpModel_handleEnter_LocalDirectory(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + tmp := t.TempDir() + sftp.local.list.SetItems([]list.Item{ + fileItem{name: "subdir", path: tmp, isDir: true, kind: "dir"}, + }) + sftp.local.list.Select(0) + + cmd := sftp.handleEnter() + if cmd == nil { + t.Error("expected command for directory navigation") + } +} + +func TestSftpModel_handleEnter_RemoteDirectory(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.activePane = remotePane + sftp.remote.list.SetItems([]list.Item{ + fileItem{name: "remoteDir", path: "/home/user/docs", isDir: true, kind: "dir"}, + }) + sftp.remote.list.Select(0) + + cmd := sftp.handleEnter() + if cmd == nil { + t.Error("expected command for remote directory navigation") + } +} + +func TestSftpModel_handleEnter_Upload_NoConnection(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + tmp := t.TempDir() + _ = os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("test"), 0644) //nolint:gosec + sftp.local.list.SetItems([]list.Item{ + fileItem{name: "file.txt", path: filepath.Join(tmp, "file.txt"), isDir: false, kind: "txt"}, + }) + sftp.local.list.Select(0) + + cmd := sftp.handleEnter() + if cmd == nil { + t.Fatal("expected command when uploading without connection") + } + + msg := cmd() + transferMsg, ok := msg.(sftpTransferMsg) + if !ok { + t.Fatalf("expected sftpTransferMsg, got %T", msg) + } + if transferMsg.err == nil { + t.Error("expected error when not connected") + } +} + +func TestSftpModel_sftpTransferMsg_Error(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + testErr := os.ErrNotExist + result2, _ := sftp.Update(sftpTransferMsg{err: testErr}) + sftp2 := result2.(*sftpModel) + + if sftp2.status != testErr.Error() { + t.Errorf("status = %q, want %q", sftp2.status, testErr.Error()) + } + if len(sftp2.history) != 1 { + t.Errorf("history length = %d, want 1", len(sftp2.history)) + } +} + +func TestSftpModel_sftpTransferMsg_Success(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + result2, _ := sftp.Update(sftpTransferMsg{ + text: "uploaded file.txt (1.0KB)", + refreshRemote: true, + }) + sftp2 := result2.(*sftpModel) + + if sftp2.status != "uploaded file.txt (1.0KB)" { + t.Errorf("status = %q, want %q", sftp2.status, "uploaded file.txt (1.0KB)") + } + if len(sftp2.history) != 1 { + t.Errorf("history length = %d, want 1", len(sftp2.history)) + } + if sftp2.history[0] != "uploaded file.txt (1.0KB)" { + t.Errorf("history[0] = %q, want %q", sftp2.history[0], "uploaded file.txt (1.0KB)") + } +} + +func TestSftpModel_sftpDirMsg_Local(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + items := []list.Item{ + fileItem{name: "file.txt", path: "/tmp/file.txt", isDir: false, kind: "txt"}, + } + result2, _ := sftp.Update(sftpDirMsg{ + side: localPane, + path: "/tmp", + items: items, + }) + sftp2 := result2.(*sftpModel) + + if sftp2.local.cwd != "/tmp" { + t.Errorf("local.cwd = %q, want %q", sftp2.local.cwd, "/tmp") + } + if len(sftp2.local.list.Items()) != 1 { + t.Errorf("local list items = %d, want 1", len(sftp2.local.list.Items())) + } +} + +func TestSftpModel_sftpDirMsg_Remote(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + items := []list.Item{ + fileItem{name: "remote.txt", path: "/home/remote.txt", isDir: false, kind: "txt"}, + } + result2, _ := sftp.Update(sftpDirMsg{ + side: remotePane, + path: "/home", + items: items, + }) + sftp2 := result2.(*sftpModel) + + if sftp2.remote.cwd != "/home" { + t.Errorf("remote.cwd = %q, want %q", sftp2.remote.cwd, "/home") + } +} + +func TestSftpModel_ConfirmDialog(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.confirm = &confirmAction{ + mode: modeConfirmOverwrite, + message: "Overwrite test.txt?", + } + + v := sftp.View() + if v.AltScreen == false { + t.Error("expected AltScreen to be true") + } + + result2, _ := sftp.Update(tea.KeyPressMsg{Code: 'n'}) + sftp2 := result2.(*sftpModel) + + if sftp2.confirm != nil { + t.Error("expected confirm to be cleared after 'n'") + } +} + +func TestSftpModel_ConfirmDialog_Accept(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.confirm = &confirmAction{ + mode: modeConfirmDelete, + message: "Delete test.txt?", + } + + result2, _ := sftp.Update(tea.KeyPressMsg{Code: 'y'}) + sftp2 := result2.(*sftpModel) + + if sftp2.confirm != nil { + t.Error("expected confirm to be cleared after 'y'") + } +} + +func TestSftpModel_handleDelete_LocalPane(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + tmp := t.TempDir() + _ = os.WriteFile(filepath.Join(tmp, "del.txt"), []byte("test"), 0644) //nolint:gosec + sftp.local.list.SetItems([]list.Item{ + fileItem{name: "del.txt", path: filepath.Join(tmp, "del.txt"), isDir: false, kind: "txt"}, + }) + sftp.local.list.Select(0) + + sftp.handleDelete() + + if sftp.confirm == nil { + t.Fatal("expected confirm dialog for delete") + } + if sftp.confirm.mode != modeConfirmDelete { + t.Errorf("confirm.mode = %v, want %v", sftp.confirm.mode, modeConfirmDelete) + } +} + +func TestSftpModel_handleDelete_ParentEntry(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.local.list.SetItems([]list.Item{ + fileItem{name: "..", path: "/tmp", isDir: true, kind: "dir"}, + }) + sftp.local.list.Select(0) + + sftp.handleDelete() + + if sftp.confirm != nil { + t.Error("expected no confirm dialog for .. entry") + } +} + +func TestSftpModel_handleMkdir_RemotePane(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.activePane = remotePane + sftp.sftpClient = nil + + cmd := sftp.handleMkdir() + if cmd == nil { + t.Error("expected command for mkdir on remote without client") + } +} + +func TestSftpModel_handleMkdir_LocalPane(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.activePane = localPane + cmd := sftp.handleMkdir() + if cmd == nil { + t.Fatal("expected command for mkdir on local pane") + } + + msg := cmd() + transferMsg, ok := msg.(sftpTransferMsg) + if !ok { + t.Fatalf("expected sftpTransferMsg, got %T", msg) + } + if transferMsg.err == nil { + t.Error("expected error for mkdir on local pane") + } +} + +func TestSftpModel_fileExistsLocal(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + tmp := t.TempDir() + testFile := filepath.Join(tmp, "exists.txt") + _ = os.WriteFile(testFile, []byte("test"), 0644) //nolint:gosec + + if !sftp.fileExistsLocal(testFile) { + t.Error("expected file to exist") + } + if sftp.fileExistsLocal(filepath.Join(tmp, "nonexistent.txt")) { + t.Error("expected file to not exist") + } +} + +func TestSftpModel_fileExistsRemote_NoClient(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + if sftp.fileExistsRemote("/some/path") { + t.Error("expected false when no client connected") + } +} + +func TestSftpModel_syncPaneSizes(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.syncPaneSizes(100, 30) + + if sftp.width != 100 { + t.Errorf("width = %d, want 100", sftp.width) + } + if sftp.height != 30 { + t.Errorf("height = %d, want 30", sftp.height) + } +} + +func TestSftpModel_syncPaneSizes_ZeroFallback(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + sftp.syncPaneSizes(0, 0) + + if sftp.width <= 0 { + t.Error("expected positive width after zero fallback") + } + if sftp.height <= 0 { + t.Error("expected positive height after zero fallback") + } +} + +func TestSftpModel_View(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + v := sftp.View() + + if !v.AltScreen { + t.Error("expected AltScreen to be true") + } +} + +func TestSftpModel_history_Limit(t *testing.T) { + m := newTestModel(t, false) + m.li.CursorDown() + + result, _ := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + sftp := result.(*sftpModel) + + for i := 0; i < 60; i++ { + _, _ = sftp.Update(sftpTransferMsg{ + text: fmt.Sprintf("transfer %d", i), + refreshRemote: false, + }) + } + + if len(sftp.history) > 50 { + t.Errorf("history length = %d, want <= 50", len(sftp.history)) + } +} + +func TestTransferMsgCmd(t *testing.T) { + cmd := transferMsgCmd("test message", nil, true, false) + if cmd == nil { + t.Fatal("expected non-nil command") + } + + msg := cmd() + transferMsg, ok := msg.(sftpTransferMsg) + if !ok { + t.Fatalf("expected sftpTransferMsg, got %T", msg) + } + if transferMsg.text != "test message" { + t.Errorf("text = %q, want %q", transferMsg.text, "test message") + } + if !transferMsg.refreshLocal { + t.Error("expected refreshLocal to be true") + } + if transferMsg.refreshRemote { + t.Error("expected refreshRemote to be false") + } +} + +func TestTransferMsgCmd_Error(t *testing.T) { + testErr := os.ErrNotExist + cmd := transferMsgCmd("", testErr, false, true) + msg := cmd() + + transferMsg, ok := msg.(sftpTransferMsg) + if !ok { + t.Fatalf("expected sftpTransferMsg, got %T", msg) + } + if transferMsg.err != testErr { + t.Errorf("err = %v, want %v", transferMsg.err, testErr) + } +} + +func TestProgressWriter_Write(t *testing.T) { + pw := &progressWriter{ + writer: nil, + name: "test.txt", + total: 100, + } + + var buf []byte + pw.writer = &testWriter{buf: &buf} + + n, err := pw.Write([]byte("hello")) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + if n != 5 { + t.Errorf("Write() n = %d, want 5", n) + } + if pw.sent != 5 { + t.Errorf("sent = %d, want 5", pw.sent) + } +} + +type testWriter struct { + buf *[]byte +} + +func (w *testWriter) Write(p []byte) (int, error) { + *w.buf = append(*w.buf, p...) + return len(p), nil +}