diff --git a/README.md b/README.md index 1766dae..c6406f5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ --- > [!WARNING] -> **Pre-1.0 — active development.** Postgres backup/restore/sync/verify/inspect work end-to-end today (Phase B). MySQL/MariaDB, the interactive dashboard, incremental backups, and ops features are on the [roadmap](#roadmap). APIs, flags, and the on-disk dump format may change before 1.0. Track progress via the milestone tags (`phase-a`, `phase-b`, …). +> **Pre-1.0 — active development.** Postgres backup/restore/sync/verify/inspect work end-to-end today (Phase B), and bare `siphon` opens an interactive multi-panel dashboard (Phase C). MySQL/MariaDB, incremental backups, and ops features are on the [roadmap](#roadmap). APIs, flags, and the on-disk dump format may change before 1.0. Track progress via the milestone tags (`phase-a`, `phase-b`, `phase-c`, …). ## Table of contents @@ -41,7 +41,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ - **One CLI, many databases.** Postgres works today; MySQL and MariaDB land in v1.0 (sharing a common backend). The driver interface is engine-agnostic, so SQLite, MongoDB, SQL Server, and ClickHouse can follow. - **Native, not reimplemented.** siphon shells out to `pg_dump`/`pg_restore` (and `mysqldump`/`mariadb-dump` in v1.0) for the actual data movement — you inherit 20+ years of correctness from the official tools, wrapped in a consistent UX. - **Integrity by default.** Every dump is checksummed (SHA-256) and recorded in a sidecar metadata file. `siphon verify` re-hashes the dump and flags corruption or tampering — and fails with a distinct exit code so CI can catch it. -- **Built for scripts and humans.** A Cobra command tree with predictable flags and POSIX exit codes for automation; a Bubble Tea dashboard (Phase C) when you invoke `siphon` bare. +- **Built for scripts and humans.** A Cobra command tree with predictable flags and POSIX exit codes for automation; an interactive Bubble Tea dashboard when you invoke `siphon` bare. - **Named profiles + secret refs.** Store connection details once; reference secrets as `env:VAR` today, with OS keychain / Vault / 1Password / AWS Secrets Manager backends on the roadmap. Plaintext passwords never have to live in your config. - **Streaming sync.** `siphon sync src dst` pipes a backup straight into a restore with no intermediate file on disk. @@ -51,7 +51,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | **A** — Skeleton | Go module, Cobra CLI, TUI placeholder, `Driver` interface + registry, `errs`/`config`/`secrets`/`profile` packages, golangci-lint + depguard, cross-platform CI | ✅ Complete | | **B** — Postgres walking skeleton | `backup`, `restore`, `sync`, `verify`, `inspect`, `dumps`, `config`, `profile` working end-to-end against PostgreSQL | ✅ Complete | -| **C** — TUI dashboard | Multi-panel Bubble Tea dashboard with live job progress | ⏳ Planned | +| **C** — TUI dashboard | Multi-panel Bubble Tea dashboard (profiles · dumps · jobs) with live job progress, backup/restore modal forms, and snapshot tests | ✅ Complete | | **D** — Driver hardening | Shared cross-driver test harness, capability gating, retry policy | ⏳ Planned | | **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package | ⏳ Planned | | **F** — Advanced transfer | Incremental backups, bounded-buffer streaming, cross-engine sync, CDC | ⏳ Planned | @@ -133,7 +133,7 @@ Exit codes follow a POSIX-friendly taxonomy (`0` ok, `1` user error, `2` system | `siphon config path\|edit` | Show or edit the config file | | `siphon schedule` | Cron-managed recurring backups _(Phase G)_ | | `siphon tunnel` | SSH tunnel helper _(Phase G)_ | -| `siphon` _(bare)_ | Launch the interactive dashboard _(real dashboard in Phase C)_ | +| `siphon` _(bare)_ | Launch the interactive multi-panel dashboard | Run `siphon --help` for full flags. diff --git a/go.mod b/go.mod index 6b01094..ecdc759 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,13 @@ module github.com/nixrajput/siphon go 1.26.3 require ( + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20260527151214-009e6338d40d github.com/jackc/pgx/v5 v5.9.2 + github.com/muesli/termenv v0.16.0 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/cobra v1.10.2 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 @@ -16,13 +20,22 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -32,6 +45,7 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -44,12 +58,13 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect @@ -61,13 +76,13 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/go.sum b/go.sum index aaa4f9c..0f6ea90 100644 --- a/go.sum +++ b/go.sum @@ -4,26 +4,60 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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 v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260527151214-009e6338d40d h1:H0qnIazEU9pe39RZPpQrXFyUJ8ks2TLTiDkGDxYxPFQ= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260527151214-009e6338d40d/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -46,6 +80,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +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.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -80,10 +116,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -92,10 +130,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -131,12 +171,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/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/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/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -181,8 +222,8 @@ go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJc go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/cli/root.go b/internal/cli/root.go index a366031..9693e0d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -35,7 +35,11 @@ func NewRoot(out, err io.Writer) *cobra.Command { SilenceErrors: true, RunE: func(c *cobra.Command, args []string) error { if len(args) == 0 { - return tui.Run() + deps, depErr := buildDeps() + if depErr != nil { + return depErr + } + return tui.Run(deps) } return c.Help() }, diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 8ed5363..a904889 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -1,48 +1,304 @@ -// Package tui hosts siphon's Bubble Tea application. Phase A ships a -// minimal placeholder; the real multi-panel dashboard lands in Phase C. +// Package tui hosts siphon's Bubble Tea application: the multi-panel +// dashboard, its child panels, and the modal forms layered over them. package tui import ( + "context" + "errors" + + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" + + "github.com/nixrajput/siphon/internal/app" + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/tui/modals" + "github.com/nixrajput/siphon/internal/tui/panels" + "github.com/nixrajput/siphon/internal/tui/styles" ) -type Model struct { - quitting bool +// formKind discriminates which app verb an active form will dispatch. +type formKind int + +const ( + formNone formKind = iota + formBackup + formRestore +) + +// Dashboard is the root TUI model. It owns the child panels, focus state, +// key routing, and the active modal form / error overlay. +// +// Panels are held by pointer so that focus mutations made through d.order +// (which holds the same pointers) are visible to the field accessed in +// View(). Copying the Dashboard value copies the pointers, not the panel +// structs, so order and the named fields never diverge (FIX 2, approach a). +type Dashboard struct { + deps app.Deps + keys KeyMap + width int + height int + + profiles *panels.Profiles + dumps *panels.Dumps + jobs *panels.Jobs + + order []Panel + focusIdx int + + // Active modal form, if any. + form *huh.Form + formKind formKind + backupRes *modals.BackupResult + restoreRes *modals.RestoreResult + + // Active error overlay, if any. + errModal *modals.ErrorModel } -// New returns a fresh, ready-to-run TUI model. -func New() Model { return Model{} } +// NewDashboard constructs the root model with the three panels wired up and +// focus on the profiles panel. +func NewDashboard(d app.Deps) Dashboard { + prof := panels.NewProfiles(d) + dmp := panels.NewDumps(d) + jbs := panels.NewJobs() + pp, dp, jp := &prof, &dmp, &jbs + pp.SetFocus(true) + return Dashboard{ + deps: d, + keys: DefaultKeys(), + profiles: pp, + dumps: dp, + jobs: jp, + order: []Panel{pp, dp, jp}, + } +} -func (m Model) Init() tea.Cmd { return nil } +func (d Dashboard) Init() tea.Cmd { + return tea.Batch(d.profiles.Init(), d.dumps.Init(), d.jobs.Init()) +} -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if k, ok := msg.(tea.KeyMsg); ok { - switch k.String() { - case "q", "esc", "ctrl+c": - m.quitting = true - return m, tea.Quit +func (d Dashboard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // 1. Error overlay swallows input until dismissed. + if d.errModal != nil { + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "esc", "enter", "q": + d.errModal = nil + } } + return d, nil } - return m, nil + + // 2. An active form receives all input until it completes or aborts. + if d.form != nil { + return d.updateForm(msg) + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + d.width, d.height = msg.Width, msg.Height + d.layout() + return d, nil + case tea.KeyMsg: + switch { + case key.Matches(msg, d.keys.Quit): + return d, tea.Quit + case key.Matches(msg, d.keys.NextPanel): + d.advanceFocus(+1) + return d, nil + case key.Matches(msg, d.keys.PrevPanel): + d.advanceFocus(-1) + return d, nil + case key.Matches(msg, d.keys.Backup): + return d.openBackup() + case key.Matches(msg, d.keys.Restore): + return d.openRestore() + } + case JobChannelMsg: + return d, panels.SubscribeCmd(msg.Channel) + case panels.JobEventInternal: + updated, cmd := d.jobs.Update(msg) + *d.jobs = updated.(panels.Jobs) + // React to the runner's terminal event (sent as a normal event before + // the channel closes): surface failures and refresh the catalog. + switch msg.Event.Phase { + case jobs.PhaseError, jobs.PhaseCancelled: + err := msg.Event.Err + if err == nil { + if msg.Event.Message != "" { + err = errors.New(msg.Event.Message) + } else { + err = errors.New("job failed") + } + } + hint := "" + var se *errs.Error + if errors.As(err, &se) { + hint = se.Hint + } + em := modals.NewError(err, hint) + d.errModal = &em + case jobs.PhaseDone: + d.dumps.Reload() + } + return d, cmd + case ErrorMsg: + em := modals.NewError(msg.Err, msg.Hint) + d.errModal = &em + return d, nil + } + + // Route remaining messages to the focused panel. + if d.focusIdx >= 0 && d.focusIdx < len(d.order) { + updated, cmd := d.order[d.focusIdx].Update(msg) + d.applyPanelUpdate(updated) + return d, cmd + } + return d, nil } -var ( - title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7dd3fc")) - subtitle = lipgloss.NewStyle().Faint(true) -) +func (d Dashboard) View() string { + if d.errModal != nil { + return d.errModal.View() + } + if d.form != nil { + hint := styles.StatusBar.Width(d.width).Render(d.formHint()) + return d.form.View() + "\n" + hint + } + body := lipgloss.JoinHorizontal(lipgloss.Top, + d.profiles.View(), d.dumps.View(), d.jobs.View()) + status := styles.StatusBar.Width(d.width). + Render("b backup · r restore · tab focus · q quit") + return body + "\n" + status +} -func (m Model) View() string { - if m.quitting { - return "" +// formHint returns the command-row hint text for the currently active form. +func (d Dashboard) formHint() string { + switch d.formKind { + case formBackup: + return "↑/↓ select · enter confirm · esc cancel" + case formRestore: + return "↑/↓ select · / filter · enter next · esc cancel" + default: + return "enter next · esc cancel" } - return title.Render("siphon") + "\n" + - subtitle.Render("sync any database, anywhere") + "\n\n" + - "press q to quit.\n" } -// Run starts the TUI program and blocks until it exits. -func Run() error { - _, err := tea.NewProgram(New()).Run() - return err +// updateForm routes a message to the active form and, on completion, fires +// the corresponding app verb. On abort it tears the form down. +func (d Dashboard) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { + // Esc cancels the form without dispatching. + if k, ok := msg.(tea.KeyMsg); ok && k.Type == tea.KeyEsc { + d.clearForm() + return d, nil + } + model, cmd := d.form.Update(msg) + if f, ok := model.(*huh.Form); ok { + d.form = f + } + switch d.form.State { + case huh.StateCompleted: + dispatch := d.dispatchForm() + d.clearForm() + return d, tea.Batch(cmd, dispatch) + case huh.StateAborted: + d.clearForm() + return d, cmd + } + return d, cmd +} + +// dispatchForm builds the command that fires the verb for the just-completed +// form. Returns nil when there is nothing to dispatch. +func (d Dashboard) dispatchForm() tea.Cmd { + switch d.formKind { + case formBackup: + if d.backupRes == nil || d.backupRes.Profile == "" { + return nil + } + opt := app.BackupOpts{Profile: d.backupRes.Profile} + return func() tea.Msg { + ch, id, err := app.Backup(context.Background(), d.deps, opt) + if err != nil { + return ErrorMsg{Err: err, Hint: "check that the profile + secrets resolve"} + } + return JobChannelMsg{Channel: ch, JobID: id} + } + case formRestore: + if d.restoreRes == nil || d.restoreRes.Profile == "" || d.restoreRes.DumpID == "" { + return nil + } + opt := app.RestoreOpts{ + Profile: d.restoreRes.Profile, + DumpID: d.restoreRes.DumpID, + Clean: d.restoreRes.Clean, + } + return func() tea.Msg { + ch, id, err := app.Restore(context.Background(), d.deps, opt) + if err != nil { + return ErrorMsg{Err: err, Hint: "check that the dump + target profile resolve"} + } + return JobChannelMsg{Channel: ch, JobID: id} + } + } + return nil +} + +func (d *Dashboard) clearForm() { + d.form = nil + d.formKind = formNone + d.backupRes = nil + d.restoreRes = nil +} + +// openBackup builds and activates the backup form. +func (d Dashboard) openBackup() (tea.Model, tea.Cmd) { + form, res := modals.NewBackup(d.deps, d.profiles.SelectedName()) + d.form = form + d.formKind = formBackup + d.backupRes = res + return d, d.form.Init() +} + +// openRestore builds and activates the restore form, defaulting the dump ID +// to the dumps panel's current selection. +func (d Dashboard) openRestore() (tea.Model, tea.Cmd) { + form, res := modals.NewRestore(d.deps, d.profiles.SelectedName(), d.dumps.SelectedID()) + d.form = form + d.formKind = formRestore + d.restoreRes = res + return d, d.form.Init() +} + +func (d *Dashboard) layout() { + if d.width == 0 || d.height == 0 { + return + } + colW := (d.width - 6) / 3 + rowH := d.height - 4 + d.profiles.SetSize(colW, rowH) + d.dumps.SetSize(colW, rowH) + d.jobs.SetSize(colW, rowH) +} + +func (d *Dashboard) advanceFocus(delta int) { + d.order[d.focusIdx].SetFocus(false) + d.focusIdx = (d.focusIdx + delta + len(d.order)) % len(d.order) + d.order[d.focusIdx].SetFocus(true) +} + +// applyPanelUpdate writes a panel's post-Update value back through the stored +// pointer so the change survives the Dashboard value copy. +func (d *Dashboard) applyPanelUpdate(updated tea.Model) { + switch p := updated.(type) { + case panels.Profiles: + *d.profiles = p + case panels.Dumps: + *d.dumps = p + case panels.Jobs: + *d.jobs = p + } } diff --git a/internal/tui/dashboard_snapshot_test.go b/internal/tui/dashboard_snapshot_test.go new file mode 100644 index 0000000..ed20576 --- /dev/null +++ b/internal/tui/dashboard_snapshot_test.go @@ -0,0 +1,73 @@ +package tui + +import ( + "io" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" +) + +// TestDashboard_Initial_Snapshot captures the initial rendered dashboard. +func TestDashboard_Initial_Snapshot(t *testing.T) { + d := NewDashboard(testDeps(t)) + tm := teatest.NewTestModel(t, d, teatest.WithInitialTermSize(120, 30)) + tm.Send(tea.WindowSizeMsg{Width: 120, Height: 30}) + + // Wait until output is non-empty. + teatest.WaitFor(t, tm.Output(), func(b []byte) bool { + return len(b) > 0 + }, teatest.WithCheckInterval(20*time.Millisecond), teatest.WithDuration(2*time.Second)) + + // Quit so the program finishes and FinalOutput is readable. + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) + + out, err := io.ReadAll(tm.FinalOutput(t, teatest.WithFinalTimeout(2*time.Second))) + if err != nil { + t.Fatal(err) + } + teatest.RequireEqualOutput(t, out) +} + +// TestDashboard_FocusDumps_Snapshot captures the dashboard after one Tab press. +// +// Note: the golden is byte-identical to the initial snapshot because teatest +// renders off-TTY in ASCII mode, where the only focus difference (border color) +// is stripped. The snapshot therefore serves as a layout/content regression +// guard; the focus *movement* is asserted explicitly below via the final model +// (and verified visually with forced color in TestDashboard_Tab_MovesFocus). +// (focus moved to the Dumps panel). +func TestDashboard_FocusDumps_Snapshot(t *testing.T) { + d := NewDashboard(testDeps(t)) + tm := teatest.NewTestModel(t, d, teatest.WithInitialTermSize(120, 30)) + tm.Send(tea.WindowSizeMsg{Width: 120, Height: 30}) + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + + // Wait until output is non-empty. + teatest.WaitFor(t, tm.Output(), func(b []byte) bool { + return len(b) > 0 + }, teatest.WithCheckInterval(20*time.Millisecond), teatest.WithDuration(2*time.Second)) + + // Quit so the program finishes and FinalOutput is readable. + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) + + // Deterministically assert the Tab actually moved focus to the Dumps panel + // (index 1 in order profiles=0, dumps=1, jobs=2). The golden bytes can't + // prove this off-TTY, so check the final model directly. + final, ok := tm.FinalModel(t).(Dashboard) + if !ok { + t.Fatalf("FinalModel is %T; want tui.Dashboard", tm.FinalModel(t)) + } + if final.focusIdx != 1 { + t.Fatalf("after Tab, focusIdx = %d; want 1 (Dumps focused)", final.focusIdx) + } + + out, err := io.ReadAll(tm.FinalOutput(t, teatest.WithFinalTimeout(2*time.Second))) + if err != nil { + t.Fatal(err) + } + teatest.RequireEqualOutput(t, out) +} diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 789115b..b25f9aa 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -1,41 +1,209 @@ package tui import ( + "errors" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/nixrajput/siphon/internal/app" + "github.com/nixrajput/siphon/internal/config" + "github.com/nixrajput/siphon/internal/dumps" + "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/profile" + "github.com/nixrajput/siphon/internal/secrets" + "github.com/nixrajput/siphon/internal/tui/modals" + "github.com/nixrajput/siphon/internal/tui/panels" ) -func TestModel_View_ContainsTitleAndQuitHint(t *testing.T) { - m := New() - view := m.View() - if !strings.Contains(view, "siphon") { - t.Fatalf("View() = %q; want it to contain 'siphon'", view) +func testDeps(t *testing.T) app.Deps { + t.Helper() + cfg := &config.Config{} + res := secrets.NewResolver(secrets.Passthrough{}) + ps := profile.New(cfg, res, func(*config.Config) error { return nil }) + cat, err := dumps.NewCatalog(t.TempDir()) + if err != nil { + t.Fatalf("NewCatalog: %v", err) } - if !strings.Contains(view, "quit") { - t.Fatalf("View() = %q; want it to contain quit hint", view) + return app.Deps{Profiles: ps, Dumps: cat, Drivers: app.DefaultDrivers()} +} + +func TestDashboard_View_ContainsPanelsAndQuitHint(t *testing.T) { + d := NewDashboard(testDeps(t)) + out, _ := d.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + view := out.(Dashboard).View() + for _, want := range []string{"Profiles", "Dumps", "Jobs", "quit"} { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q; got:\n%s", want, view) + } } } -func TestModel_Update_QQuits(t *testing.T) { - m := New() - out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) +func TestDashboard_Update_QQuits(t *testing.T) { + d := NewDashboard(testDeps(t)) + _, cmd := d.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) if cmd == nil { t.Fatal("Update on 'q' returned no command; expected tea.Quit") } - if updated, ok := out.(Model); !ok || !updated.quitting { - t.Fatalf("Update did not flag quitting; got model=%+v ok=%v", out, ok) +} + +// TestDashboard_Tab_MovesFocus verifies FIX 2: pressing tab advances focus +// through the panels and the change is visible in the rendered frame. +func TestDashboard_Tab_MovesFocus(t *testing.T) { + // Force color rendering so the focus-border color difference is visible + // in the rendered frame even off a TTY. + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(termenv.Ascii) }) + + d := NewDashboard(testDeps(t)) + before := d.View() + + out, _ := d.Update(tea.KeyMsg{Type: tea.KeyTab}) + moved := out.(Dashboard) + if moved.focusIdx != 1 { + t.Fatalf("after tab, focusIdx = %d; want 1", moved.focusIdx) + } + if moved.View() == before { + t.Fatal("View did not change after tab; focus border did not move") + } + + // Wrap-around: from last panel, tab returns to the first. + out, _ = moved.Update(tea.KeyMsg{Type: tea.KeyTab}) + out, _ = out.(Dashboard).Update(tea.KeyMsg{Type: tea.KeyTab}) + if got := out.(Dashboard).focusIdx; got != 0 { + t.Fatalf("after wrap, focusIdx = %d; want 0", got) + } +} + +// TestDashboard_JobError_PopsErrorModal verifies FIX A: a terminal PhaseError +// job event pops the error overlay with the event's error and the hint +// extracted from a wrapped *errs.Error. +func TestDashboard_JobError_PopsErrorModal(t *testing.T) { + ch := make(chan jobs.Event) + wrapped := &errs.Error{Op: "backup", Code: errs.CodeUser, Cause: errors.New("boom"), Hint: "fix the profile"} + evt := panels.JobEventInternal{ + Event: jobs.Event{JobID: "j1", Stage: "dump", Phase: jobs.PhaseError, Err: wrapped}, + Ch: ch, + } + + d := NewDashboard(testDeps(t)) + out, _ := d.Update(evt) + got := out.(Dashboard) + if got.errModal == nil { + t.Fatal("PhaseError event did not pop the error modal") + } + view := got.errModal.View() + if !strings.Contains(view, "fix the profile") { + t.Fatalf("error modal missing hint from wrapped *errs.Error; got:\n%s", view) } } -func TestModel_Update_OtherKey_Noop(t *testing.T) { - m := New() - out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) - if cmd != nil { - t.Fatalf("Update on 'x' returned a command; expected nil") +// TestProfiles_FilteringDisabled verifies FIX B: the profiles list has +// filtering disabled, so '/' cannot open a filter that 'q' quits the app from. +func TestProfiles_FilteringDisabled(t *testing.T) { + p := panels.NewProfiles(testDeps(t)) + if p.FilteringEnabled() { + t.Fatal("profiles list filtering is enabled; expected it disabled (FIX B)") } - if updated, ok := out.(Model); !ok || updated.quitting { - t.Fatalf("Update flagged quitting on 'x'; should not") +} + +// TestNewRestore_AdaptiveDumpIDField verifies that NewRestore builds without +// panic for both an empty catalog and a seeded catalog. +func TestNewRestore_AdaptiveDumpIDField(t *testing.T) { + t.Run("empty catalog builds form", func(t *testing.T) { + d := testDeps(t) + form, res := modals.NewRestore(d, "", "") + if form == nil { + t.Fatal("NewRestore returned nil form for empty catalog") + } + if res == nil { + t.Fatal("NewRestore returned nil result for empty catalog") + } + }) + + t.Run("seeded catalog builds form", func(t *testing.T) { + d := testDeps(t) + meta := &dumps.Meta{ + ID: "01JABCDEFGHJKMNPQRSTVWXYZ0", + Profile: "default", + Driver: "postgres", + Created: time.Now(), + } + if err := d.Dumps.WriteMeta(meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + form, res := modals.NewRestore(d, "", "") + if form == nil { + t.Fatal("NewRestore returned nil form for seeded catalog") + } + if res == nil { + t.Fatal("NewRestore returned nil result for seeded catalog") + } + }) +} + +// TestDashboard_FormHint verifies that View() renders the correct +// formKind-aware command-row hint when a form is active. +func TestDashboard_FormHint(t *testing.T) { + t.Run("backup form shows select hint", func(t *testing.T) { + d := NewDashboard(testDeps(t)) + out, _ := d.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + dash := out.(Dashboard) + opened, _ := dash.openBackup() + view := opened.(Dashboard).View() + for _, want := range []string{"↑/↓ select", "enter confirm", "esc cancel"} { + if !strings.Contains(view, want) { + t.Fatalf("backup form View() missing %q; got:\n%s", want, view) + } + } + }) + + t.Run("restore form shows filter hint", func(t *testing.T) { + d := NewDashboard(testDeps(t)) + out, _ := d.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + dash := out.(Dashboard) + opened, _ := dash.openRestore() + view := opened.(Dashboard).View() + for _, want := range []string{"↑/↓ select", "filter", "enter next", "esc cancel"} { + if !strings.Contains(view, want) { + t.Fatalf("restore form View() missing %q; got:\n%s", want, view) + } + } + }) +} + +// TestDashboard_EscCancelsForm verifies that pressing Escape while a backup or +// restore form is active dismisses the form without dispatching any command. +func TestDashboard_EscCancelsForm(t *testing.T) { + for _, name := range []string{"backup", "restore"} { + t.Run(name, func(t *testing.T) { + d := NewDashboard(testDeps(t)) + + // Open the relevant form. + var out tea.Model + if name == "backup" { + out, _ = d.openBackup() + } else { + out, _ = d.openRestore() + } + before := out.(Dashboard) + if before.form == nil { + t.Fatalf("form not set after open%s", name) + } + + // Send Escape. + after, cmd := before.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if after.(Dashboard).form != nil { + t.Fatal("form still set after Esc; expected it to be cleared (cancel)") + } + if cmd != nil { + t.Fatal("Esc cancel returned a non-nil command; expected nil") + } + }) } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..0faf57f --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,33 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines every key in the dashboard. Keep this exhaustive and +// editable — the help modal renders directly from this struct. +type KeyMap struct { + Quit key.Binding + NextPanel key.Binding + PrevPanel key.Binding + Backup key.Binding + Restore key.Binding + Sync key.Binding + NewProfile key.Binding + Delete key.Binding + Help key.Binding + Refresh key.Binding +} + +func DefaultKeys() KeyMap { + return KeyMap{ + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + NextPanel: key.NewBinding(key.WithKeys("tab", "l", "right"), key.WithHelp("tab/l/→", "next panel")), + PrevPanel: key.NewBinding(key.WithKeys("shift+tab", "h", "left"), key.WithHelp("shift-tab/h/←", "prev panel")), + Backup: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "backup")), + Restore: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "restore")), + Sync: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sync")), + NewProfile: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new profile")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Refresh: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "refresh")), + } +} diff --git a/internal/tui/modals/backup.go b/internal/tui/modals/backup.go new file mode 100644 index 0000000..294084a --- /dev/null +++ b/internal/tui/modals/backup.go @@ -0,0 +1,41 @@ +// Package modals holds short-lived Bubble Tea forms presented over the +// dashboard. Each form binds its inputs into a result struct that the +// dashboard reads once the form reaches huh.StateCompleted. +package modals + +import ( + "github.com/charmbracelet/huh" + + "github.com/nixrajput/siphon/internal/app" +) + +// BackupResult holds the values bound by the backup form. The dashboard +// reads it after the form completes. +type BackupResult struct { + Profile string +} + +// NewBackup builds a Huh form that lets the user pick a backup profile. The +// returned form binds its selection into the returned *BackupResult, which +// the dashboard reads once the form reaches huh.StateCompleted. +func NewBackup(d app.Deps, defaultProfile string) (*huh.Form, *BackupResult) { + res := &BackupResult{Profile: defaultProfile} + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Profile"). + Options(profileOptions(d)...). + Value(&res.Profile), + ), + ).WithShowHelp(false) + return form, res +} + +func profileOptions(d app.Deps) []huh.Option[string] { + names := d.Profiles.List() + out := make([]huh.Option[string], 0, len(names)) + for _, n := range names { + out = append(out, huh.NewOption(n, n)) + } + return out +} diff --git a/internal/tui/modals/errormodal.go b/internal/tui/modals/errormodal.go new file mode 100644 index 0000000..eedfa40 --- /dev/null +++ b/internal/tui/modals/errormodal.go @@ -0,0 +1,53 @@ +package modals + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/nixrajput/siphon/internal/tui/styles" +) + +// ErrorModel is a Bubble Tea model that displays an error and a hint in a +// bordered box. It is rendered as an overlay by the dashboard, which owns its +// lifecycle: the dashboard intercepts esc/enter/q to dismiss the overlay, so +// this model never drives dismissal or quitting itself. Update only absorbs a +// window-size hint for layout; it is otherwise inert. +type ErrorModel struct { + err error + hint string + width int +} + +// NewError constructs an ErrorModel for the given error and hint text. +func NewError(err error, hint string) ErrorModel { return ErrorModel{err: err, hint: hint} } + +func (m ErrorModel) Init() tea.Cmd { return nil } + +func (m ErrorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + } + return m, nil +} + +func (m ErrorModel) View() string { + // Guard against a nil error (NewError is exported, so a caller could pass + // one) and clamp the box width so a zero/small terminal width never yields + // a negative or absurdly narrow box. + msg := "unknown error" + if m.err != nil { + msg = m.err.Error() + } + available := m.width - 4 + if available < 20 { + available = 20 + } + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#fb7185")). + Padding(1, 2). + Width(min(80, available)) + return box.Render(fmt.Sprintf("%s\n\n%s", styles.Err.Render("✗ "+msg), m.hint)) +} diff --git a/internal/tui/modals/errormodal_test.go b/internal/tui/modals/errormodal_test.go new file mode 100644 index 0000000..5525737 --- /dev/null +++ b/internal/tui/modals/errormodal_test.go @@ -0,0 +1,31 @@ +package modals + +import ( + "errors" + "strings" + "testing" +) + +// TestErrorModel_View_NilError verifies View doesn't panic and shows a fallback +// when constructed with a nil error (NewError is exported, so this is reachable). +func TestErrorModel_View_NilError(t *testing.T) { + m := NewError(nil, "some hint") + out := m.View() // must not panic + if !strings.Contains(out, "unknown error") { + t.Fatalf("View() with nil err should show fallback; got:\n%s", out) + } + if !strings.Contains(out, "some hint") { + t.Fatalf("View() should still render the hint; got:\n%s", out) + } +} + +// TestErrorModel_View_ZeroWidth verifies View renders without panic at width 0 +// (the dashboard does not forward a WindowSizeMsg to the overlay, so width is +// typically 0) — the clamp must keep the box width sane. +func TestErrorModel_View_ZeroWidth(t *testing.T) { + m := NewError(errors.New("boom"), "") + out := m.View() // width is 0; clamp must avoid a negative Width + if !strings.Contains(out, "boom") { + t.Fatalf("View() should render the error message; got:\n%s", out) + } +} diff --git a/internal/tui/modals/restore.go b/internal/tui/modals/restore.go new file mode 100644 index 0000000..ebb67a4 --- /dev/null +++ b/internal/tui/modals/restore.go @@ -0,0 +1,51 @@ +package modals + +import ( + "github.com/charmbracelet/huh" + + "github.com/nixrajput/siphon/internal/app" +) + +// RestoreResult holds the values bound by the restore form. The dashboard +// reads it after the form completes. +type RestoreResult struct { + Profile string + DumpID string + Clean bool +} + +// NewRestore builds a Huh form for selecting a restore target. The returned +// form binds its inputs into the returned *RestoreResult, which the dashboard +// reads once the form reaches huh.StateCompleted. +// +// The dump-ID step is adaptive: if the catalog contains dumps, a Select field +// is shown; otherwise an Input field is shown (with a title that signals no +// saved dumps exist). +func NewRestore(d app.Deps, defaultProfile, defaultDump string) (*huh.Form, *RestoreResult) { + res := &RestoreResult{Profile: defaultProfile, DumpID: defaultDump} + + var dumpIDField huh.Field + metas, err := d.Dumps.List() + if err == nil && len(metas) > 0 { + opts := make([]huh.Option[string], len(metas)) + for i, m := range metas { + opts[i] = huh.NewOption(m.ID, m.ID) + } + dumpIDField = huh.NewSelect[string](). + Title("Dump ID (/ to filter)"). + Options(opts...). + Filtering(true). + Value(&res.DumpID) + } else { + dumpIDField = huh.NewInput(). + Title("Dump ID (type — no saved dumps)"). + Value(&res.DumpID) + } + + form := huh.NewForm( + huh.NewGroup(huh.NewSelect[string]().Title("Target profile").Options(profileOptions(d)...).Value(&res.Profile)), + huh.NewGroup(dumpIDField), + huh.NewGroup(huh.NewConfirm().Title("Clean target before restore?").Affirmative("Yes").Negative("No").Value(&res.Clean)), + ).WithShowHelp(false) + return form, res +} diff --git a/internal/tui/msg.go b/internal/tui/msg.go new file mode 100644 index 0000000..465bbf0 --- /dev/null +++ b/internal/tui/msg.go @@ -0,0 +1,28 @@ +package tui + +import "github.com/nixrajput/siphon/internal/jobs" + +// JobEventMsg carries a single jobs.Event from the runner into the TUI's +// Update cycle. The Jobs panel listens for this; other panels ignore it. +type JobEventMsg struct { + Event jobs.Event +} + +// JobChannelMsg wraps the channel returned by app.Backup/Restore/Sync so +// the Jobs panel can subscribe. Sent by modals when they complete. +type JobChannelMsg struct { + Channel <-chan jobs.Event + JobID string +} + +// RefreshMsg asks all panels to reload their data (profile list, dumps). +type RefreshMsg struct{} + +// ErrorMsg pops the error modal with the given error and optional hint. +type ErrorMsg struct { + Err error + Hint string +} + +// FocusMsg moves focus to the named panel. +type FocusMsg struct{ Panel string } diff --git a/internal/tui/panel.go b/internal/tui/panel.go new file mode 100644 index 0000000..6dbcf94 --- /dev/null +++ b/internal/tui/panel.go @@ -0,0 +1,13 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +// Panel is the small contract every child component implements so the +// Dashboard can route key events, lifecycle messages, and resize hints +// to the focused panel uniformly. +type Panel interface { + tea.Model + Title() string + SetSize(w, h int) + SetFocus(bool) +} diff --git a/internal/tui/panels/dumps.go b/internal/tui/panels/dumps.go new file mode 100644 index 0000000..ddd1beb --- /dev/null +++ b/internal/tui/panels/dumps.go @@ -0,0 +1,113 @@ +package panels + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + + "github.com/nixrajput/siphon/internal/app" + "github.com/nixrajput/siphon/internal/tui/styles" +) + +// Dumps is a panel that displays the dump catalog in a scrollable table. +type Dumps struct { + deps app.Deps + tbl table.Model + focused bool +} + +// NewDumps creates a Dumps panel and performs an initial catalog load. +func NewDumps(d app.Deps) Dumps { + cols := []table.Column{ + {Title: "ID", Width: 26}, + {Title: "Profile", Width: 12}, + {Title: "Created", Width: 20}, + {Title: "Size", Width: 10}, + } + t := table.New(table.WithColumns(cols), table.WithFocused(false)) + dp := Dumps{deps: d, tbl: t} + dp.Reload() + return dp +} + +func (p Dumps) Init() tea.Cmd { return nil } + +func (p Dumps) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !p.focused { + return p, nil + } + var cmd tea.Cmd + p.tbl, cmd = p.tbl.Update(msg) + return p, cmd +} + +func (p Dumps) View() string { + border := styles.Border + if p.focused { + border = styles.BorderFocus + } + header := styles.PanelTitle.Render("Dumps") + return border.Render(header + "\n" + p.tbl.View()) +} + +// Title returns the panel's display name. +func (p Dumps) Title() string { return "Dumps" } + +// SetSize resizes the inner table to fit within the panel borders. +func (p *Dumps) SetSize(w, h int) { + p.tbl.SetWidth(w - 2) + p.tbl.SetHeight(h - 4) +} + +// SetFocus toggles keyboard focus on the table. +func (p *Dumps) SetFocus(b bool) { + p.focused = b + p.tbl.Focus() + if !b { + p.tbl.Blur() + } +} + +// SelectedID returns the currently highlighted dump ID, or "" if empty. +func (p Dumps) SelectedID() string { + r := p.tbl.SelectedRow() + if len(r) == 0 { + return "" + } + return r[0] +} + +// Reload rebuilds the rows from the catalog. +func (p *Dumps) Reload() { + all, err := p.deps.Dumps.List() + if err != nil { + return + } + rows := make([]table.Row, 0, len(all)) + for _, m := range all { + rows = append(rows, table.Row{ + m.ID, + m.Profile, + m.Created.Format(time.RFC3339), + humanSize(m.SizeBytes), + }) + } + p.tbl.SetRows(rows) +} + +func humanSize(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1fGB", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1fMB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1fKB", float64(n)/(1<<10)) + } + return fmt.Sprintf("%dB", n) +} + +// Compile-time check. +var _ tea.Model = Dumps{} diff --git a/internal/tui/panels/jobs.go b/internal/tui/panels/jobs.go new file mode 100644 index 0000000..3f7229e --- /dev/null +++ b/internal/tui/panels/jobs.go @@ -0,0 +1,132 @@ +package panels + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + + "github.com/nixrajput/siphon/internal/jobs" + "github.com/nixrajput/siphon/internal/tui/styles" +) + +type jobView struct { + id string + stage string + phase jobs.Phase + message string + err error + progress progress.Model + bytes int64 + total int64 +} + +// Jobs is a panel that displays running job progress bars via a live event +// subscription pump. +type Jobs struct { + jobs map[string]*jobView + order []string // job IDs in first-seen order, for deterministic rendering + focused bool +} + +// NewJobs creates an empty Jobs panel. +func NewJobs() Jobs { + return Jobs{jobs: map[string]*jobView{}} +} + +func (p Jobs) Init() tea.Cmd { return nil } + +// SubscribeCmd returns a tea.Cmd that reads one Event from ch and emits a +// JobEventInternal back into Update. The caller re-issues SubscribeCmd after +// each delivery to keep the pump running until ch closes. +func SubscribeCmd(ch <-chan jobs.Event) tea.Cmd { + return func() tea.Msg { + e, ok := <-ch + if !ok { + return jobsChannelClosedMsg{} + } + return JobEventInternal{Event: e, Ch: ch} + } +} + +// JobEventInternal is the message emitted by SubscribeCmd. Panel-internal pump +// message — distinct from tui.JobEventMsg / tui.JobChannelMsg in msg.go. +type JobEventInternal struct { + Event jobs.Event + Ch <-chan jobs.Event +} + +type jobsChannelClosedMsg struct{} + +func (p Jobs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case JobEventInternal: + p.absorb(msg.Event) + // Re-issue the subscription to pump the next event. + return p, SubscribeCmd(msg.Ch) + case jobsChannelClosedMsg: + // Channel closed — most recent state stays visible. + return p, nil + } + return p, nil +} + +func (p *Jobs) absorb(e jobs.Event) { + view, ok := p.jobs[e.JobID] + if !ok { + view = &jobView{ + id: e.JobID, + stage: e.Stage, + progress: progress.New(progress.WithDefaultGradient()), + } + p.jobs[e.JobID] = view + p.order = append(p.order, e.JobID) // record first-seen order + } + view.phase = e.Phase + view.message = e.Message + view.err = e.Err + if e.Progress != nil { + view.bytes = e.Progress.BytesDone + view.total = e.Progress.BytesTotal + } +} + +func (p Jobs) View() string { + border := styles.Border + if p.focused { + border = styles.BorderFocus + } + if len(p.jobs) == 0 { + return border.Render(styles.PanelTitle.Render("Jobs") + "\n" + styles.Dim.Render("(idle)")) + } + body := styles.PanelTitle.Render("Jobs") + "\n" + // Iterate in first-seen order (p.order) rather than ranging the map, so rows + // render deterministically and don't jump between frames. + for _, id := range p.order { + j, ok := p.jobs[id] + if !ok { + continue // defensive: skip any id without a backing view + } + bar := "" + if j.total > 0 { + bar = j.progress.ViewAs(float64(j.bytes) / float64(j.total)) + } + body += fmt.Sprintf("%-6s %s %s\n%s\n", j.stage, j.phase.String(), j.message, bar) + if (j.phase == jobs.PhaseError || j.phase == jobs.PhaseCancelled) && j.err != nil { + body += styles.Err.Render(j.err.Error()) + "\n" + } + } + return border.Render(body) +} + +// Title returns the panel's display name. +func (p Jobs) Title() string { return "Jobs" } + +// SetSize is a no-op — progress bars use their default width in Phase C. +func (p *Jobs) SetSize(w, h int) { /* progress bars are width-aware via Update */ } + +// SetFocus toggles keyboard focus on the panel. +func (p *Jobs) SetFocus(b bool) { p.focused = b } + +// Compile-time check. +var _ tea.Model = Jobs{} diff --git a/internal/tui/panels/jobs_test.go b/internal/tui/panels/jobs_test.go new file mode 100644 index 0000000..cdbc952 --- /dev/null +++ b/internal/tui/panels/jobs_test.go @@ -0,0 +1,46 @@ +package panels + +import ( + "strings" + "testing" + + "github.com/nixrajput/siphon/internal/jobs" +) + +// feed pushes a JobEventInternal through Update and returns the updated panel, +// mirroring how the dashboard forwards events and writes the panel back. +func feed(p Jobs, e jobs.Event) Jobs { + m, _ := p.Update(JobEventInternal{Event: e}) + return m.(Jobs) +} + +// TestJobs_View_DeterministicOrder verifies rows render in first-seen order +// (not the map's random iteration order). Jobs are fed B, A, C and must render +// in exactly that order, stably. +func TestJobs_View_DeterministicOrder(t *testing.T) { + p := NewJobs() + // Distinct stages so we can locate each job's row in the output. + p = feed(p, jobs.Event{JobID: "id-b", Stage: "bbk", Phase: jobs.PhaseProgress}) + p = feed(p, jobs.Event{JobID: "id-a", Stage: "ars", Phase: jobs.PhaseProgress}) + p = feed(p, jobs.Event{JobID: "id-c", Stage: "csy", Phase: jobs.PhaseProgress}) + + out := p.View() + ib := strings.Index(out, "bbk") + ia := strings.Index(out, "ars") + ic := strings.Index(out, "csy") + if ib < 0 || ia < 0 || ic < 0 { + t.Fatalf("View missing a job row: bbk=%d ars=%d csy=%d\n%s", ib, ia, ic, out) + } + // First-seen order is B, A, C — assert that exact positional order. + if ib >= ia || ia >= ic { + t.Fatalf("rows not in first-seen order (want bbk= ja || ja >= jc { + t.Fatalf("order changed after updating an existing job:\n%s", out2) + } +} diff --git a/internal/tui/panels/profiles.go b/internal/tui/panels/profiles.go new file mode 100644 index 0000000..8b47d11 --- /dev/null +++ b/internal/tui/panels/profiles.go @@ -0,0 +1,99 @@ +// Package panels contains the dashboard's child panels. Each panel is an +// independent tea.Model that conforms to siphon/internal/tui.Panel. +package panels + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + "github.com/nixrajput/siphon/internal/app" + "github.com/nixrajput/siphon/internal/tui/styles" +) + +type profileItem struct { + name string + driver string + host string +} + +func (p profileItem) Title() string { return p.name } +func (p profileItem) Description() string { return fmt.Sprintf("%s · %s", p.driver, p.host) } +func (p profileItem) FilterValue() string { return p.name } + +type Profiles struct { + deps app.Deps + list list.Model + focused bool +} + +func NewProfiles(d app.Deps) Profiles { + l := list.New(loadItems(d), list.NewDefaultDelegate(), 0, 0) + l.Title = "Profiles" + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.DisableQuitKeybindings() + // Filtering isn't a Phase C feature, and the dashboard's global key routing + // intercepts q/b/r before the list, so '/' would open a filter that 'q' + // quits the whole app out of. Disable it for safety + consistency with Dumps. + l.SetFilteringEnabled(false) + return Profiles{deps: d, list: l} +} + +func loadItems(d app.Deps) []list.Item { + names := d.Profiles.List() + items := make([]list.Item, 0, len(names)) + for _, n := range names { + p, err := d.Profiles.Get(n) + if err != nil { + // Skip rather than append a zero-value row — can't happen today + // (List and Get read the same map) but guards against future drift. + continue + } + items = append(items, profileItem{name: n, driver: p.Driver, host: p.Host}) + } + return items +} + +func (p Profiles) Init() tea.Cmd { return nil } + +func (p Profiles) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !p.focused { + return p, nil + } + var cmd tea.Cmd + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p Profiles) View() string { + border := styles.Border + if p.focused { + border = styles.BorderFocus + } + return border.Render(p.list.View()) +} + +func (p Profiles) Title() string { return "Profiles" } +func (p *Profiles) SetSize(w, h int) { + p.list.SetSize(w-2, h-2) +} +func (p *Profiles) SetFocus(b bool) { p.focused = b } + +// SelectedName returns the currently highlighted profile, or "" if none. +func (p Profiles) SelectedName() string { + if i, ok := p.list.SelectedItem().(profileItem); ok { + return i.name + } + return "" +} + +// Reload re-reads the profile list. Call after add/remove. +func (p *Profiles) Reload() { p.list.SetItems(loadItems(p.deps)) } + +// FilteringEnabled reports whether the underlying list accepts filter input. +func (p Profiles) FilteringEnabled() bool { return p.list.FilteringEnabled() } + +// Compile-time check. +var _ tea.Model = Profiles{} diff --git a/internal/tui/run.go b/internal/tui/run.go new file mode 100644 index 0000000..27dd474 --- /dev/null +++ b/internal/tui/run.go @@ -0,0 +1,14 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/nixrajput/siphon/internal/app" +) + +// Run launches the dashboard with the given app deps and blocks until the +// user quits. It replaces the Phase A no-arg Run(). +func Run(deps app.Deps) error { + _, err := tea.NewProgram(NewDashboard(deps), tea.WithAltScreen()).Run() + return err +} diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go new file mode 100644 index 0000000..9300504 --- /dev/null +++ b/internal/tui/styles/styles.go @@ -0,0 +1,21 @@ +// Package styles holds Lipgloss styles shared across the TUI. Keep this +// small and deliberate — every visual decision lives here so the look is +// consistent across panels and modals. +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7dd3fc")) + Subtitle = lipgloss.NewStyle().Faint(true) + Dim = lipgloss.NewStyle().Faint(true) + Ok = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + Warn = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")) + Err = lipgloss.NewStyle().Foreground(lipgloss.Color("#fb7185")) + Label = lipgloss.NewStyle().Foreground(lipgloss.Color("#a3e635")) + Selection = lipgloss.NewStyle().Background(lipgloss.Color("#1e3a8a")).Foreground(lipgloss.Color("#f0f9ff")) + PanelTitle = lipgloss.NewStyle().Bold(true).Padding(0, 1).Foreground(lipgloss.Color("#c084fc")) + Border = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#1f2733")).Padding(0, 1) + BorderFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#7dd3fc")).Padding(0, 1) + StatusBar = lipgloss.NewStyle().Background(lipgloss.Color("#0c0f14")).Foreground(lipgloss.Color("#6b7280")).Padding(0, 1) +) diff --git a/internal/tui/testdata/TestDashboard_FocusDumps_Snapshot.golden b/internal/tui/testdata/TestDashboard_FocusDumps_Snapshot.golden new file mode 100644 index 0000000..1a2358d --- /dev/null +++ b/internal/tui/testdata/TestDashboard_FocusDumps_Snapshot.golden @@ -0,0 +1,27 @@ + ╭────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮╭────────╮ +│ Profiles ││ Dumps ││ Jobs │ +│ ││ ID Profile Created Size ││ (idle) │ +│ No items. ││ │╰────────╯ +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ │╰──────────────────────────────────────────────────────────────────────────────╯  +╰────────────────╯  + b backup · r restore · tab focus · q quit  [?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/internal/tui/testdata/TestDashboard_Initial_Snapshot.golden b/internal/tui/testdata/TestDashboard_Initial_Snapshot.golden new file mode 100644 index 0000000..1a2358d --- /dev/null +++ b/internal/tui/testdata/TestDashboard_Initial_Snapshot.golden @@ -0,0 +1,27 @@ + ╭────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮╭────────╮ +│ Profiles ││ Dumps ││ Jobs │ +│ ││ ID Profile Created Size ││ (idle) │ +│ No items. ││ │╰────────╯ +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ ││ │  +│ │╰──────────────────────────────────────────────────────────────────────────────╯  +╰────────────────╯  + b backup · r restore · tab focus · q quit  [?2004l[?25h[?1002l[?1003l[?1006l \ No newline at end of file