From 2f9ff8b5f3206304c1502844f937267f80f40286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ailton=20Ba=C3=BAque?= Date: Mon, 1 Sep 2025 07:34:42 +0200 Subject: [PATCH 1/7] feat(tui): add a scaffold for sftp file browser with local and remote views --- go.mod | 51 +++++++++- go.sum | 213 ++++++++++++++++++++++++++++++++++++++--- pkg/tui/model.go | 2 +- pkg/tui/sftp.go | 102 ++++++++++++++++++++ pkg/tui/sftp_local.go | 69 +++++++++++++ pkg/tui/sftp_remote.go | 86 +++++++++++++++++ 6 files changed, 506 insertions(+), 17 deletions(-) create mode 100644 pkg/tui/sftp.go create mode 100644 pkg/tui/sftp_local.go create mode 100644 pkg/tui/sftp_remote.go diff --git a/go.mod b/go.mod index 51fc48a..a1fae4a 100644 --- a/go.mod +++ b/go.mod @@ -1,33 +1,74 @@ module github.com/lfaoro/ssm -go 1.24 +go 1.24.0 + +toolchain go1.24.6 require ( github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 github.com/google/go-github v17.0.0+incompatible + github.com/rclone/rclone v1.71.0 github.com/thalesfsp/go-common-types v0.2.4 github.com/urfave/cli/v3 v3.3.2 - golang.org/x/term v0.31.0 + golang.org/x/term v0.34.0 ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/abbot/go-http-auth v0.4.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.9.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/windows v0.2.1 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/peterh/liner v1.2.2 // indirect + github.com/pkg/sftp v1.13.9 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/shirou/gopsutil/v4 v4.25.7 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.33.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect ) diff --git a/go.sum b/go.sum index 666edc7..98c8903 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,20 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE= +github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0= +github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI= @@ -24,46 +35,226 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= +github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg= +github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/lanrat/extsort v1.4.0 h1:jysS/Tjnp7mBwJ6NG8SY+XYFi8HF3LujGbqY9jOWjco= +github.com/lanrat/extsort v1.4.0/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= +github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rclone/rclone v1.71.0 h1:PK1+IUs3EL3pCdqaeHBPCiDcBpw3MWaMH1eWJsfC2ww= +github.com/rclone/rclone v1.71.0/go.mod h1:NLyX57FrnZ9nVLTY5TRdMmGelrGKbIRYGcgRkNdqqlA= +github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= +github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM= +github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thalesfsp/go-common-types v0.2.4 h1:OJ+5NjKBebzjx6AUGxvs4+eUZqV+ciBBjbxXudOZQF4= github.com/thalesfsp/go-common-types v0.2.4/go.mod h1:VbwMiYw41/ET/pNXl3e9XUftE+2T58Mrz2jJ4MIrKV4= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U= +github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8= github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 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= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +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/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= +golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20211117180635-dee7805ff2e1/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +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.6/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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +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/model.go b/pkg/tui/model.go index 7a05bd4..d7d8835 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -232,7 +232,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 m.setConfig() diff --git a/pkg/tui/sftp.go b/pkg/tui/sftp.go new file mode 100644 index 0000000..3f725df --- /dev/null +++ b/pkg/tui/sftp.go @@ -0,0 +1,102 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" +) + +type sftpModel struct { + previous tea.Model + remoteFSModel tea.Model + localFSModel tea.Model + fistBoot bool +} + +func SftpModel(base tea.Model) tea.Model { + + previous, ok := base.(*Model) + + i := previous.li.GlobalIndex() + host := previous.config.Hosts[i] + + if !ok { + panic("failed to cast tea.Model to Model") + } + + return &sftpModel{ + previous: base, + fistBoot: true, + localFSModel: NewLocalFileSystem(), + remoteFSModel: NewRemoteFileSystem(&host), + } +} + +func (s *sftpModel) Init() tea.Cmd { + return nil +} + +func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + var cmds []tea.Cmd + var localFSCmd tea.Cmd + var remoteFSCmd tea.Cmd + + // this is a workaround because Init is not called + if s.fistBoot { + s.fistBoot = false + + previousModel, ok := s.previous.(*Model) + if !ok { + panic("failed to cast tea.Model to Model") + } + + var windowSize tea.WindowSizeMsg + + windowSize.Width = previousModel.vp.Width() + windowSize.Height = previousModel.vp.Height() + + var uLocalFSCmd, uRemoteFSCmd tea.Cmd + s.localFSModel, _ = s.localFSModel.Update(windowSize) + s.remoteFSModel, _ = s.remoteFSModel.Update(windowSize) + + cmds = append(cmds, uLocalFSCmd, uRemoteFSCmd, s.localFSModel.Init(), s.remoteFSModel.Init()) + + // return s, tea.Batch(cmds...) + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return s.previous, nil + } + + } + + s.localFSModel, localFSCmd = s.localFSModel.Update(msg) + s.remoteFSModel, remoteFSCmd = s.remoteFSModel.Update(msg) + + cmds = append(cmds, localFSCmd, remoteFSCmd) + + return s, tea.Batch(cmds...) +} + +func (s *sftpModel) View() string { + + local, ok := s.localFSModel.(*localFileSystem) + remote, ok := s.remoteFSModel.(*remoteFileSystem) + + if !ok { + panic("failed to cast tea.Model to localFileSystem Model") + } + + var view strings.Builder + view.WriteString("send receive files") + + fs := lipgloss.JoinHorizontal(lipgloss.Top, local.View(), remote.View()) + view.WriteString("\n\n" + fs + "\n") + + return view.String() +} diff --git a/pkg/tui/sftp_local.go b/pkg/tui/sftp_local.go new file mode 100644 index 0000000..5a65254 --- /dev/null +++ b/pkg/tui/sftp_local.go @@ -0,0 +1,69 @@ +package tui + +import ( + "os" + + "github.com/charmbracelet/bubbles/v2/filepicker" + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + // "github.com/charmbracelet/lipgloss/v2" +) + +type localFileSystem struct { + filePicker filepicker.Model + selectedFile string + viewport viewport.Model +} + +func NewLocalFileSystem() tea.Model { + + fp := filepicker.New() + fp.CurrentDirectory, _ = os.UserHomeDir() + + vp := viewport.New() + + return &localFileSystem{ + filePicker: fp, + viewport: vp, + } +} + +func (l *localFileSystem) Init() tea.Cmd { + return l.filePicker.Init() +} + +func (l *localFileSystem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + return l, nil + } + + case tea.WindowSizeMsg: + l.filePicker.SetHeight(msg.Height) + l.viewport.SetHeight(msg.Height) + l.viewport.SetWidth(msg.Width) + // l.viewport.Style = lipgloss.NewStyle().Background(lipgloss.Color("#28f3433")) + + } + + var cmd tea.Cmd + l.filePicker, cmd = l.filePicker.Update(msg) + + // Did the user select a file? + if didSelect, path := l.filePicker.DidSelectFile(msg); didSelect { + // Get the path of the selected file. + l.selectedFile = path + } + + return l, cmd +} + +func (l *localFileSystem) View() string { + + l.viewport.SetContent(l.filePicker.View()) + // return l.filePicker.View() + return l.viewport.View() +} diff --git a/pkg/tui/sftp_remote.go b/pkg/tui/sftp_remote.go new file mode 100644 index 0000000..5a706b8 --- /dev/null +++ b/pkg/tui/sftp_remote.go @@ -0,0 +1,86 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/v2/list" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/lfaoro/ssm/pkg/sshconf" + "github.com/pkg/sftp" +) + +type remoteFile struct { + Name string + IsDir bool +} + +func (f remoteFile) Title() string { + return f.Name +} + +func (f remoteFile) Description() string { + if f.IsDir { + return "directory" + } + return "file" +} + +func (f remoteFile) FilterValue() string { + return f.Name +} + +type remoteFileSystem struct { + list list.Model + client *sftp.Client + cwd string + host sshconf.Host + selected string + connected bool +} + +func NewRemoteFileSystem(host *sshconf.Host) tea.Model { + + return &remoteFileSystem{ + cwd: ".", + host: *host, + } +} + +func (l *remoteFileSystem) Init() tea.Cmd { + return nil +} + +func (l *remoteFileSystem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + return l, nil + } + + } + + return l, nil +} + +func (l *remoteFileSystem) View() string { + + return "" +} + +func loadDir(client *sftp.Client, path string) []list.Item { + entries, err := client.ReadDir(path) + if err != nil { + return []list.Item{remoteFile{Name: fmt.Sprintf("error: %v", err)}} + } + + items := []list.Item{} + if path != "/" { + items = append(items, remoteFile{Name: "..", IsDir: true}) // parent dir + } + for _, e := range entries { + items = append(items, remoteFile{Name: e.Name(), IsDir: e.IsDir()}) + } + return items +} From 88b1276f434738584ec40a9949bc8096ea19671f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ailton=20Ba=C3=BAque?= Date: Mon, 6 Apr 2026 06:36:21 +0200 Subject: [PATCH 2/7] feat(tui): implement dual-pane sftp browser and file transfer --- go.mod | 40 +-- go.sum | 122 +-------- pkg/tui/sftp.go | 570 +++++++++++++++++++++++++++++++++++++---- pkg/tui/sftp_local.go | 69 ----- pkg/tui/sftp_remote.go | 86 ------- 5 files changed, 528 insertions(+), 359 deletions(-) delete mode 100644 pkg/tui/sftp_local.go delete mode 100644 pkg/tui/sftp_remote.go diff --git a/go.mod b/go.mod index a1fae4a..3359b02 100644 --- a/go.mod +++ b/go.mod @@ -9,66 +9,30 @@ require ( github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 github.com/google/go-github v17.0.0+incompatible - github.com/rclone/rclone v1.71.0 + github.com/pkg/sftp v1.13.9 github.com/thalesfsp/go-common-types v0.2.4 github.com/urfave/cli/v3 v3.3.2 golang.org/x/term v0.34.0 ) require ( - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/abbot/go-http-auth v0.4.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.9.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/windows v0.2.1 // indirect - github.com/coreos/go-semver v0.3.1 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.8.4 // indirect - github.com/go-chi/chi/v5 v5.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/peterh/liner v1.2.2 // indirect - github.com/pkg/sftp v1.13.9 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_golang v1.23.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.17.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/shirou/gopsutil/v4 v4.25.7 // indirect - github.com/spf13/pflag v1.0.7 // indirect - github.com/tklauser/go-sysconf v0.3.15 // indirect - github.com/tklauser/numcpus v0.10.0 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - github.com/zeebo/blake3 v0.2.4 // indirect - github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.12.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect ) diff --git a/go.sum b/go.sum index 98c8903..56e7832 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,9 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE= -github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0= -github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI= @@ -35,26 +24,9 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= -github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= -github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= -github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -63,113 +35,48 @@ github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4r github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg= -github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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/lanrat/extsort v1.4.0 h1:jysS/Tjnp7mBwJ6NG8SY+XYFi8HF3LujGbqY9jOWjco= -github.com/lanrat/extsort v1.4.0/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= -github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= -github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= -github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= -github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/rclone/rclone v1.71.0 h1:PK1+IUs3EL3pCdqaeHBPCiDcBpw3MWaMH1eWJsfC2ww= -github.com/rclone/rclone v1.71.0/go.mod h1:NLyX57FrnZ9nVLTY5TRdMmGelrGKbIRYGcgRkNdqqlA= -github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= -github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM= -github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thalesfsp/go-common-types v0.2.4 h1:OJ+5NjKBebzjx6AUGxvs4+eUZqV+ciBBjbxXudOZQF4= github.com/thalesfsp/go-common-types v0.2.4/go.mod h1:VbwMiYw41/ET/pNXl3e9XUftE+2T58Mrz2jJ4MIrKV4= -github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= -github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= -github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= -github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U= -github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8= github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 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/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= -github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= -github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= -github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 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/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= -golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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= @@ -177,15 +84,12 @@ 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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 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= @@ -196,20 +100,11 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20211117180635-dee7805ff2e1/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.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= @@ -230,7 +125,6 @@ golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 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.6/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= @@ -238,10 +132,6 @@ 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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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= @@ -250,11 +140,7 @@ 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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 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/sftp.go b/pkg/tui/sftp.go index 3f725df..a8f2876 100644 --- a/pkg/tui/sftp.go +++ b/pkg/tui/sftp.go @@ -1,35 +1,150 @@ package tui import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + pathpkg "path" + "path/filepath" + "strconv" "strings" + "time" + "github.com/charmbracelet/bubbles/v2/list" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + lg "github.com/charmbracelet/lipgloss/v2" + "github.com/lfaoro/ssm/pkg/sshconf" + "github.com/pkg/sftp" ) +type paneSide int + +const ( + localPane paneSide = iota + remotePane +) + +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 + 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 tea.Model - remoteFSModel tea.Model - localFSModel tea.Model - fistBoot bool + 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 } 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() + } - if !ok { - panic("failed to cast tea.Model to Model") + m := &sftpModel{ + previous: previous, + host: host, + firstBoot: true, + activePane: localPane, + local: newFilePane("Local", startDir), + remote: newFilePane(host.Name, "."), + status: "Connecting...", } + m.syncPaneSizes(previous.li.Width(), previous.li.Height()) + return m +} + +func newFilePane(title, cwd string) filePane { + delegate := list.NewDefaultDelegate() + delegate.SetSpacing(0) + delegate.ShowDescription = true - return &sftpModel{ - previous: base, - fistBoot: true, - localFSModel: NewLocalFileSystem(), - remoteFSModel: NewRemoteFileSystem(&host), + li := list.New([]list.Item{}, delegate, 0, 0) + li.DisableQuitKeybindings() + li.SetFilteringEnabled(false) + li.SetShowHelp(false) + li.SetShowPagination(false) + li.SetShowStatusBar(false) + li.SetShowFilter(false) + + return filePane{ + title: title, + cwd: cwd, + list: li, } } @@ -38,65 +153,424 @@ func (s *sftpModel) Init() tea.Cmd { } func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var localFSCmd tea.Cmd - var remoteFSCmd tea.Cmd - // this is a workaround because Init is not called - if s.fistBoot { - s.fistBoot = false + if s.firstBoot { + s.firstBoot = false + cmds = append(cmds, + loadLocalDirCmd(s.local.cwd), + connectRemoteCmd(s.host, s.previous.config.GetPath()), + ) + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.syncPaneSizes(msg.Width, msg.Height) + case tea.KeyPressMsg: + switch msg.Code { + case tea.KeyEsc: + 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 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 = msg.root + 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 { + 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() + } else { + s.status = msg.text + } + 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)) + } + } + + 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) - previousModel, ok := s.previous.(*Model) + 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) + } + return uploadFileCmd(s.sftpClient, item.path, pathpkg.Join(s.remote.cwd, filepath.Base(item.path))) + case remotePane: + item, ok := s.remote.list.SelectedItem().(fileItem) if !ok { - panic("failed to cast tea.Model to Model") + return nil + } + if item.isDir { + return loadRemoteDirCmd(s.sftpClient, item.path) + } + return downloadFileCmd(s.sftpClient, item.path, filepath.Join(s.local.cwd, pathpkg.Base(item.path))) + } + return nil +} + +func (s *sftpModel) toggleFocus() { + if s.activePane == localPane { + s.activePane = remotePane + return + } + s.activePane = localPane +} + +func (s *sftpModel) close() { + 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-6) + + s.local.list.SetSize(paneWidth, paneHeight) + s.remote.list.SetSize(paneWidth, paneHeight) +} + +func (s *sftpModel) View() string { + top := lg.NewStyle(). + Foreground(lg.Color("8")). + Render(fmt.Sprintf("sftp %s | tab switch | enter open/transfer | esc back", s.status)) + + localView := s.renderPane(s.local, s.activePane == localPane) + remoteView := s.renderPane(s.remote, s.activePane == remotePane) + separator := lg.NewStyle().Foreground(lg.Color("8")).Render("│") + + return lg.JoinVertical( + lg.Left, + top, + "", + lg.JoinHorizontal(lg.Top, localView, separator, remoteView), + ) +} + +func (s *sftpModel) renderPane(p filePane, focused bool) string { + headerStyle := lg.NewStyle(). + Bold(focused). + Foreground(lg.Color("8")) + if focused { + headerStyle = headerStyle.Foreground(lg.Color(s.previous.theme.selectedTitleColor)) + } + + header := headerStyle.Render(fmt.Sprintf("%s %s", p.title, p.cwd)) + underline := lg.NewStyle(). + Foreground(lg.Color("8")). + Render(strings.Repeat("─", max(1, p.list.Width()))) + if focused { + underline = lg.NewStyle(). + Foreground(lg.Color(s.previous.theme.selectedBorderColor)). + Render(strings.Repeat("─", max(1, p.list.Width()))) + } + + 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" + underline + "\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, } + } +} - var windowSize tea.WindowSizeMsg +func connectRemoteCmd(host sshconf.Host, configPath string) tea.Cmd { + return func() tea.Msg { + cmd, stderr, client, root, err := connectSFTP(host, configPath) + return sftpConnectMsg{ + cmd: cmd, + stderr: stderr, + client: client, + root: root, + err: err, + } + } +} - windowSize.Width = previousModel.vp.Width() - windowSize.Height = previousModel.vp.Height() +func uploadFileCmd(client *sftp.Client, localPath, remotePath string) tea.Cmd { + return func() tea.Msg { + src, err := os.Open(localPath) + if err != nil { + return sftpTransferMsg{err: err} + } + defer src.Close() - var uLocalFSCmd, uRemoteFSCmd tea.Cmd - s.localFSModel, _ = s.localFSModel.Update(windowSize) - s.remoteFSModel, _ = s.remoteFSModel.Update(windowSize) + dst, err := client.Create(remotePath) + if err != nil { + return sftpTransferMsg{err: err} + } + defer dst.Close() - cmds = append(cmds, uLocalFSCmd, uRemoteFSCmd, s.localFSModel.Init(), s.remoteFSModel.Init()) + if _, err := io.Copy(dst, src); err != nil { + return sftpTransferMsg{err: err} + } - // return s, tea.Batch(cmds...) + return sftpTransferMsg{ + text: fmt.Sprintf("uploaded %s", filepath.Base(localPath)), + refreshRemote: true, + } } +} - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - return s.previous, nil +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 src.Close() + + dst, err := os.Create(localPath) + if err != nil { + return sftpTransferMsg{err: err} + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return sftpTransferMsg{err: err} + } + + return sftpTransferMsg{ + text: fmt.Sprintf("downloaded %s", pathpkg.Base(remotePath)), + refreshLocal: true, } + } +} +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, + } } +} - s.localFSModel, localFSCmd = s.localFSModel.Update(msg) - s.remoteFSModel, remoteFSCmd = s.remoteFSModel.Update(msg) +func loadLocalDir(path string) ([]list.Item, error) { + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } - cmds = append(cmds, localFSCmd, remoteFSCmd) + 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", + }) + } - return s, tea.Batch(cmds...) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + items = append(items, fileItem{ + name: entry.Name(), + path: filepath.Join(path, entry.Name()), + isDir: entry.IsDir(), + size: info.Size(), + modTime: info.ModTime(), + kind: fileKind(entry.Name(), entry.IsDir()), + }) + } + return items, nil } -func (s *sftpModel) View() string { +func loadRemoteDir(client *sftp.Client, path string) ([]list.Item, error) { + entries, err := client.ReadDir(path) + if err != nil { + return nil, err + } - local, ok := s.localFSModel.(*localFileSystem) - remote, ok := s.remoteFSModel.(*remoteFileSystem) + 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", + }) + } - if !ok { - panic("failed to cast tea.Model to localFileSystem Model") + for _, entry := range entries { + items = append(items, fileItem{ + name: entry.Name(), + path: pathpkg.Join(path, entry.Name()), + isDir: entry.IsDir(), + size: entry.Size(), + modTime: entry.ModTime(), + kind: fileKind(entry.Name(), entry.IsDir()), + }) + } + return items, nil +} + +func connectSFTP(host sshconf.Host, configPath string) (*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) + } + + cmd := exec.Command(sshPath, "-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())) + } + + 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 = "." } - var view strings.Builder - view.WriteString("send receive files") + return cmd, stderr, client, root, nil +} - fs := lipgloss.JoinHorizontal(lipgloss.Top, local.View(), remote.View()) - view.WriteString("\n\n" + fs + "\n") +func fileKind(name string, isDir bool) string { + if isDir { + return "dir" + } + ext := strings.TrimPrefix(filepath.Ext(name), ".") + if ext == "" { + return "file" + } + return ext +} - return view.String() +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" +} + +func max(a, b int) int { + if a > b { + return a + } + return b } diff --git a/pkg/tui/sftp_local.go b/pkg/tui/sftp_local.go deleted file mode 100644 index 5a65254..0000000 --- a/pkg/tui/sftp_local.go +++ /dev/null @@ -1,69 +0,0 @@ -package tui - -import ( - "os" - - "github.com/charmbracelet/bubbles/v2/filepicker" - "github.com/charmbracelet/bubbles/v2/viewport" - tea "github.com/charmbracelet/bubbletea/v2" - // "github.com/charmbracelet/lipgloss/v2" -) - -type localFileSystem struct { - filePicker filepicker.Model - selectedFile string - viewport viewport.Model -} - -func NewLocalFileSystem() tea.Model { - - fp := filepicker.New() - fp.CurrentDirectory, _ = os.UserHomeDir() - - vp := viewport.New() - - return &localFileSystem{ - filePicker: fp, - viewport: vp, - } -} - -func (l *localFileSystem) Init() tea.Cmd { - return l.filePicker.Init() -} - -func (l *localFileSystem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "enter": - return l, nil - } - - case tea.WindowSizeMsg: - l.filePicker.SetHeight(msg.Height) - l.viewport.SetHeight(msg.Height) - l.viewport.SetWidth(msg.Width) - // l.viewport.Style = lipgloss.NewStyle().Background(lipgloss.Color("#28f3433")) - - } - - var cmd tea.Cmd - l.filePicker, cmd = l.filePicker.Update(msg) - - // Did the user select a file? - if didSelect, path := l.filePicker.DidSelectFile(msg); didSelect { - // Get the path of the selected file. - l.selectedFile = path - } - - return l, cmd -} - -func (l *localFileSystem) View() string { - - l.viewport.SetContent(l.filePicker.View()) - // return l.filePicker.View() - return l.viewport.View() -} diff --git a/pkg/tui/sftp_remote.go b/pkg/tui/sftp_remote.go deleted file mode 100644 index 5a706b8..0000000 --- a/pkg/tui/sftp_remote.go +++ /dev/null @@ -1,86 +0,0 @@ -package tui - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/v2/list" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/lfaoro/ssm/pkg/sshconf" - "github.com/pkg/sftp" -) - -type remoteFile struct { - Name string - IsDir bool -} - -func (f remoteFile) Title() string { - return f.Name -} - -func (f remoteFile) Description() string { - if f.IsDir { - return "directory" - } - return "file" -} - -func (f remoteFile) FilterValue() string { - return f.Name -} - -type remoteFileSystem struct { - list list.Model - client *sftp.Client - cwd string - host sshconf.Host - selected string - connected bool -} - -func NewRemoteFileSystem(host *sshconf.Host) tea.Model { - - return &remoteFileSystem{ - cwd: ".", - host: *host, - } -} - -func (l *remoteFileSystem) Init() tea.Cmd { - return nil -} - -func (l *remoteFileSystem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "enter": - return l, nil - } - - } - - return l, nil -} - -func (l *remoteFileSystem) View() string { - - return "" -} - -func loadDir(client *sftp.Client, path string) []list.Item { - entries, err := client.ReadDir(path) - if err != nil { - return []list.Item{remoteFile{Name: fmt.Sprintf("error: %v", err)}} - } - - items := []list.Item{} - if path != "/" { - items = append(items, remoteFile{Name: "..", IsDir: true}) // parent dir - } - for _, e := range entries { - items = append(items, remoteFile{Name: e.Name(), IsDir: e.IsDir()}) - } - return items -} From bb1890e2a135847572449f2935d5969dcdeffa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ailton=20Ba=C3=BAque?= Date: Sat, 16 May 2026 22:05:54 +0200 Subject: [PATCH 3/7] fix: set altScreen on sub-model View() for fullscreen rendering --- pkg/tui/runcmd.go | 4 +++- pkg/tui/sftp.go | 26 +++++++++++--------------- 2 files changed, 14 insertions(+), 16 deletions(-) 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 index f613c30..3ce9630 100644 --- a/pkg/tui/sftp.go +++ b/pkg/tui/sftp.go @@ -102,6 +102,7 @@ type sftpModel struct { status string } +// SftpModel wraps the base model in an SFTP browser sub-model. func SftpModel(base tea.Model) tea.Model { previous, ok := base.(*Model) if !ok { @@ -307,7 +308,9 @@ func (s *sftpModel) View() tea.View { s.renderPane(s.remote, s.activePane == remotePane), ), ) - return tea.NewView(v) + view := tea.NewView(v) + view.AltScreen = true + return view } func (s *sftpModel) renderPane(p filePane, focused bool) string { @@ -377,17 +380,17 @@ func connectRemoteCmd(host sshconf.Host, configPath string) tea.Cmd { func uploadFileCmd(client *sftp.Client, localPath, remotePath string) tea.Cmd { return func() tea.Msg { - src, err := os.Open(localPath) + src, err := os.Open(localPath) //nolint:gosec if err != nil { return sftpTransferMsg{err: err} } - defer src.Close() + defer func() { _ = src.Close() }() dst, err := client.Create(remotePath) if err != nil { return sftpTransferMsg{err: err} } - defer dst.Close() + defer func() { _ = dst.Close() }() if _, err := io.Copy(dst, src); err != nil { return sftpTransferMsg{err: err} @@ -406,13 +409,13 @@ func downloadFileCmd(client *sftp.Client, remotePath, localPath string) tea.Cmd if err != nil { return sftpTransferMsg{err: err} } - defer src.Close() + defer func() { _ = src.Close() }() - dst, err := os.Create(localPath) + dst, err := os.Create(localPath) //nolint:gosec if err != nil { return sftpTransferMsg{err: err} } - defer dst.Close() + defer func() { _ = dst.Close() }() if _, err := io.Copy(dst, src); err != nil { return sftpTransferMsg{err: err} @@ -506,7 +509,7 @@ func connectSFTP(host sshconf.Host, configPath string) (*exec.Cmd, *bytes.Buffer return nil, nil, nil, "", fmt.Errorf("ssh not found in PATH: %w", err) } - cmd := exec.Command(sshPath, "-F", configPath, host.Name, "-s", "sftp") + cmd := exec.Command(sshPath, "-F", configPath, host.Name, "-s", "sftp") //nolint:gosec stderr := &bytes.Buffer{} cmd.Stderr = stderr @@ -566,10 +569,3 @@ func humanSize(size int64) string { value := float64(size) / float64(div) return strconv.FormatFloat(value, 'f', 1, 64) + string("KMGTPE"[exp]) + "B" } - -func max(a, b int) int { - if a > b { - return a - } - return b -} From 363ea9f1ea0c4126b9e9a9bef56e3de055d5aef8 Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Sun, 17 May 2026 18:44:01 +0300 Subject: [PATCH 4/7] test(sftp): add comprehensive SFTP test suite Add 40+ tests covering: - fileItem Title/Description/FilterValue - fileKind with symlink detection - humanSize formatting - loadLocalDir with valid/invalid paths - sftpModel lifecycle (Init, Update, close) - pane focus toggle and switching - handleEnter for directories and files - confirm dialog acceptance/rejection - handleDelete for local and remote panes - history buffer capping at 50 entries - progressWriter Write method - transferMsgCmd message creation --- pkg/tui/sftp_test.go | 851 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 851 insertions(+) create mode 100644 pkg/tui/sftp_test.go 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 +} From 3cb014d0276ca9e0226b370fb7757c69ffaa4f9c Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Sun, 17 May 2026 18:44:11 +0300 Subject: [PATCH 5/7] feat(tui): add ctrl+s and ctrl+r to main help menu Add SFTP and run-command key bindings to the main host list help: - ctrl+s: open SFTP file browser - ctrl+r: open run-command sub-model Update TestInitKeys to expect 6 bindings instead of 4. --- pkg/tui/list.go | 10 ++++++++++ pkg/tui/list_test.go | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) 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, } From 06201d525388a6e2e859aceffd19ff4c3d4954b2 Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Sun, 17 May 2026 18:47:46 +0300 Subject: [PATCH 6/7] feat(sftp): add SFTP file browser with full TUI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core features: - Dual-pane file browser (local ↔ remote) using github.com/pkg/sftp - firstBoot pattern for submodel initialization (Init() not called on submodels) - connectSFTP with onStarted callback for in-flight SSH process tracking - close() kills connecting SSH process and closes client on Esc/q - Upload/download with file size display and transfer history buffer (50 entries) - Overwrite confirmation dialog for existing files - Delete operation with y/n confirmation on both panes - Symlink detection in directory listings (local and remote) - SSH options: StrictHostKeyChecking=no, BatchMode=yes, RequestTTY=no - Remote pane starts in /tmp instead of server home directory - Local dir error fallback sets empty items to prevent stale UI Design consistency: - Solid background bar headers matching main TUI list title style - Theme colors with lightDark() adaptive colors on delegate styles - Help bar with • bullet separators matching main TUI help - q/esc to exit, tab to switch pane, d to delete --- pkg/tui/sftp.go | 466 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 380 insertions(+), 86 deletions(-) diff --git a/pkg/tui/sftp.go b/pkg/tui/sftp.go index 3ce9630..c99cf7b 100644 --- a/pkg/tui/sftp.go +++ b/pkg/tui/sftp.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "charm.land/bubbles/v2/list" @@ -26,6 +27,23 @@ const ( 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 @@ -49,12 +67,13 @@ type sftpTransferMsg struct { } type fileItem struct { - name string - path string - isDir bool - size int64 - modTime time.Time - kind string + name string + path string + isDir bool + isSymlink bool + size int64 + modTime time.Time + kind string } func (f fileItem) Title() string { @@ -88,18 +107,22 @@ type filePane struct { } 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 + 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. @@ -121,26 +144,38 @@ func SftpModel(base tea.Model) tea.Model { host: host, firstBoot: true, activePane: localPane, - local: newFilePane("Local", startDir), - remote: newFilePane(host.Name, "."), + 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) filePane { +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(false) - li.SetShowFilter(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, @@ -160,16 +195,41 @@ func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.firstBoot = false cmds = append(cmds, loadLocalDirCmd(s.local.cwd), - connectRemoteCmd(s.host, s.previous.config.GetPath()), + 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: + case tea.KeyEsc, 'q': s.close() return s.previous, nil case tea.KeyTab: @@ -182,6 +242,16 @@ func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -191,11 +261,14 @@ func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.sshCmd = msg.cmd s.sshErr = msg.stderr s.sftpClient = msg.client - s.remote.cwd = msg.root + 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 } @@ -209,8 +282,13 @@ func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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)) @@ -220,14 +298,6 @@ func (s *sftpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - 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) - return s, tea.Batch(cmds...) } @@ -244,7 +314,17 @@ func (s *sftpModel) handleEnter() tea.Cmd { if s.sftpClient == nil { return transferMsgCmd("", fmt.Errorf("not connected to remote host"), false, false) } - return uploadFileCmd(s.sftpClient, item.path, pathpkg.Join(s.remote.cwd, filepath.Base(item.path))) + 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 { @@ -253,11 +333,97 @@ func (s *sftpModel) handleEnter() tea.Cmd { if item.isDir { return loadRemoteDirCmd(s.sftpClient, item.path) } - return downloadFileCmd(s.sftpClient, item.path, filepath.Join(s.local.cwd, pathpkg.Base(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 @@ -267,6 +433,14 @@ func (s *sftpModel) toggleFocus() { } 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 @@ -289,47 +463,97 @@ func (s *sftpModel) syncPaneSizes(width, height int) { s.height = height paneWidth := max(20, (width/2)-2) - paneHeight := max(10, height-6) + paneHeight := max(10, height-5) s.local.list.SetSize(paneWidth, paneHeight) s.remote.list.SetSize(paneWidth, paneHeight) } func (s *sftpModel) View() tea.View { - v := lg.JoinVertical( - lg.Left, - lg.NewStyle(). - Foreground(lg.Color("8")). - Render(fmt.Sprintf("sftp %s | tab switch | enter open/transfer | esc back", s.status)), - "", - lg.JoinHorizontal(lg.Top, - s.renderPane(s.local, s.activePane == localPane), - lg.NewStyle().Foreground(lg.Color("8")).Render("│"), - s.renderPane(s.remote, s.activePane == remotePane), - ), + 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), ) - view := tea.NewView(v) + + 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) renderPane(p filePane, focused bool) string { +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(focused). - Foreground(lg.Color("8")) + Bold(true). + Background(lg.Color(th.mainTitleColor)). + Foreground(lg.Color("230")). + Padding(0, 1). + Width(p.list.Width()) if focused { - headerStyle = headerStyle.Foreground(lg.Color(s.previous.theme.selectedTitleColor)) + headerStyle = headerStyle.Background(lg.Color(th.selectedTitleColor)) } - header := headerStyle.Render(fmt.Sprintf("%s %s", p.title, p.cwd)) - underline := lg.NewStyle(). - Foreground(lg.Color("8")). - Render(strings.Repeat("─", max(1, p.list.Width()))) - if focused { - underline = lg.NewStyle(). - Foreground(lg.Color(s.previous.theme.selectedBorderColor)). - Render(strings.Repeat("─", max(1, p.list.Width()))) + 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). @@ -338,7 +562,7 @@ func (s *sftpModel) renderPane(p filePane, focused bool) string { return lg.NewStyle(). Width(p.list.Width() + 1). - Render(header + "\n" + underline + "\n" + body) + Render(header + "\n" + body) } func loadLocalDirCmd(path string) tea.Cmd { @@ -365,9 +589,9 @@ func loadRemoteDirCmd(client *sftp.Client, path string) tea.Cmd { } } -func connectRemoteCmd(host sshconf.Host, configPath string) tea.Cmd { +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) + cmd, stderr, client, root, err := connectSFTP(host, configPath, onStarted) return sftpConnectMsg{ cmd: cmd, stderr: stderr, @@ -380,11 +604,17 @@ func connectRemoteCmd(host sshconf.Host, configPath string) tea.Cmd { func uploadFileCmd(client *sftp.Client, localPath, remotePath string) tea.Cmd { return func() tea.Msg { - src, err := os.Open(localPath) //nolint:gosec + srcFile, err := os.Open(localPath) //nolint:gosec if err != nil { return sftpTransferMsg{err: err} } - defer func() { _ = src.Close() }() + 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 { @@ -392,12 +622,19 @@ func uploadFileCmd(client *sftp.Client, localPath, remotePath string) tea.Cmd { } defer func() { _ = dst.Close() }() - if _, err := io.Copy(dst, src); err != nil { + 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", filepath.Base(localPath)), + text: fmt.Sprintf("uploaded %s (%s)", name, humanSize(total)), refreshRemote: true, } } @@ -411,23 +648,49 @@ func downloadFileCmd(client *sftp.Client, remotePath, localPath string) tea.Cmd } 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() }() - if _, err := io.Copy(dst, src); err != nil { + 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", pathpkg.Base(remotePath)), + 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{ @@ -439,6 +702,18 @@ func transferMsgCmd(text string, err error, refreshLocal, refreshRemote bool) te } } +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 { @@ -461,13 +736,15 @@ func loadLocalDir(path string) ([]list.Item, error) { 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(), - size: info.Size(), - modTime: info.ModTime(), - kind: fileKind(entry.Name(), entry.IsDir()), + 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 @@ -491,25 +768,35 @@ func loadRemoteDir(client *sftp.Client, path string) ([]list.Item, error) { } 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(), - size: entry.Size(), - modTime: entry.ModTime(), - kind: fileKind(entry.Name(), entry.IsDir()), + 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) (*exec.Cmd, *bytes.Buffer, *sftp.Client, string, error) { +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) } - cmd := exec.Command(sshPath, "-F", configPath, host.Name, "-s", "sftp") //nolint:gosec + //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 @@ -526,6 +813,10 @@ func connectSFTP(host sshconf.Host, configPath string) (*exec.Cmd, *bytes.Buffer 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() @@ -539,13 +830,16 @@ func connectSFTP(host sshconf.Host, configPath string) (*exec.Cmd, *bytes.Buffer root, err := client.Getwd() if err != nil || root == "" { - root = "." + root = "/tmp" } return cmd, stderr, client, root, nil } -func fileKind(name string, isDir bool) string { +func fileKind(name string, isDir, isSymlink bool) string { + if isSymlink { + return "link" + } if isDir { return "dir" } From 993f7ff66a392a9089702f14582e9d1682c5e73c Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Sun, 17 May 2026 18:53:49 +0300 Subject: [PATCH 7/7] docs: update changelog, readme, and agents for SFTP feature CHANGELOG: - Add SFTP file browser entry under [Unreleased] with all sub-features - Update test count from 92 to 132+ tests README: - Add ctrl+s key binding to keys table AGENTS: - Add SFTP sub-model to pkg/tui/ description - Add sftp_test.go to test file list - Update test count to 132+ across 6 files - Add SFTP constraint (github.com/pkg/sftp, BatchMode) - Add SFTP security notes (BatchMode, RequestTTY=no, sync.Mutex) --- AGENTS.md | 8 ++++++-- CHANGELOG.md | 10 +++++++++- README.md | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) 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 |