diff --git a/README.md b/README.md index 4bfb682..a680245 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,12 @@ docket next # View the Kanban board docket board + +# Browse issues in the interactive terminal UI +docket tui ``` -**AI agents:** add `--json` to any command for structured, machine-readable output: +**AI agents:** add `--json` to supported non-interactive commands for structured, machine-readable output: ```bash docket next --json @@ -81,14 +84,16 @@ docket issue list --json -s todo -s in-progress ## Why Docket? - **No servers, no network** — everything is a local SQLite file in `.docket/`. Works offline, on planes, in CI. -- **AI-native from day one** — every command supports `--json` with a consistent envelope. Agents can create, query, plan, and update issues without parsing human text. +- **AI-native from day one** — machine-readable commands support `--json` with a consistent envelope. Agents can create, query, plan, and update issues without parsing human text. - **Dependency-aware planning** — `docket next` and `docket plan` use a DAG to surface only unblocked, work-ready issues. No stale sprint boards. - **Zero configuration** — `docket init` and you're done. No accounts, no tokens, no YAML. - **Portable data** — the `.docket/` directory travels with your repo. Clone it, fork it, archive it. ## AI Agent Integration -Every command supports `--json` for structured, machine-readable output. All JSON responses use a consistent envelope: +Most commands support `--json` for structured, machine-readable output. All JSON responses use a consistent envelope: + +`docket tui` is the exception: it opens a read-only interactive terminal UI, requires a real TTY, and rejects `--json`. **Success:** `{"ok": true, "data": { ... }, "message": "..."}` @@ -117,6 +122,33 @@ Run `docket next --json` to find work. Move issues to `in-progress` before start Any agent that can run shell commands works with Docket. Point it at `docket next --json` to discover work items, and use `docket issue show --json` to get full context before starting a task. The consistent JSON envelope (`ok`, `data`, `error`, `code`) makes parsing straightforward in any language. +### Interactive UI + +Use `docket tui` when you want an interactive browser instead of command output. It opens a read-only terminal UI for list and board browsing, auto-refreshes by default, supports `p` to pause or resume polling, supports list sorting with `s` (cycle sort field) and `S` (toggle sort direction), requires an interactive terminal, and does not support `--json`. + +If you need to debug terminal-specific behavior, set `DOCKET_TUI_DEBUG_LOG=/tmp/docket-tui.log` before running `docket tui`. + +#### TUI Keybinds + +| Key | Action | +|-----|--------| +| `1` | Switch to list view | +| `2` | Switch to board view | +| `j` / `k` | Move selection in the focused pane | +| `J` / `K` | Move detail selection while browse stays focused | +| `s` | Cycle list sort field | +| `S` | Toggle list sort direction | +| `o` | Drill into the selected epic | +| `tab` | Switch between browse and detail panes | +| `enter` | Expand detail or open the selected sub-issue | +| `h` / `l` | Switch board column or detail region | +| `u` | Go to parent issue or back | +| `ctrl+u` / `ctrl+d` | Half-page scroll in detail | +| `r` | Refresh current view | +| `p` | Pause or resume auto-refresh | +| `?` | Toggle help | +| `q` | Quit | +
Verbose JSON examples @@ -222,6 +254,8 @@ docket issue list --json -s todo -s in-progress -p high --quiet, -q Suppress non-essential output ``` +`docket tui` is interactive-only and rejects `--json`. + ### Issue Commands (`docket issue` / `docket i`) | Command | Description | @@ -290,6 +324,7 @@ docket issue list --json -s todo -s in-progress -p high | `docket config` | Show current configuration (database path, schema version, etc.) | | `docket version` | Print version, commit, and build date | | `docket stats` | Show summary statistics for the issue database | +| `docket tui` | Browse issues in an interactive terminal UI | ### Export / Import diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 639ded1..d406511 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -76,7 +76,7 @@ SQLite (modernc.org/sqlite) -- .docket/issues.db Built on **spf13/cobra**. The root command (`docket`) defines: -- Global flags: `--json` (structured output), `--quiet` (suppress non-essential output) +- Global flags: `--json` (structured output), `--quiet` (suppress non-essential output). Interactive commands can reject unsupported flags at runtime; `docket tui` rejects `--json`. - `PersistentPreRunE`: Resolves config, opens SQLite DB, runs migrations. Commands annotated with `skipDB` bypass DB initialization (e.g., `init`). - `PersistentPostRunE`: Closes the DB connection. - Version info injected via ldflags at build time (`version`, `commit`, `buildDate`). @@ -87,6 +87,7 @@ Built on **spf13/cobra**. The root command (`docket`) defines: |---------|-------------|-------------| | `init` | -- | Create `.docket/` directory and initialize schema | | `board` | -- | Kanban board view (columns by status) | +| `tui` | -- | Read-only interactive terminal UI for browsing issues | | `plan` | -- | DAG-based execution plan with phased grouping | | `next` | -- | Work-ready issues (unblocked leaf tasks) | | `stats` | -- | Summary statistics (counts by status/priority/label) | diff --git a/docs/spec/review-strategy.md b/docs/spec/review-strategy.md index da33041..dd26609 100644 --- a/docs/spec/review-strategy.md +++ b/docs/spec/review-strategy.md @@ -69,15 +69,16 @@ The following dimensions are weighted by their relevance to the docket project, - Review must verify: migration idempotency, rollback safety, data preservation across versions **JSON API Contract Stability (Critical)** -- Every command supports `--json` with a documented envelope (`{"ok": true, "data": ..., "message": ...}`) +- Non-interactive commands support `--json` with a documented envelope (`{"ok": true, "data": ..., "message": ...}`) - AI agents depend on stable JSON shapes — breaking changes silently break agent workflows +- Interactive commands like `docket tui` are exceptions and must fail clearly when `--json` is unsupported - Error codes (`GENERAL_ERROR`, `NOT_FOUND`, `VALIDATION_ERROR`, `CONFLICT`) are part of the contract - The QA suite has dedicated sections (Q, R) for contract and exit code validation - Review must verify: no field renames/removals without versioning, exit codes match documented behavior **CLI UX Consistency (High)** - One-file-per-command pattern in `internal/cli/` must be maintained -- All commands must support `--json` and `--quiet` flags via `getWriter()` +- Non-interactive commands should support `--json` and `--quiet` via `getWriter()`; interactive commands must document and enforce any exceptions - Interactive forms (via `charmbracelet/huh`) must degrade gracefully in non-TTY contexts - Review must verify: new commands follow established patterns, help text is consistent diff --git a/go.mod b/go.mod index 9664e79..13f5e20 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,14 @@ go 1.26.0 require ( github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260320211915-9fb550947a7e + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/dustin/go-humanize v1.0.1 + github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 modernc.org/sqlite v1.47.0 ) @@ -21,7 +24,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -29,7 +31,6 @@ require ( github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -45,7 +46,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -55,9 +55,8 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 5e5d5b9..99d7881 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,15 @@ -github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260212055816-51bdb9386f34 h1:oE3oOhguKzI26kaO4SzYh/0EwpjWBpYPefOjRinl2rY= -github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260212055816-51bdb9386f34/go.mod h1:z08U1gjJ2zMCvb9gonuVS1PE8LzKZZu0SWNRoL6BZIc= -github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260215052735-587d84323d6a h1:/bCuCT0yuFMtL2vMpiItZmUs0SUBFJeSdNKBDtzsldg= -github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260215052735-587d84323d6a/go.mod h1:z08U1gjJ2zMCvb9gonuVS1PE8LzKZZu0SWNRoL6BZIc= github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260320211915-9fb550947a7e h1:D36I+hSSaiQSMXxRK46040hOpuD8IXwdKEMklZWI9uA= github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260320211915-9fb550947a7e/go.mod h1:MmFH0pgl/4l+to8AXJRkdVcWwoAziUj2gajCuyAicUE= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= @@ -30,34 +22,20 @@ 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/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 v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 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.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 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.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 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.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 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.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 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= @@ -66,16 +44,10 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 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/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/slice v0.0.0-20260316093931-f2fb44ab3145 h1:SP0ri6gx/zq5C78s+mHXJC/wOLp1HhgKP7hAgYrKTr4= github.com/charmbracelet/x/exp/slice v0.0.0-20260316093931-f2fb44ab3145/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= -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/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= -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/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= @@ -84,15 +56,11 @@ github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGl github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -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.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -119,8 +87,6 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -128,8 +94,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -155,19 +119,13 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -183,46 +141,28 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -230,21 +170,16 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= @@ -255,8 +190,6 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= -modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= diff --git a/internal/app/issues.go b/internal/app/issues.go new file mode 100644 index 0000000..06f3868 --- /dev/null +++ b/internal/app/issues.go @@ -0,0 +1,261 @@ +package app + +import ( + "database/sql" + "fmt" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/render" +) + +type ListIssuesParams struct { + Statuses []string + Priorities []string + Labels []string + Types []string + Assignee string + ParentID *int + RootsOnly bool + IncludeDone bool + Sort string + SortDir string + Limit int +} + +type IssueListData struct { + Issues []*model.Issue + Total int + ParentMap map[int]*model.Issue + Progress map[int]render.SubIssueProgress +} + +type BoardParams struct { + Labels []string + Priorities []string + Assignee string + Expand bool +} + +type BoardData struct { + Issues []*model.Issue + Progress map[int]render.SubIssueProgress + Total int + Expanded bool +} + +type IssueDetailData struct { + Issue *model.Issue + SubIssues []*model.Issue + Relations []model.Relation + Comments []*model.Comment + Activity []model.Activity +} + +func ListIssues(conn *sql.DB, params ListIssuesParams) (IssueListData, error) { + issues, total, err := db.ListIssues(conn, db.ListOptions{ + Statuses: params.Statuses, + Priorities: params.Priorities, + Labels: params.Labels, + Types: params.Types, + Assignee: params.Assignee, + ParentID: params.ParentID, + RootsOnly: params.RootsOnly, + IncludeDone: params.IncludeDone, + Sort: params.Sort, + SortDir: params.SortDir, + Limit: params.Limit, + }) + if err != nil { + return IssueListData{}, fmt.Errorf("listing issues: %w", err) + } + + parentMap, progress, err := loadParentProgress(conn, issues) + if err != nil { + return IssueListData{}, err + } + + return IssueListData{ + Issues: issues, + Total: total, + ParentMap: parentMap, + Progress: progress, + }, nil +} + +func LoadBoard(conn *sql.DB, params BoardParams) (BoardData, error) { + issues, _, err := db.ListIssues(conn, db.ListOptions{ + Priorities: params.Priorities, + Labels: params.Labels, + Assignee: params.Assignee, + IncludeDone: true, + }) + if err != nil { + return BoardData{}, fmt.Errorf("listing issues: %w", err) + } + + if !params.Expand { + var roots []*model.Issue + for _, issue := range issues { + if issue.ParentID == nil { + roots = append(roots, issue) + } + } + issues = roots + } + + progress, err := loadSubIssueProgress(conn, issues) + if err != nil { + return BoardData{}, err + } + + return BoardData{ + Issues: issues, + Progress: progress, + Total: len(issues), + Expanded: params.Expand, + }, nil +} + +func GetIssueDetail(conn *sql.DB, id int) (IssueDetailData, error) { + issue, err := db.GetIssue(conn, id) + if err != nil { + return IssueDetailData{}, err + } + + issue.Labels, err = db.GetIssueLabels(conn, id) + if err != nil { + return IssueDetailData{}, fmt.Errorf("fetching labels: %w", err) + } + + issue.Files, err = db.GetIssueFiles(conn, id) + if err != nil { + return IssueDetailData{}, fmt.Errorf("fetching files: %w", err) + } + + subIssues, err := db.GetSubIssues(conn, id) + if err != nil { + return IssueDetailData{}, fmt.Errorf("fetching sub-issues: %w", err) + } + + relations, err := db.GetIssueRelations(conn, id) + if err != nil { + return IssueDetailData{}, fmt.Errorf("fetching relations: %w", err) + } + + comments, err := db.ListComments(conn, id) + if err != nil { + return IssueDetailData{}, fmt.Errorf("fetching comments: %w", err) + } + + activity, err := db.GetActivity(conn, id, 10) + if err != nil { + return IssueDetailData{}, fmt.Errorf("fetching activity: %w", err) + } + + return IssueDetailData{ + Issue: issue, + SubIssues: subIssues, + Relations: relations, + Comments: comments, + Activity: activity, + }, nil +} + +func loadParentProgress(conn *sql.DB, issues []*model.Issue) (map[int]*model.Issue, map[int]render.SubIssueProgress, error) { + resultIDs := make(map[int]struct{}, len(issues)) + for _, issue := range issues { + resultIDs[issue.ID] = struct{}{} + } + + missingParentIDs := make(map[int]struct{}) + for _, issue := range issues { + if issue.ParentID == nil { + continue + } + pid := *issue.ParentID + if _, inResult := resultIDs[pid]; !inResult { + missingParentIDs[pid] = struct{}{} + } + } + + parentMap := make(map[int]*model.Issue) + if len(missingParentIDs) > 0 { + ids := make([]int, 0, len(missingParentIDs)) + for id := range missingParentIDs { + ids = append(ids, id) + } + var err error + parentMap, err = db.GetIssuesByIDs(conn, ids) + if err != nil { + return nil, nil, fmt.Errorf("fetching parent issues: %w", err) + } + } + + parentIDSet := make(map[int]struct{}) + for _, issue := range issues { + parentIDSet[issue.ID] = struct{}{} + } + for id := range parentMap { + parentIDSet[id] = struct{}{} + } + for _, issue := range issues { + if issue.ParentID == nil { + continue + } + pid := *issue.ParentID + if _, inResult := resultIDs[pid]; inResult { + parentIDSet[pid] = struct{}{} + continue + } + if _, inMap := parentMap[pid]; inMap { + parentIDSet[pid] = struct{}{} + } + } + + progress := make(map[int]render.SubIssueProgress) + if len(parentIDSet) == 0 { + return parentMap, progress, nil + } + + parentIDs := make([]int, 0, len(parentIDSet)) + for id := range parentIDSet { + parentIDs = append(parentIDs, id) + } + batchProgress, err := db.GetBatchSubIssueProgress(conn, parentIDs) + if err != nil { + return nil, nil, fmt.Errorf("fetching sub-issue progress: %w", err) + } + for id, counts := range batchProgress { + if counts[1] > 0 { + progress[id] = render.SubIssueProgress{Done: counts[0], Total: counts[1]} + } + } + + return parentMap, progress, nil +} + +func loadSubIssueProgress(conn *sql.DB, issues []*model.Issue) (map[int]render.SubIssueProgress, error) { + if len(issues) == 0 { + return map[int]render.SubIssueProgress{}, nil + } + + parentIDs := make([]int, len(issues)) + for i, issue := range issues { + parentIDs[i] = issue.ID + } + + batchProgress, err := db.GetBatchSubIssueProgress(conn, parentIDs) + if err != nil { + return nil, fmt.Errorf("fetching sub-issue progress: %w", err) + } + + progress := make(map[int]render.SubIssueProgress, len(batchProgress)) + for id, counts := range batchProgress { + if counts[1] > 0 { + progress[id] = render.SubIssueProgress{Done: counts[0], Total: counts[1]} + } + } + + return progress, nil +} diff --git a/internal/app/issues_test.go b/internal/app/issues_test.go new file mode 100644 index 0000000..5766906 --- /dev/null +++ b/internal/app/issues_test.go @@ -0,0 +1,243 @@ +package app + +import ( + "database/sql" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func mustOpenDB(t *testing.T) *sql.DB { + t.Helper() + conn, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open(:memory:): %v", err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Initialize(conn); err != nil { + t.Fatalf("Initialize: %v", err) + } + return conn +} + +func createIssue(t *testing.T, conn *sql.DB, issue model.Issue, labels, files []string) int { + t.Helper() + id, err := db.CreateIssue(conn, &issue, labels, files) + if err != nil { + t.Fatalf("CreateIssue(%q): %v", issue.Title, err) + } + return id +} + +func TestListIssuesBuildsParentContext(t *testing.T) { + conn := mustOpenDB(t) + + parentID := createIssue(t, conn, model.Issue{ + Title: "Parent epic", + Status: model.StatusBacklog, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }, nil, nil) + + createIssue(t, conn, model.Issue{ + ParentID: &parentID, + Title: "Child task", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }, nil, nil) + + data, err := ListIssues(conn, ListIssuesParams{Statuses: []string{string(model.StatusTodo)}}) + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + + if data.Total != 1 || len(data.Issues) != 1 { + t.Fatalf("got total=%d len=%d, want 1/1", data.Total, len(data.Issues)) + } + if _, ok := data.ParentMap[parentID]; !ok { + t.Fatalf("expected parent %d in parent map", parentID) + } + if progress, ok := data.Progress[parentID]; !ok || progress.Total != 1 { + t.Fatalf("expected progress for parent %d, got %#v", parentID, data.Progress[parentID]) + } +} + +func TestListIssuesKeepsProgressForStandaloneParentRows(t *testing.T) { + conn := mustOpenDB(t) + + parentID := createIssue(t, conn, model.Issue{ + Title: "Standalone parent", + Status: model.StatusBacklog, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }, nil, nil) + + createIssue(t, conn, model.Issue{ + ParentID: &parentID, + Title: "Hidden child one", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }, nil, nil) + createIssue(t, conn, model.Issue{ + ParentID: &parentID, + Title: "Hidden child two", + Status: model.StatusDone, + Priority: model.PriorityLow, + Kind: model.IssueKindTask, + }, nil, nil) + + data, err := ListIssues(conn, ListIssuesParams{Statuses: []string{string(model.StatusBacklog)}}) + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + + if data.Total != 1 || len(data.Issues) != 1 { + t.Fatalf("got total=%d len=%d, want 1/1", data.Total, len(data.Issues)) + } + if data.Issues[0].ID != parentID { + t.Fatalf("got issue %d, want parent %d", data.Issues[0].ID, parentID) + } + if progress, ok := data.Progress[parentID]; !ok || progress.Done != 1 || progress.Total != 2 { + t.Fatalf("expected standalone parent progress 1/2 for %d, got %#v", parentID, data.Progress[parentID]) + } +} + +func TestListIssuesHonorsExplicitIDDescSort(t *testing.T) { + conn := mustOpenDB(t) + + olderID := createIssue(t, conn, model.Issue{ + Title: "Older but higher-ranked by default sort", + Status: model.StatusInProgress, + Priority: model.PriorityCritical, + Kind: model.IssueKindTask, + }, nil, nil) + newerID := createIssue(t, conn, model.Issue{ + Title: "Newer by id", + Status: model.StatusBacklog, + Priority: model.PriorityLow, + Kind: model.IssueKindTask, + }, nil, nil) + + data, err := ListIssues(conn, ListIssuesParams{Sort: "id", SortDir: "desc"}) + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + + if data.Total != 2 || len(data.Issues) != 2 { + t.Fatalf("got total=%d len=%d, want 2/2", data.Total, len(data.Issues)) + } + if data.Issues[0].ID != newerID || data.Issues[1].ID != olderID { + t.Fatalf("order = [%d, %d], want [%d, %d]", data.Issues[0].ID, data.Issues[1].ID, newerID, olderID) + } +} + +func TestLoadBoardRollsUpChildrenByDefault(t *testing.T) { + conn := mustOpenDB(t) + + parentID := createIssue(t, conn, model.Issue{ + Title: "Parent task", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindTask, + }, nil, nil) + createIssue(t, conn, model.Issue{ + ParentID: &parentID, + Title: "Nested task", + Status: model.StatusTodo, + Priority: model.PriorityLow, + Kind: model.IssueKindTask, + }, nil, nil) + createIssue(t, conn, model.Issue{ + Title: "Standalone task", + Status: model.StatusReview, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }, nil, nil) + + rolledUp, err := LoadBoard(conn, BoardParams{}) + if err != nil { + t.Fatalf("LoadBoard: %v", err) + } + if len(rolledUp.Issues) != 2 { + t.Fatalf("len(rolledUp.Issues) = %d, want 2", len(rolledUp.Issues)) + } + if progress, ok := rolledUp.Progress[parentID]; !ok || progress.Total != 1 { + t.Fatalf("expected rolled-up progress for parent %d, got %#v", parentID, rolledUp.Progress[parentID]) + } + + expanded, err := LoadBoard(conn, BoardParams{Expand: true}) + if err != nil { + t.Fatalf("LoadBoard expand: %v", err) + } + if len(expanded.Issues) != 3 { + t.Fatalf("len(expanded.Issues) = %d, want 3", len(expanded.Issues)) + } +} + +func TestGetIssueDetailHydratesAssociatedData(t *testing.T) { + conn := mustOpenDB(t) + + issueID := createIssue(t, conn, model.Issue{ + Title: "Feature work", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindFeature, + }, []string{"ui"}, []string{"internal/tui/browser.go"}) + + childID := createIssue(t, conn, model.Issue{ + ParentID: &issueID, + Title: "Child work", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }, nil, nil) + + otherID := createIssue(t, conn, model.Issue{ + Title: "Dependency", + Status: model.StatusBacklog, + Priority: model.PriorityLow, + Kind: model.IssueKindTask, + }, nil, nil) + + if _, err := db.CreateRelation(conn, &model.Relation{ + SourceIssueID: issueID, + TargetIssueID: otherID, + RelationType: model.RelationBlocks, + }); err != nil { + t.Fatalf("CreateRelation: %v", err) + } + + if _, err := db.CreateComment(conn, &model.Comment{IssueID: issueID, Body: "Looks good", Author: "cam"}); err != nil { + t.Fatalf("CreateComment: %v", err) + } + + data, err := GetIssueDetail(conn, issueID) + if err != nil { + t.Fatalf("GetIssueDetail: %v", err) + } + + if data.Issue == nil || data.Issue.ID != issueID { + t.Fatalf("expected issue %d, got %#v", issueID, data.Issue) + } + if len(data.Issue.Labels) != 1 || data.Issue.Labels[0] != "ui" { + t.Fatalf("expected hydrated labels, got %#v", data.Issue.Labels) + } + if len(data.Issue.Files) != 1 || data.Issue.Files[0] != "internal/tui/browser.go" { + t.Fatalf("expected hydrated files, got %#v", data.Issue.Files) + } + if len(data.SubIssues) != 1 || data.SubIssues[0].ID != childID { + t.Fatalf("expected child issue %d, got %#v", childID, data.SubIssues) + } + if len(data.Relations) != 1 { + t.Fatalf("expected one relation, got %#v", data.Relations) + } + if len(data.Comments) != 1 || data.Comments[0].Body != "Looks good" { + t.Fatalf("expected one comment, got %#v", data.Comments) + } + if len(data.Activity) == 0 { + t.Fatalf("expected activity entries") + } +} diff --git a/internal/cli/board.go b/internal/cli/board.go index 5a5af93..cfbb1b5 100644 --- a/internal/cli/board.go +++ b/internal/cli/board.go @@ -2,14 +2,13 @@ package cli import ( "context" - "fmt" "os" "os/signal" "syscall" "golang.org/x/term" - "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/app" "github.com/ALT-F4-LLC/docket/internal/model" "github.com/ALT-F4-LLC/docket/internal/output" "github.com/ALT-F4-LLC/docket/internal/render" @@ -70,34 +69,20 @@ func runBoard(cmd *cobra.Command, args []string, w *output.Writer) error { } } - opts := db.ListOptions{ - Priorities: priorities, - Labels: labels, - Assignee: assignee, - IncludeDone: true, - } - - issues, _, err := db.ListIssues(conn, opts) + data, err := app.LoadBoard(conn, app.BoardParams{ + Labels: labels, + Priorities: priorities, + Assignee: assignee, + Expand: expand, + }) if err != nil { - return cmdErr(fmt.Errorf("listing issues: %w", err), output.ErrGeneral) - } - - // By default, roll up sub-issues into their parent (exclude issues that - // have a parent). When --expand is set, show all issues individually. - if !expand { - var roots []*model.Issue - for _, issue := range issues { - if issue.ParentID == nil { - roots = append(roots, issue) - } - } - issues = roots + return cmdErr(err, output.ErrGeneral) } if w.JSONMode { // Group issues by status for structured output. groups := make(map[model.Status][]*model.Issue) - for _, issue := range issues { + for _, issue := range data.Issues { groups[issue.Status] = append(groups[issue.Status], issue) } @@ -118,27 +103,11 @@ func runBoard(cmd *cobra.Command, args []string, w *output.Writer) error { return nil } - // Build sub-issue progress map for parent issues in a single query. - parentIDs := make([]int, len(issues)) - for i, issue := range issues { - parentIDs[i] = issue.ID - } - batchProgress, err := db.GetBatchSubIssueProgress(conn, parentIDs) - if err != nil { - return cmdErr(fmt.Errorf("fetching sub-issue progress: %w", err), output.ErrGeneral) - } - progress := make(map[int]render.SubIssueProgress, len(batchProgress)) - for id, counts := range batchProgress { - if counts[1] > 0 { - progress[id] = render.SubIssueProgress{Done: counts[0], Total: counts[1]} - } - } - boardOpts := render.BoardOptions{ Expand: expand, - Progress: progress, + Progress: data.Progress, } - message := render.RenderBoard(issues, boardOpts) + message := render.RenderBoard(data.Issues, boardOpts) w.Success(nil, message) return nil diff --git a/internal/cli/issue_list.go b/internal/cli/issue_list.go index 24c873d..06c11d8 100644 --- a/internal/cli/issue_list.go +++ b/internal/cli/issue_list.go @@ -8,6 +8,7 @@ import ( "strings" "syscall" + "github.com/ALT-F4-LLC/docket/internal/app" "github.com/ALT-F4-LLC/docket/internal/db" "github.com/ALT-F4-LLC/docket/internal/model" "github.com/ALT-F4-LLC/docket/internal/output" @@ -110,91 +111,31 @@ func runIssueList(cmd *cobra.Command, args []string, w *output.Writer) error { } } - issues, total, err := db.ListIssues(conn, opts) + data, err := app.ListIssues(conn, app.ListIssuesParams{ + Statuses: opts.Statuses, + Priorities: opts.Priorities, + Labels: opts.Labels, + Types: opts.Types, + Assignee: opts.Assignee, + ParentID: opts.ParentID, + RootsOnly: opts.RootsOnly, + IncludeDone: opts.IncludeDone, + Sort: opts.Sort, + SortDir: opts.SortDir, + Limit: opts.Limit, + }) if err != nil { - return cmdErr(fmt.Errorf("listing issues: %w", err), output.ErrGeneral) + return cmdErr(err, output.ErrGeneral) } - result := listResult{Issues: issues, Total: total} - - // Fetch parent issues and sub-issue progress for the grouped display. - // Only needed for human-readable output (JSON stays flat). - var parentMap map[int]*model.Issue - var progress map[int]render.SubIssueProgress - if !w.JSONMode { - // Build a set of issue IDs in the result set for quick lookup. - resultIDs := make(map[int]struct{}, len(issues)) - for _, issue := range issues { - resultIDs[issue.ID] = struct{}{} - } - - // Collect parent IDs that are referenced but not in the result set. - missingParentIDs := make(map[int]struct{}) - for _, issue := range issues { - if issue.ParentID != nil { - pid := *issue.ParentID - if _, inResult := resultIDs[pid]; !inResult { - missingParentIDs[pid] = struct{}{} - } - } - } - - // Batch-fetch missing parents if any exist. - if len(missingParentIDs) > 0 { - ids := make([]int, 0, len(missingParentIDs)) - for id := range missingParentIDs { - ids = append(ids, id) - } - parentMap, err = db.GetIssuesByIDs(conn, ids) - if err != nil { - return cmdErr(fmt.Errorf("fetching parent issues: %w", err), output.ErrGeneral) - } - } - - // Collect IDs of all parent issues that have children in the - // result set. This includes parents fetched into parentMap - // (excluded by filters) and parents already in the result set. - parentIDSet := make(map[int]struct{}) - for id := range parentMap { - parentIDSet[id] = struct{}{} - } - for _, issue := range issues { - if issue.ParentID != nil { - pid := *issue.ParentID - if _, inResult := resultIDs[pid]; inResult { - parentIDSet[pid] = struct{}{} - } else if _, inMap := parentMap[pid]; inMap { - parentIDSet[pid] = struct{}{} - } - } - } - - // Fetch sub-issue progress (done/total counts) for parent - // issues in a single batch query. - if len(parentIDSet) > 0 { - parentIDs := make([]int, 0, len(parentIDSet)) - for id := range parentIDSet { - parentIDs = append(parentIDs, id) - } - batchProgress, err := db.GetBatchSubIssueProgress(conn, parentIDs) - if err != nil { - return cmdErr(fmt.Errorf("fetching sub-issue progress: %w", err), output.ErrGeneral) - } - progress = make(map[int]render.SubIssueProgress, len(batchProgress)) - for id, counts := range batchProgress { - if counts[1] > 0 { - progress[id] = render.SubIssueProgress{Done: counts[0], Total: counts[1]} - } - } - } - } + result := listResult{Issues: data.Issues, Total: data.Total} var message string if !w.JSONMode { if treeMode { - message = render.RenderTable(issues, true) + message = render.RenderTable(data.Issues, true) } else { - message = render.RenderGroupedTable(issues, parentMap, progress) + message = render.RenderGroupedTable(data.Issues, data.ParentMap, data.Progress) } } w.Success(result, message) diff --git a/internal/cli/issue_show.go b/internal/cli/issue_show.go index 6beed75..65c6933 100644 --- a/internal/cli/issue_show.go +++ b/internal/cli/issue_show.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "github.com/ALT-F4-LLC/docket/internal/app" "github.com/ALT-F4-LLC/docket/internal/db" "github.com/ALT-F4-LLC/docket/internal/model" "github.com/ALT-F4-LLC/docket/internal/output" @@ -19,8 +20,6 @@ import ( "golang.org/x/term" ) -// showResult composes the issue fields with additional detail fields -// (sub-issues, relations, comments, activity) into a single flat JSON object. type showResult struct { Issue *model.Issue `json:"-"` SubIssues []*model.Issue `json:"sub_issues"` @@ -29,8 +28,6 @@ type showResult struct { Activity []model.Activity `json:"activity"` } -// showResultJSON is the wire format that explicitly lists all fields, -// avoiding the fragile marshal-unmarshal-remarshal pattern. type showResultJSON struct { ID string `json:"id"` ParentID *string `json:"parent_id,omitempty"` @@ -139,57 +136,25 @@ func runIssueShow(cmd *cobra.Command, args []string, w *output.Writer) error { return cmdErr(fmt.Errorf("invalid issue ID: %w", err), output.ErrValidation) } - issue, err := db.GetIssue(conn, id) + data, err := app.GetIssueDetail(conn, id) if err != nil { if errors.Is(err, db.ErrNotFound) { return cmdErr(fmt.Errorf("issue %s not found", args[0]), output.ErrNotFound) } - return cmdErr(fmt.Errorf("fetching issue: %w", err), output.ErrGeneral) - } - - // Hydrate labels. - issue.Labels, err = db.GetIssueLabels(conn, id) - if err != nil { - return cmdErr(fmt.Errorf("fetching labels: %w", err), output.ErrGeneral) - } - - // Hydrate files. - issue.Files, err = db.GetIssueFiles(conn, id) - if err != nil { - return cmdErr(fmt.Errorf("fetching files: %w", err), output.ErrGeneral) - } - - subIssues, err := db.GetSubIssues(conn, id) - if err != nil { - return cmdErr(fmt.Errorf("fetching sub-issues: %w", err), output.ErrGeneral) - } - - relations, err := db.GetIssueRelations(conn, id) - if err != nil { - return cmdErr(fmt.Errorf("fetching relations: %w", err), output.ErrGeneral) - } - - comments, err := db.ListComments(conn, id) - if err != nil { - return cmdErr(fmt.Errorf("fetching comments: %w", err), output.ErrGeneral) - } - - activity, err := db.GetActivity(conn, id, 10) - if err != nil { - return cmdErr(fmt.Errorf("fetching activity: %w", err), output.ErrGeneral) + return cmdErr(err, output.ErrGeneral) } result := showResult{ - Issue: issue, - SubIssues: subIssues, - Relations: relations, - Comments: comments, - Activity: activity, + Issue: data.Issue, + SubIssues: data.SubIssues, + Relations: data.Relations, + Comments: data.Comments, + Activity: data.Activity, } var message string if !w.JSONMode { - message = render.RenderDetail(issue, subIssues, relations, comments, activity) + message = render.RenderDetail(data.Issue, data.SubIssues, data.Relations, data.Comments, data.Activity) } w.Success(result, message) diff --git a/internal/cli/tui.go b/internal/cli/tui.go new file mode 100644 index 0000000..e5b955e --- /dev/null +++ b/internal/cli/tui.go @@ -0,0 +1,72 @@ +package cli + +import ( + "fmt" + "io" + "log" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/ALT-F4-LLC/docket/internal/render" + "github.com/ALT-F4-LLC/docket/internal/tui" +) + +var openTUIDebugLog = func(path string) (io.Closer, error) { + return tea.LogToFile(path, "docket-tui") +} + +var hasInteractiveTerminal = func() bool { + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) +} + +var runTUIProgram = func(cmd *cobra.Command) error { + render.ConfigureUIOutput() + program := tea.NewProgram( + tui.NewBrowser(getDB(cmd), getCfg(cmd).DocketDir), + tea.WithAltScreen(), + ) + _, err := program.Run() + return err +} + +func newTUICommand() *cobra.Command { + return &cobra.Command{ + Use: "tui", + Short: "Browse issues in an interactive terminal UI", + Long: "Browse issues in a read-only interactive terminal UI.\n\nThis command requires an interactive terminal and does not support --json.", + RunE: func(cmd *cobra.Command, args []string) error { + if debugPath := os.Getenv("DOCKET_TUI_DEBUG_LOG"); debugPath != "" { + debugFile, err := openTUIDebugLog(debugPath) + if err != nil { + return cmdErr(fmt.Errorf("opening debug log: %w", err), output.ErrGeneral) + } + defer debugFile.Close() + log.Printf("starting docket tui debug log") + } + + jsonMode, _ := cmd.Flags().GetBool("json") + if jsonMode { + return cmdErr(fmt.Errorf("--json is not supported with 'docket tui'"), output.ErrValidation) + } + + if !hasInteractiveTerminal() { + return cmdErr(fmt.Errorf("'docket tui' requires an interactive terminal"), output.ErrValidation) + } + + if err := runTUIProgram(cmd); err != nil { + return cmdErr(fmt.Errorf("running docket tui: %w", err), output.ErrGeneral) + } + return nil + }, + } +} + +var tuiCmd = newTUICommand() + +func init() { + rootCmd.AddCommand(tuiCmd) +} diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go new file mode 100644 index 0000000..fd28563 --- /dev/null +++ b/internal/cli/tui_test.go @@ -0,0 +1,201 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/ALT-F4-LLC/docket/internal/output" +) + +func TestTUIHelpIncludesInteractiveDescription(t *testing.T) { + var buf bytes.Buffer + + root := &cobra.Command{Use: "docket"} + root.SetOut(&buf) + root.SetErr(&buf) + root.AddCommand(newTUICommand()) + root.SetArgs([]string{"tui", "--help"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + output := buf.String() + for _, fragment := range []string{"docket tui", "interactive terminal UI", "does not support --json"} { + if !strings.Contains(output, fragment) { + t.Fatalf("help output missing %q: %q", fragment, output) + } + } +} + +func TestTUIRejectsJSONFlag(t *testing.T) { + cmd := newTUICommand() + cmd.Flags().Bool("json", false, "") + if err := cmd.Flags().Set("json", "true"); err != nil { + t.Fatalf("setting json flag: %v", err) + } + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected validation error") + } + + var cmdErr *CmdError + if !errors.As(err, &cmdErr) { + t.Fatalf("error = %T, want *CmdError", err) + } + + var stdout bytes.Buffer + w := &output.Writer{JSONMode: true, Stdout: &stdout, Stderr: &bytes.Buffer{}} + if code := w.Error(cmdErr.Err, cmdErr.Code); code != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", code, output.ExitValidation) + } + + var env struct { + OK bool `json:"ok"` + Error string `json:"error"` + Code output.ErrorCode `json:"code"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.OK { + t.Fatal("ok = true, want false") + } + if env.Code != output.ErrValidation { + t.Fatalf("code = %q, want %q", env.Code, output.ErrValidation) + } + if env.Error != "--json is not supported with 'docket tui'" { + t.Fatalf("error = %q", env.Error) + } +} + +func TestTUIRequiresInteractiveTerminal(t *testing.T) { + if term.IsTerminal(int(os.Stdin.Fd())) || term.IsTerminal(int(os.Stdout.Fd())) { + t.Skip("test requires non-interactive stdio") + } + + cmd := newTUICommand() + cmd.Flags().Bool("json", false, "") + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected validation error") + } + + var cmdErr *CmdError + if !errors.As(err, &cmdErr) { + t.Fatalf("error = %T, want *CmdError", err) + } + if cmdErr.Code != output.ErrValidation { + t.Fatalf("code = %q, want %q", cmdErr.Code, output.ErrValidation) + } + if cmdErr.Error() != "'docket tui' requires an interactive terminal" { + t.Fatalf("error = %q", cmdErr.Error()) + } +} + +func TestTUIDebugLogOpenFailureReturnsGeneralError(t *testing.T) { + cmd := newTUICommand() + t.Setenv("DOCKET_TUI_DEBUG_LOG", "/definitely/missing/docket-tui.log") + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected general error") + } + + var cmdErr *CmdError + if !errors.As(err, &cmdErr) { + t.Fatalf("error = %T, want *CmdError", err) + } + if cmdErr.Code != output.ErrGeneral { + t.Fatalf("code = %q, want %q", cmdErr.Code, output.ErrGeneral) + } + if !strings.Contains(cmdErr.Error(), "opening debug log") { + t.Fatalf("error = %q", cmdErr.Error()) + } +} + +func TestTUILegacyUIDebugEnvIsIgnored(t *testing.T) { + cmd := newTUICommand() + t.Setenv("DOCKET_UI_DEBUG_LOG", "/definitely/missing/docket-ui.log") + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected validation error") + } + + var cmdErr *CmdError + if !errors.As(err, &cmdErr) { + t.Fatalf("error = %T, want *CmdError", err) + } + if cmdErr.Code != output.ErrValidation { + t.Fatalf("code = %q, want %q", cmdErr.Code, output.ErrValidation) + } + if cmdErr.Error() != "'docket tui' requires an interactive terminal" { + t.Fatalf("error = %q", cmdErr.Error()) + } +} + +func TestTUIRunsProgramWhenInteractive(t *testing.T) { + restoreTerminal := hasInteractiveTerminal + restoreRun := runTUIProgram + t.Cleanup(func() { + hasInteractiveTerminal = restoreTerminal + runTUIProgram = restoreRun + }) + + hasInteractiveTerminal = func() bool { return true } + runCalled := false + runTUIProgram = func(cmd *cobra.Command) error { + runCalled = true + return nil + } + + cmd := newTUICommand() + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + if !runCalled { + t.Fatal("expected runTUIProgram to be called") + } +} + +func TestTUIWrapsProgramError(t *testing.T) { + restoreTerminal := hasInteractiveTerminal + restoreRun := runTUIProgram + t.Cleanup(func() { + hasInteractiveTerminal = restoreTerminal + runTUIProgram = restoreRun + }) + + hasInteractiveTerminal = func() bool { return true } + runTUIProgram = func(cmd *cobra.Command) error { + return io.EOF + } + + cmd := newTUICommand() + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected general error") + } + + var cmdErr *CmdError + if !errors.As(err, &cmdErr) { + t.Fatalf("error = %T, want *CmdError", err) + } + if cmdErr.Code != output.ErrGeneral { + t.Fatalf("code = %q, want %q", cmdErr.Code, output.ErrGeneral) + } + if cmdErr.Error() != "running docket tui: EOF" { + t.Fatalf("error = %q", cmdErr.Error()) + } +} diff --git a/internal/cli/watch_commands.go b/internal/cli/watch_commands.go index 8523f98..0f8f827 100644 --- a/internal/cli/watch_commands.go +++ b/internal/cli/watch_commands.go @@ -5,19 +5,19 @@ import "github.com/spf13/cobra" // watchEligible is the set of command paths that support --watch mode. // Keys are Cobra CommandPath() values for unambiguous matching. var watchEligible = map[string]bool{ - "docket board": true, - "docket issue list": true, - "docket issue show": true, - "docket issue log": true, - "docket issue graph": true, - "docket issue comment list": true, - "docket next": true, - "docket plan": true, - "docket stats": true, - "docket config": true, - "docket vote list": true, - "docket vote show": true, - "docket vote result": true, + "docket board": true, + "docket issue list": true, + "docket issue show": true, + "docket issue log": true, + "docket issue graph": true, + "docket issue comment list": true, + "docket next": true, + "docket plan": true, + "docket stats": true, + "docket config": true, + "docket vote list": true, + "docket vote show": true, + "docket vote result": true, } func isWatchEligible(cmd *cobra.Command) bool { @@ -33,5 +33,7 @@ func hideWatchFlags(cmd *cobra.Command) { if cmd != rootCmd && !isWatchEligible(cmd) { cmd.Flags().MarkHidden("watch") cmd.Flags().MarkHidden("interval") + cmd.InheritedFlags().MarkHidden("watch") + cmd.InheritedFlags().MarkHidden("interval") } } diff --git a/internal/render/ui.go b/internal/render/ui.go new file mode 100644 index 0000000..167b88b --- /dev/null +++ b/internal/render/ui.go @@ -0,0 +1,381 @@ +package render + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +type HierarchyDecoration struct { + ChildCount int + Done int + Total int + IsEpic bool + IsChild bool +} + +type RefreshStatus struct { + Enabled bool + Pending bool + LastSuccess time.Time + LastError string + Interval time.Duration +} + +func ConfigureUIOutput() { + lipgloss.SetColorProfile(termenv.TrueColor) + lipgloss.SetHasDarkBackground(true) + if os.Getenv("GLAMOUR_STYLE") == "" { + _ = os.Setenv("GLAMOUR_STYLE", "dark") + } +} + +func JoinUIVertical(parts ...string) string { + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func JoinUIHorizontal(parts ...string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} + +func PlaceUICentered(width, height int, content string) string { + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content) +} + +func WrapUIContent(content string, width int) string { + return lipgloss.NewStyle().Width(uiMax(width, 1)).Render(content) +} + +func RenderUIDimText(text string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(text) +} + +func RenderUIErrorText(text string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(text) +} + +func RenderUIHeaderBar(projectName, viewName string, refreshStatus RefreshStatus, width int, contextParts ...string) string { + left := lipgloss.NewStyle().Bold(true).Render("docket tui") + project := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render(projectName) + mode := lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render(strings.ToUpper(viewName)) + badge := RenderUIDimText("READ-ONLY") + refresh := RenderUIDimText(formatHeaderRefreshStatus(refreshStatus)) + + parts := []string{left, project, mode, badge} + if strings.TrimSpace(projectName) == "" { + parts = []string{left, mode, badge} + } + for _, part := range contextParts { + if strings.TrimSpace(part) == "" { + continue + } + parts = append(parts, lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(part)) + } + parts = append(parts, refresh) + + line := truncateUIWidth(strings.Join(parts, " • "), uiMax(width-2, 1)) + return lipgloss.NewStyle().Width(width).Padding(0, 1).Render(line) +} + +func RenderUIFooterBar(detailExpanded, detailFocused, browseFocused bool, refreshStatus RefreshStatus, width int) string { + hints := []string{"1 list", "2 board", "j/k move", "tab pane", "enter detail", "r refresh", "p pause", "? help", "q quit"} + if detailExpanded { + hints = []string{"h/l region", "j/k move", "enter open/zoom", "u parent", "esc collapse/back", "ctrl+u/d page", refreshToggleHint(refreshStatus.Enabled), "q quit"} + } else if detailFocused { + hints = []string{"h/l region", "j/k move", "enter open/zoom", "u parent", "esc back", "ctrl+u/d page", refreshToggleHint(refreshStatus.Enabled), "q quit"} + } else if browseFocused { + hints = []string{"j/k move", "J/K detail", "o drill-down", "tab pane", "enter detail", "r refresh", refreshToggleHint(refreshStatus.Enabled), "q quit"} + } + + return renderUIFooterContent(formatFooterRefreshStatus(refreshStatus), hints, width) +} + +func RenderUIListFooterBar(detailExpanded, detailFocused, browseFocused bool, refreshStatus RefreshStatus, width int) string { + hints := []string{"1 list", "2 board", "j/k move", "s sort-field", "S sort-dir", "tab pane", "enter detail", "r refresh", refreshToggleHint(refreshStatus.Enabled), "q quit"} + if detailExpanded { + hints = []string{"h/l region", "j/k move", "enter open/zoom", "u parent", "esc collapse/back", "ctrl+u/d page", refreshToggleHint(refreshStatus.Enabled), "q quit"} + } else if detailFocused { + hints = []string{"h/l region", "j/k move", "enter open/zoom", "u parent", "esc back", "ctrl+u/d page", refreshToggleHint(refreshStatus.Enabled), "q quit"} + } else if browseFocused { + hints = []string{"j/k move", "s sort-field", "S sort-dir", "J/K detail", "o drill-down", "tab pane", "enter detail", "r refresh", refreshToggleHint(refreshStatus.Enabled), "q quit"} + } + + return renderUIFooterContent(formatFooterRefreshStatus(refreshStatus), hints, width) +} + +func renderUIFooterContent(refresh string, hints []string, width int) string { + innerWidth := uiMax(width-2, 1) + content := wrapUIFooterContent(refresh, hints, innerWidth) + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width).Padding(0, 1).Render( + lipgloss.NewStyle().Width(innerWidth).Render(content), + ) +} + +func wrapUIFooterContent(refresh string, hints []string, width int) string { + if width <= 0 { + return "" + } + + current := truncateUIWidth(refresh, width) + hasHintOnLine := false + lines := make([]string, 0, 2) + for _, hint := range hints { + segment := hint + separator := " " + if !hasHintOnLine { + separator = " • " + } + candidate := current + separator + hint + if lipgloss.Width(candidate) <= width { + current = candidate + hasHintOnLine = true + continue + } + lines = append(lines, current) + current = truncateUIWidth(segment, width) + hasHintOnLine = true + } + lines = append(lines, current) + return strings.Join(lines, "\n") +} + +func formatHeaderRefreshStatus(status RefreshStatus) string { + if status.Pending { + return "refreshing" + } + if status.LastError != "" { + return "refresh failed" + } + if !status.LastSuccess.IsZero() { + return "refreshed " + status.LastSuccess.Format("15:04:05") + } + return "not loaded" +} + +func formatFooterRefreshStatus(status RefreshStatus) string { + state := "refresh" + if status.Enabled { + state += " auto" + } else { + state += " paused" + } + if status.Interval > 0 { + state += " " + status.Interval.Round(time.Second).String() + } + if status.Pending { + return state + " updating" + } + if status.LastError != "" { + return state + " failed" + } + if !status.LastSuccess.IsZero() { + return state + " " + status.LastSuccess.Format("15:04:05") + } + return state + " not loaded" +} + +func refreshToggleHint(enabled bool) string { + if enabled { + return "p pause" + } + return "p resume" +} + +func RenderUIPane(title, content string, width, height int, focused bool) string { + borderColor := lipgloss.Color("8") + if focused { + borderColor = lipgloss.Color("12") + } + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + body := lipgloss.NewStyle().Width(uiMax(width-4, 1)).Height(uiMax(height-3, 1)).Render(content) + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1). + Width(uiMax(width-2, 1)). + Height(uiMax(height-2, 1)) + + return style.Render(titleStyle.Render(title) + "\n" + body) +} + +func RenderUIListRow(issue *model.Issue, decoration HierarchyDecoration, width int, selected bool) string { + content := renderUIIssueLine(issue, decoration, width) + + style := lipgloss.NewStyle().Width(width) + if selected { + style = style.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12")).Bold(true) + } + return style.Render(content) +} + +func RenderUIBoardColumn(status model.Status, issues []*model.Issue, decorations map[int]HierarchyDecoration, width, height int, selected, browseFocused bool, selectedRow int) string { + rowsHeight := uiMax(height-3, 1) + innerWidth := uiMax(width-4, 1) + start, end := uiWindowBounds(selectedRow, len(issues), rowsHeight) + rows := make([]string, 0, end-start) + for i := start; i < end; i++ { + row := renderUIIssueLine(issues[i], decorations[issues[i].ID], innerWidth) + style := lipgloss.NewStyle().Width(innerWidth) + if selected && i == selectedRow { + style = style.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("11")).Bold(true) + } + rows = append(rows, style.Render(row)) + } + + return RenderUIPane( + fmt.Sprintf("%s (%d)", strings.ToUpper(string(status)), len(issues)), + strings.Join(rows, "\n"), + width, + height, + selected && browseFocused, + ) +} + +func renderUIIssueLine(issue *model.Issue, decoration HierarchyDecoration, width int) string { + idLabel := fmt.Sprintf("%-7s", model.FormatID(issue.ID)) + statusLabel := fmt.Sprintf("%-7s", uiStatusLabel(issue.Status)) + priorityLabel := issue.Priority.Icon() + kindLabel := fmt.Sprintf("%-7s", uiKindLabel(issue.Kind)) + prefixWidth := lipgloss.Width(idLabel) + 1 + lipgloss.Width(statusLabel) + 1 + lipgloss.Width(priorityLabel) + 1 + lipgloss.Width(kindLabel) + 1 + title := issue.Title + if decoration.IsChild { + title = "> " + title + } + suffix := uiHierarchyLabel(decoration) + suffixWidth := 0 + if suffix != "" { + suffixWidth = lipgloss.Width(" " + suffix) + } + titleWidth := uiMax(width-prefixWidth-suffixWidth, 0) + if titleWidth == 0 { + plain := strings.TrimSpace(strings.Join([]string{idLabel, statusLabel, priorityLabel, kindLabel, suffix}, " ")) + return truncateUIWidth(plain, width) + } + content := strings.Join([]string{ + idLabel, + lipgloss.NewStyle().Foreground(ColorFromName(issue.Status.Color())).Render(statusLabel), + lipgloss.NewStyle().Foreground(ColorFromName(issue.Priority.Color())).Render(priorityLabel), + lipgloss.NewStyle().Foreground(ColorFromName(issue.Kind.Color())).Render(kindLabel), + truncateUIWidth(title, titleWidth), + }, " ") + if suffix != "" { + content += " " + RenderUIDimText(suffix) + } + return content +} + +func uiHierarchyLabel(decoration HierarchyDecoration) string { + if !decoration.IsEpic || decoration.Total == 0 { + return "" + } + return fmt.Sprintf("[%d sub %d/%d]", decoration.ChildCount, decoration.Done, decoration.Total) +} + +func uiStatusLabel(status model.Status) string { + switch status { + case model.StatusInProgress: + return "INPROG" + default: + return strings.ToUpper(string(status)) + } +} + +func uiKindLabel(kind model.IssueKind) string { + return strings.ToUpper(string(kind)) +} + +func RenderUIDetailSubIssuesHeader(doneCount, totalCount int, focused bool) string { + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + if focused { + headerStyle = headerStyle.Foreground(lipgloss.Color("11")) + } + return headerStyle.Render(fmt.Sprintf("Sub-issues (%d/%d done)", doneCount, totalCount)) +} + +func RenderUIDetailSubIssueRow(issue *model.Issue, width int, selected, focused bool) string { + prefix := fmt.Sprintf("%-7s %s %s %s ", model.FormatID(issue.ID), issue.Status.Icon(), issue.Priority.Icon(), issue.Kind.Icon()) + content := prefix + truncateUIWidth(issue.Title, uiMax(width-lipgloss.Width(prefix), 1)) + + style := lipgloss.NewStyle().Width(width) + if selected { + style = style.Bold(true) + if focused { + style = style.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("11")) + } else { + style = style.Foreground(lipgloss.Color("11")) + } + } + + return style.Render(content) +} + +func RenderUIModal(title, content string, boxWidth, boxHeight int) string { + return RenderUIPane(title, content, boxWidth, boxHeight, true) +} + +func uiWindowBounds(selected, total, height int) (int, int) { + if total == 0 { + return 0, 0 + } + if height >= total { + return 0, total + } + + start := selected - height/2 + if start < 0 { + start = 0 + } + end := start + height + if end > total { + end = total + start = end - height + } + return start, end +} + +func uiMax(a, b int) int { + if a > b { + return a + } + return b +} + +func truncateUIWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(s) <= maxWidth { + return s + } + + if maxWidth <= 3 { + var b strings.Builder + for _, r := range s { + next := b.String() + string(r) + if lipgloss.Width(next) > maxWidth { + break + } + b.WriteRune(r) + } + return b.String() + } + + const ellipsis = "..." + budget := maxWidth - lipgloss.Width(ellipsis) + var b strings.Builder + for _, r := range s { + next := b.String() + string(r) + if lipgloss.Width(next) > budget { + break + } + b.WriteRune(r) + } + return b.String() + ellipsis +} diff --git a/internal/render/ui_test.go b/internal/render/ui_test.go new file mode 100644 index 0000000..3cb254f --- /dev/null +++ b/internal/render/ui_test.go @@ -0,0 +1,365 @@ +package render + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func TestRenderUIHeaderBarIncludesContext(t *testing.T) { + rendered := RenderUIHeaderBar("docket", "list", RefreshStatus{Enabled: true, Interval: 5 * time.Second, LastSuccess: time.Date(2026, 3, 28, 12, 34, 56, 0, time.UTC)}, 96, "SORT ID DESC") + + for _, fragment := range []string{"docket tui", "docket", "LIST", "READ-ONLY", "SORT ID DESC", "12:34:56"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("header missing %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIFooterBarWrapsInsteadOfTruncatingWhenDetailFocused(t *testing.T) { + rendered := RenderUIFooterBar(false, true, false, RefreshStatus{Enabled: true, Interval: 5 * time.Second, LastSuccess: time.Date(2026, 3, 28, 12, 34, 56, 0, time.UTC)}, 90) + if !strings.Contains(rendered, "\n") { + t.Fatalf("expected wrapped footer when detail is focused at narrow widths, got %q", rendered) + } + for _, fragment := range []string{"h/l region", "enter open/zoom", "ctrl+u/d page", "p pause", "12:34:56"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected wrapped footer to include %q, got %q", fragment, rendered) + } + } +} + +func TestRenderUIPaneUsesRequestedDimensions(t *testing.T) { + rendered := RenderUIPane("Title", "body", 28, 10, false) + lines := strings.Split(rendered, "\n") + if got := len(lines); got != 10 { + t.Fatalf("pane lines = %d, want 10: %q", got, rendered) + } + for _, line := range lines { + if got := lipgloss.Width(line); got != 28 { + t.Fatalf("pane width = %d, want 28: %q", got, rendered) + } + } +} + +func TestRenderUIBoardColumnIncludesIssues(t *testing.T) { + rendered := RenderUIBoardColumn(model.StatusTodo, []*model.Issue{{ + ID: 12, + Title: "Ship UI helper refactor", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + }}, nil, 40, 8, true, true, 0) + + for _, fragment := range []string{"TODO (1)", "DKT-12", "Ship UI..."} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("board column missing %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIBoardColumnUsesSingleHeaderLineAtSmallHeights(t *testing.T) { + rendered := RenderUIBoardColumn(model.StatusTodo, []*model.Issue{{ + ID: 12, + Title: "Ship UI helper refactor", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + }}, nil, 40, 4, true, true, 0) + + if strings.Count(rendered, "TODO (1)") != 1 { + t.Fatalf("expected a single TODO column title, got %q", rendered) + } + if !strings.Contains(rendered, "TODO (1)") { + t.Fatalf("expected pane title to include issue count: %q", rendered) + } + if !strings.Contains(rendered, "DKT-12") { + t.Fatalf("expected first issue row to remain visible: %q", rendered) + } +} + +func TestRenderUIBoardColumnSelectedRowStaysSingleLine(t *testing.T) { + rendered := RenderUIBoardColumn(model.StatusTodo, []*model.Issue{{ + ID: 7, + Title: "Epic: full read-only docket ui roadmap", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + }}, nil, 28, 10, true, true, 0) + + lines := strings.Split(rendered, "\n") + if len(lines) < 4 { + t.Fatalf("expected board column output, got %q", rendered) + } + if !strings.Contains(lines[2], "DKT-7") { + t.Fatalf("expected selected issue row on a single line, got %q", rendered) + } + if strings.Contains(lines[3], "...") || strings.Contains(lines[3], "roadmap") || strings.Contains(lines[3], "read-only") { + t.Fatalf("expected title truncation instead of wrapped continuation line, got %q", rendered) + } +} + +func TestRenderUIListRowKeepsMetadataSingleLineAtConstrainedWidth(t *testing.T) { + rendered := RenderUIListRow(&model.Issue{ + ID: 14, + Title: "Render richer list metadata without wrapping rows", + Status: model.StatusInProgress, + Priority: model.PriorityHigh, + Kind: model.IssueKindFeature, + }, HierarchyDecoration{}, 34, true) + + if strings.Contains(rendered, "\n") { + t.Fatalf("expected single-line list row, got %q", rendered) + } + for _, fragment := range []string{"DKT-14", "INPROG", "↑", "FEATURE"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected list row to include %q: %q", fragment, rendered) + } + } + if got := lipgloss.Width(rendered); got != 34 { + t.Fatalf("list row width = %d, want 34: %q", got, rendered) + } +} + +func TestRenderUIListRowColorizesBrowseMetadata(t *testing.T) { + ConfigureUIOutput() + rendered := RenderUIListRow(&model.Issue{ + ID: 18, + Title: "Colorize list metadata for faster scanning", + Status: model.StatusReview, + Priority: model.PriorityCritical, + Kind: model.IssueKindBug, + }, HierarchyDecoration{}, 72, false) + + if !strings.Contains(rendered, "\x1b[") { + t.Fatalf("expected ANSI styling in unselected list row, got %q", rendered) + } + for _, fragment := range []string{"REVIEW", "⏫", "BUG"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected colored list row to include %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIBoardColumnIncludesStatusAndKindMetadata(t *testing.T) { + rendered := RenderUIBoardColumn(model.StatusTodo, []*model.Issue{{ + ID: 7, + Title: "Board rows keep status and kind metadata visible", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }}, nil, 38, 8, true, true, 0) + + for _, fragment := range []string{"DKT-7", "TODO", "↑", "EPIC"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected board row to include %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIListRowIncludesHierarchyDecoration(t *testing.T) { + rendered := RenderUIListRow(&model.Issue{ + ID: 21, + Title: "Epic row keeps hierarchy progress visible", + Status: model.StatusInProgress, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }, HierarchyDecoration{IsEpic: true, ChildCount: 3, Done: 1, Total: 3}, 64, false) + + for _, fragment := range []string{"DKT-21", "EPIC", "[3 sub 1/3]"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected list row to include %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIBoardColumnIncludesHierarchyDecoration(t *testing.T) { + rendered := RenderUIBoardColumn(model.StatusTodo, []*model.Issue{{ + ID: 8, + Title: "Epic card keeps hierarchy summary visible", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }}, map[int]HierarchyDecoration{8: {IsEpic: true, ChildCount: 2, Done: 1, Total: 2}}, 48, 8, true, true, 0) + + for _, fragment := range []string{"DKT-8", "EPIC", "[2 sub 1/2]"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected board row to include %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIDetailSubIssueRowIncludesIssueFields(t *testing.T) { + rendered := RenderUIDetailSubIssueRow(&model.Issue{ + ID: 7, + Title: "Nested issue title", + Status: model.StatusReview, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }, 50, true, true) + + for _, fragment := range []string{"DKT-7", "Nested issue title"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("sub-issue row missing %q: %q", fragment, rendered) + } + } +} + +func TestConfigureUIOutputSetsDefaultGlamourStyle(t *testing.T) { + t.Setenv("GLAMOUR_STYLE", "") + ConfigureUIOutput() + if got := os.Getenv("GLAMOUR_STYLE"); got != "dark" { + t.Fatalf("GLAMOUR_STYLE = %q, want dark", got) + } +} + +func TestJoinAndWrapHelpersPreserveContent(t *testing.T) { + vertical := JoinUIVertical("one", "two") + if !strings.Contains(vertical, "one\ntwo") { + t.Fatalf("expected vertical join to stack content, got %q", vertical) + } + + horizontal := JoinUIHorizontal("one", "two") + if !strings.Contains(horizontal, "onetwo") { + t.Fatalf("expected horizontal join to keep both fragments, got %q", horizontal) + } + + wrapped := WrapUIContent("body", 8) + if got := lipgloss.Width(wrapped); got != 8 { + t.Fatalf("wrapped width = %d, want 8: %q", got, wrapped) + } +} + +func TestRenderUITextHelpersPreserveText(t *testing.T) { + if !strings.Contains(RenderUIDimText("dim text"), "dim text") { + t.Fatalf("expected dim text helper to keep content") + } + if !strings.Contains(RenderUIErrorText("error text"), "error text") { + t.Fatalf("expected error text helper to keep content") + } +} + +func TestRenderUIFooterBarHandlesRefreshStates(t *testing.T) { + notLoaded := RenderUIFooterBar(false, false, true, RefreshStatus{Enabled: true, Interval: 5 * time.Second}, 90) + if !strings.Contains(notLoaded, "refresh auto 5s not loaded") { + t.Fatalf("expected not-loaded refresh state, got %q", notLoaded) + } + + expanded := RenderUIFooterBar(true, false, false, RefreshStatus{Enabled: false, Interval: 5 * time.Second, LastSuccess: time.Date(2026, 3, 28, 12, 34, 56, 0, time.UTC)}, 180) + for _, fragment := range []string{"refresh paused 5s 12:34:56", "p resume"} { + if !strings.Contains(expanded, fragment) { + t.Fatalf("footer missing %q: %q", fragment, expanded) + } + } +} + +func TestRenderUIListFooterBarIncludesSortHints(t *testing.T) { + rendered := RenderUIListFooterBar(false, false, true, RefreshStatus{Enabled: true, Interval: 5 * time.Second}, 160) + for _, fragment := range []string{"s sort-field", "S sort-dir", "refresh auto 5s not loaded"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected list footer to include %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIListFooterBarWrapsInsteadOfTruncatingAtNarrowWidths(t *testing.T) { + rendered := RenderUIListFooterBar(false, false, true, RefreshStatus{Enabled: true, Interval: 5 * time.Second}, 78) + if !strings.Contains(rendered, "\n") { + t.Fatalf("expected wrapped footer at narrow widths, got %q", rendered) + } + for _, fragment := range []string{"refresh auto 5s not loaded", "s sort-field", "S sort-dir", "J/K detail", "o drill-down", "q quit"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected wrapped list footer to include %q: %q", fragment, rendered) + } + } +} + +func TestRenderUIHeaderBarShowsRefreshFailure(t *testing.T) { + rendered := RenderUIHeaderBar("docket", "board", RefreshStatus{Enabled: true, Interval: 5 * time.Second, LastError: "boom"}, 80) + if !strings.Contains(rendered, "refresh failed") { + t.Fatalf("expected refresh failure in header, got %q", rendered) + } +} + +func TestRenderUIDetailSubIssuesHeaderHighlightsFocus(t *testing.T) { + rendered := RenderUIDetailSubIssuesHeader(2, 3, true) + if !strings.Contains(rendered, "Sub-issues (2/3 done)") { + t.Fatalf("expected sub-issue summary, got %q", rendered) + } +} + +func TestRenderUIModalUsesRequestedDimensions(t *testing.T) { + rendered := RenderUIModal("Help", "body", 28, 8) + lines := strings.Split(rendered, "\n") + if got := len(lines); got != 8 { + t.Fatalf("modal lines = %d, want 8: %q", got, rendered) + } + for _, line := range lines { + if got := lipgloss.Width(line); got != 28 { + t.Fatalf("modal width = %d, want 28: %q", got, rendered) + } + } +} + +func TestPlaceUICenteredReturnsSizedCanvas(t *testing.T) { + rendered := PlaceUICentered(20, 4, "x") + lines := strings.Split(rendered, "\n") + if got := len(lines); got != 4 { + t.Fatalf("centered lines = %d, want 4: %q", got, rendered) + } + for _, line := range lines { + if got := lipgloss.Width(line); got != 20 { + t.Fatalf("centered width = %d, want 20: %q", got, rendered) + } + } +} + +func TestUIWindowBoundsAndMaxHelpers(t *testing.T) { + if start, end := uiWindowBounds(0, 0, 5); start != 0 || end != 0 { + t.Fatalf("empty bounds = %d,%d, want 0,0", start, end) + } + if start, end := uiWindowBounds(1, 3, 5); start != 0 || end != 3 { + t.Fatalf("full bounds = %d,%d, want 0,3", start, end) + } + if start, end := uiWindowBounds(4, 10, 4); start != 2 || end != 6 { + t.Fatalf("middle bounds = %d,%d, want 2,6", start, end) + } + if got := uiMax(2, 5); got != 5 { + t.Fatalf("uiMax = %d, want 5", got) + } +} + +func TestTruncateUIWidthHandlesEdgeCases(t *testing.T) { + if got := truncateUIWidth("hello", 0); got != "" { + t.Fatalf("truncate width 0 = %q, want empty", got) + } + if got := truncateUIWidth("hello", 5); got != "hello" { + t.Fatalf("truncate exact width = %q, want hello", got) + } + if got := truncateUIWidth("hello", 3); got != "hel" { + t.Fatalf("truncate narrow width = %q, want hel", got) + } + if got := truncateUIWidth("hello world", 8); got != "hello..." { + t.Fatalf("truncate ellipsis width = %q, want hello...", got) + } +} + +func TestIssueMetadataLabelsUseExpectedShortForms(t *testing.T) { + if got := uiStatusLabel(model.StatusInProgress); got != "INPROG" { + t.Fatalf("in-progress label = %q, want INPROG", got) + } + if got := uiKindLabel(model.IssueKindFeature); got != "FEATURE" { + t.Fatalf("feature label = %q, want FEATURE", got) + } + line := renderUIIssueLine(&model.Issue{ + ID: 4, + Title: "Short", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }, HierarchyDecoration{}, 12) + if strings.Contains(line, "\n") { + t.Fatalf("expected single-line issue metadata, got %q", line) + } +} diff --git a/internal/tui/browser.go b/internal/tui/browser.go new file mode 100644 index 0000000..727d87c --- /dev/null +++ b/internal/tui/browser.go @@ -0,0 +1,7 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +func (m browserModel) Init() tea.Cmd { + return loadViewCmd(m.conn, m.view, m.listSort, m.viewRequestID) +} diff --git a/internal/tui/browser_load.go b/internal/tui/browser_load.go new file mode 100644 index 0000000..fc14986 --- /dev/null +++ b/internal/tui/browser_load.go @@ -0,0 +1,118 @@ +package tui + +import ( + "database/sql" + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ALT-F4-LLC/docket/internal/app" +) + +var ( + listIssues = app.ListIssues + loadBoard = app.LoadBoard + getIssueDetail = app.GetIssueDetail +) + +func (m *browserModel) beginViewLoad() tea.Cmd { + m.loading = true + m.viewRequestID++ + m.refreshState.Pending = true + return loadViewCmd(m.conn, m.view, m.listSort, m.viewRequestID) +} + +func (m *browserModel) beginDetailLoad() tea.Cmd { + targetID := m.currentDetailTargetID() + if targetID == 0 { + m.loadingDetail = false + m.detailIssueID = 0 + m.detailData = app.IssueDetailData{} + return nil + } + m.loadingDetail = true + m.detailErr = "" + m.detailRequestID++ + return loadDetailCmd(m.conn, targetID, m.detailRequestID) +} + +func (m *browserModel) nextRefreshCmd() tea.Cmd { + if !m.refreshPolicy.Enabled || m.refreshPolicy.Interval <= 0 { + return nil + } + m.refreshTickID++ + return refreshTickCmd(m.refreshPolicy.Interval, m.refreshTickID) +} + +func loadViewCmd(conn *sql.DB, view viewMode, listSort sortMode, requestID int) tea.Cmd { + debugEventf("refresh_started", "view=%s sort_field=%s sort_dir=%s request_id=%d", view, listSort.Field, listSort.Dir, requestID) + switch view { + case viewBoard: + return loadBoardCmd(conn, requestID) + default: + return loadListCmd(conn, listSort, requestID) + } +} + +func loadListCmd(conn *sql.DB, listSort sortMode, requestID int) tea.Cmd { + return func() tea.Msg { + debugEventf("refresh_query_list", "sort_field=%s sort_dir=%s request_id=%d", listSort.Field, listSort.Dir, requestID) + data, err := listIssues(conn, app.ListIssuesParams{ + Sort: listSort.Field, + SortDir: listSort.Dir, + Limit: defaultListLimit, + }) + return listLoadedMsg{view: viewList, requestID: requestID, data: data, err: err} + } +} + +func loadBoardCmd(conn *sql.DB, requestID int) tea.Cmd { + return func() tea.Msg { + debugEventf("refresh_query_board", "request_id=%d", requestID) + data, err := loadBoard(conn, app.BoardParams{}) + return boardLoadedMsg{view: viewBoard, requestID: requestID, data: data, err: err} + } +} + +func loadDetailCmd(conn *sql.DB, id int, requestID int) tea.Cmd { + return func() tea.Msg { + debugEventf("detail_query", "request_id=%d issue_id=%d", requestID, id) + data, err := getIssueDetail(conn, id) + return detailLoadedMsg{requestID: requestID, id: id, data: data, err: err} + } +} + +func debugLogf(format string, args ...any) { + path := os.Getenv("DOCKET_TUI_DEBUG_LOG") + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return + } + defer f.Close() + stamp := time.Now().Format("2006-01-02 15:04:05.000") + _, _ = fmt.Fprintf(f, "%s %s\n", stamp, fmt.Sprintf(format, args...)) +} + +func debugEventf(event string, format string, args ...any) { + message := strings.TrimSpace(fmt.Sprintf(format, args...)) + if message == "" { + debugLogf("event=%s", event) + return + } + debugLogf("event=%s %s", event, message) +} + +func refreshTickCmd(interval time.Duration, tickID int) tea.Cmd { + if interval <= 0 { + return nil + } + return tea.Tick(interval, func(time.Time) tea.Msg { + return refreshTickMsg{tickID: tickID} + }) +} diff --git a/internal/tui/browser_navigation.go b/internal/tui/browser_navigation.go new file mode 100644 index 0000000..7b1b56e --- /dev/null +++ b/internal/tui/browser_navigation.go @@ -0,0 +1,266 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +func (m *browserModel) resetDetailNavigationToBrowseSelection() { + m.detailTargetID = m.selectedIssueID + m.detailScroll = 0 + m.detailFocus = detailFocusBody + m.detailSubIndex = 0 + m.detailHistory = nil + m.pendingDetailFocus = "" +} + +func (m *browserModel) normalizeDetailState() { + if len(m.detailData.SubIssues) == 0 { + m.detailFocus = detailFocusBody + m.detailSubIndex = 0 + return + } + m.detailSubIndex = clamp(m.detailSubIndex, 0, len(m.detailData.SubIssues)-1) +} + +func (m *browserModel) navigateDetailToIssue(id int, pushCurrent bool) tea.Cmd { + currentID := m.currentDetailTargetID() + if id == 0 || id == currentID { + return nil + } + if pushCurrent && currentID != 0 { + m.detailHistory = append(m.detailHistory, detailNavState{ + issueID: currentID, + scroll: m.detailScroll, + subIssueIndex: m.detailSubIndex, + focusRegion: m.detailFocus, + }) + } + m.detailTargetID = id + m.detailScroll = 0 + m.detailFocus = detailFocusBody + m.detailSubIndex = 0 + m.pendingDetailFocus = "" + return m.beginDetailLoad() +} + +func (m *browserModel) navigateDetailBack() (tea.Cmd, bool) { + if len(m.detailHistory) == 0 { + return nil, false + } + prev := m.detailHistory[len(m.detailHistory)-1] + m.detailHistory = m.detailHistory[:len(m.detailHistory)-1] + m.detailTargetID = prev.issueID + m.detailScroll = prev.scroll + m.detailSubIndex = prev.subIssueIndex + m.detailFocus = prev.focusRegion + return m.beginDetailLoad(), true +} + +func (m *browserModel) navigateDetailParentOrBack() (tea.Cmd, bool) { + if cmd, ok := m.navigateDetailBack(); ok { + return cmd, true + } + if m.detailData.Issue == nil || m.detailData.Issue.ParentID == nil { + return nil, false + } + return m.navigateDetailToIssue(*m.detailData.Issue.ParentID, true), true +} + +func (m *browserModel) openSelectedSubIssue() (tea.Cmd, bool) { + if len(m.detailData.SubIssues) == 0 { + return nil, false + } + child := m.detailData.SubIssues[clamp(m.detailSubIndex, 0, len(m.detailData.SubIssues)-1)] + return m.navigateDetailToIssue(child.ID, true), true +} + +func (m browserModel) selectedIssueHasHierarchy() bool { + if issue := m.selectedIssue(); issue == nil { + return false + } + if m.detailIssueID == m.selectedIssueID && m.currentDetailTargetID() == m.selectedIssueID && len(m.detailData.SubIssues) > 0 { + return true + } + switch m.view { + case viewBoard: + prog, ok := m.boardData.Progress[m.selectedIssueID] + return ok && prog.Total > 0 + default: + prog, ok := m.listData.Progress[m.selectedIssueID] + return ok && prog.Total > 0 + } +} + +func (m *browserModel) enterSelectedIssueHierarchy() tea.Cmd { + if !m.selectedIssueHasHierarchy() || m.selectedIssueID == 0 { + return nil + } + m.focus = focusDetail + m.pendingDetailFocus = detailFocusSubIssues + if m.currentDetailTargetID() != m.selectedIssueID { + m.resetDetailNavigationToBrowseSelection() + m.focus = focusDetail + m.pendingDetailFocus = detailFocusSubIssues + return m.beginDetailLoad() + } + if m.loadingDetail || m.detailIssueID != m.selectedIssueID { + return nil + } + m.detailFocus = detailFocusSubIssues + m.pendingDetailFocus = "" + return nil +} + +func (m *browserModel) reconcileListSelection() { + previousID := m.selectedIssueID + issues := m.listData.Issues + if len(issues) == 0 { + m.selectedIssueID = 0 + m.listIndex = 0 + m.detailIssueID = 0 + m.detailScroll = 0 + debugEventf("selection_reconciled", "view=list selected_issue_id=0") + return + } + + if idx := findIssueIndex(issues, m.selectedIssueID); idx >= 0 { + m.listIndex = idx + m.selectedIssueID = issues[idx].ID + debugEventf("selection_reconciled", "view=list selected_issue_id=%d list_index=%d", m.selectedIssueID, m.listIndex) + m.reconcileDetailTarget(previousID) + return + } + + m.listIndex = 0 + m.selectedIssueID = issues[0].ID + if m.focus == focusDetail { + m.focus = focusBrowse + } + debugEventf("selection_reconciled", "view=list selected_issue_id=%d list_index=%d", m.selectedIssueID, m.listIndex) + m.reconcileDetailTarget(previousID) +} + +func (m *browserModel) reconcileBoardSelection() { + previousID := m.selectedIssueID + if len(m.boardColumns) == 0 { + m.selectedIssueID = 0 + m.boardColumnIdx = 0 + m.boardRowIdx = 0 + m.detailIssueID = 0 + m.detailScroll = 0 + debugEventf("selection_reconciled", "view=board selected_issue_id=0") + return + } + + if colIdx, rowIdx := findBoardIssue(m.boardColumns, m.selectedIssueID); colIdx >= 0 { + m.boardColumnIdx = colIdx + m.boardRowIdx = rowIdx + m.selectedIssueID = m.boardColumns[colIdx].Issues[rowIdx].ID + debugEventf("selection_reconciled", "view=board selected_issue_id=%d column_index=%d row_index=%d", m.selectedIssueID, m.boardColumnIdx, m.boardRowIdx) + m.reconcileDetailTarget(previousID) + return + } + + m.boardColumnIdx = 0 + m.boardRowIdx = 0 + m.selectedIssueID = m.boardColumns[0].Issues[0].ID + if m.focus == focusDetail { + m.focus = focusBrowse + } + debugEventf("selection_reconciled", "view=board selected_issue_id=%d column_index=%d row_index=%d", m.selectedIssueID, m.boardColumnIdx, m.boardRowIdx) + m.reconcileDetailTarget(previousID) +} + +func (m *browserModel) reconcileDetailTarget(previousSelectionID int) { + if m.selectedIssueID == 0 { + m.resetDetailNavigationToBrowseSelection() + return + } + if len(m.detailHistory) > 0 { + return + } + if m.detailTargetID == 0 { + m.detailTargetID = m.selectedIssueID + return + } + if m.detailTargetID != previousSelectionID { + return + } + if m.selectedIssueID == previousSelectionID { + return + } + m.resetDetailNavigationToBrowseSelection() +} + +func (m *browserModel) moveListSelection(delta int) bool { + issues := m.listData.Issues + if len(issues) == 0 { + return false + } + next := clamp(m.listIndex+delta, 0, len(issues)-1) + if next == m.listIndex { + return false + } + m.listIndex = next + m.selectedIssueID = issues[next].ID + m.detailScroll = 0 + return true +} + +func (m *browserModel) moveBoardColumn(delta int) bool { + if len(m.boardColumns) == 0 { + return false + } + next := clamp(m.boardColumnIdx+delta, 0, len(m.boardColumns)-1) + if next == m.boardColumnIdx { + return false + } + m.boardColumnIdx = next + col := m.boardColumns[next] + m.boardRowIdx = clamp(m.boardRowIdx, 0, len(col.Issues)-1) + m.selectedIssueID = col.Issues[m.boardRowIdx].ID + m.detailScroll = 0 + return true +} + +func (m *browserModel) moveBoardRow(delta int) bool { + if len(m.boardColumns) == 0 { + return false + } + col := m.boardColumns[m.boardColumnIdx] + next := clamp(m.boardRowIdx+delta, 0, len(col.Issues)-1) + if next == m.boardRowIdx { + return false + } + m.boardRowIdx = next + m.selectedIssueID = col.Issues[next].ID + m.detailScroll = 0 + return true +} + +func (m *browserModel) moveDetailSubIssueSelection(delta int) bool { + if len(m.detailData.SubIssues) == 0 { + return false + } + next := clamp(m.detailSubIndex+delta, 0, len(m.detailData.SubIssues)-1) + if next == m.detailSubIndex { + return false + } + m.detailSubIndex = next + return true +} + +func (m *browserModel) proxyDetailMovement(delta int) bool { + if m.loadingDetail || m.detailErr != "" || m.detailIssueID != m.currentDetailTargetID() { + return false + } + if m.detailFocus == detailFocusSubIssues { + return m.moveDetailSubIssueSelection(delta) + } + bodyVisibleLines, _ := m.detailContentHeights() + maxScroll := max(m.maxDetailScroll(bodyVisibleLines), 0) + next := clamp(m.detailScroll+delta, 0, maxScroll) + if next == m.detailScroll { + return false + } + m.detailScroll = next + return true +} diff --git a/internal/tui/browser_render.go b/internal/tui/browser_render.go new file mode 100644 index 0000000..1ff3c80 --- /dev/null +++ b/internal/tui/browser_render.go @@ -0,0 +1,357 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/render" +) + +func (m browserModel) View() string { + if m.width == 0 || m.height == 0 { + return "Loading docket tui..." + } + + if m.showHelp { + return m.renderHelp() + } + + header := m.renderHeader() + footer := m.renderFooter() + contentHeight := max(m.height-uiLineCount(header)-uiLineCount(footer), 1) + content := m.renderContent(contentHeight) + + return render.JoinUIVertical(header, content, footer) +} + +func uiLineCount(content string) int { + if content == "" { + return 0 + } + return strings.Count(content, "\n") + 1 +} + +func (m browserModel) renderHeader() string { + context := []string{} + if m.view == viewList { + context = append(context, fmt.Sprintf("SORT %s %s", strings.ToUpper(m.listSort.Field), strings.ToUpper(m.listSort.Dir))) + } + return render.RenderUIHeaderBar(m.projectName, string(m.view), m.refreshStatusValue(), m.width, context...) +} + +func (m browserModel) renderFooter() string { + if m.view == viewList { + return render.RenderUIListFooterBar(m.detailExpanded, m.focus == focusDetail, m.focus == focusBrowse && !m.detailExpanded, m.refreshStatusValue(), m.width) + } + return render.RenderUIFooterBar(m.detailExpanded, m.focus == focusDetail, m.focus == focusBrowse && !m.detailExpanded, m.refreshStatusValue(), m.width) +} + +func (m browserModel) renderContent(contentHeight int) string { + if m.detailExpanded { + return m.renderExpandedDetail(contentHeight) + } + + browseWidth, detailWidth, stacked := m.paneWidths() + if stacked { + if contentHeight < minimumStackedContentHeight { + if m.focus == focusDetail { + return m.renderDetailPane(detailWidth, contentHeight) + } + return m.renderBrowsePane(browseWidth, contentHeight) + } + browseHeight, detailHeight := stackedPaneHeights(contentHeight) + if detailHeight == 0 { + if m.focus == focusDetail { + return m.renderDetailPane(detailWidth, browseHeight) + } + return m.renderBrowsePane(browseWidth, browseHeight) + } + browse := m.renderBrowsePane(browseWidth, browseHeight) + detail := m.renderDetailPane(detailWidth, detailHeight) + return render.JoinUIVertical(browse, detail) + } + + browse := m.renderBrowsePane(browseWidth, contentHeight) + detail := m.renderDetailPane(detailWidth, contentHeight) + return render.JoinUIHorizontal(browse, detail) +} + +func (m browserModel) renderExpandedDetail(contentHeight int) string { + return m.renderDetailPane(m.width, contentHeight) +} + +func (m browserModel) renderBrowsePane(width, height int) string { + title := fmt.Sprintf("Issues · %s", strings.ToUpper(string(m.view))) + if m.view == viewList && m.listData.Total > 0 { + title = fmt.Sprintf("Issues · %s · %d/%d", strings.ToUpper(string(m.view)), len(m.listData.Issues), m.listData.Total) + } + content := m.renderBrowseContent(max(width-4, 1), max(height-3, 1)) + return render.RenderUIPane(title, content, width, height, m.focus == focusBrowse && !m.detailExpanded) +} + +func (m browserModel) renderDetailPane(width, height int) string { + title := "Detail" + if detailID := m.currentDetailTargetID(); detailID != 0 { + title = fmt.Sprintf("Detail · %s", model.FormatID(detailID)) + if m.focus == focusDetail || m.detailExpanded { + title += " · " + strings.ToUpper(string(m.detailFocus)) + } + } + content := m.renderDetailContent(max(width-4, 1), max(height-3, 1)) + return render.RenderUIPane(title, content, width, height, m.focus == focusDetail || m.detailExpanded) +} + +func (m browserModel) renderBrowseContent(width, height int) string { + if m.loading { + return render.RenderUIDimText("Loading issues...") + } + if m.errMsg != "" { + return render.RenderUIErrorText(m.errMsg) + } + + switch m.view { + case viewBoard: + return m.renderBoard(width, height) + default: + return m.renderList(width, height) + } +} + +func (m browserModel) renderList(width, height int) string { + issues := m.listData.Issues + if len(issues) == 0 { + return render.RenderUIDimText("No issues found.") + } + + start, end := windowBounds(m.listIndex, len(issues), height) + rows := make([]string, 0, end-start) + for i := start; i < end; i++ { + rows = append(rows, render.RenderUIListRow(issues[i], m.listHierarchyDecoration(issues[i]), width, i == m.listIndex)) + } + return strings.Join(rows, "\n") +} + +func (m browserModel) renderBoard(width, height int) string { + if len(m.boardColumns) == 0 { + return render.RenderUIDimText("No issues on the board.") + } + + colWidth := max((width-(len(m.boardColumns)-1))/len(m.boardColumns), 18) + cols := make([]string, 0, len(m.boardColumns)) + decorations := m.boardHierarchyDecorations() + for idx, col := range m.boardColumns { + selected := idx == m.boardColumnIdx + cols = append(cols, render.RenderUIBoardColumn(col.Status, col.Issues, decorations, colWidth, height, selected, m.focus == focusBrowse, m.boardRowIdx)) + } + return render.JoinUIHorizontal(cols...) +} + +func (m browserModel) renderDetailContent(width, height int) string { + if m.currentDetailTargetID() == 0 { + return render.RenderUIDimText("Select an issue to inspect it.") + } + if m.loadingDetail || m.detailIssueID != m.currentDetailTargetID() { + return render.RenderUIDimText("Loading issue detail...") + } + if m.detailErr != "" { + return render.RenderUIErrorText(m.detailErr) + } + + bodyHeight, subIssueHeight := m.detailContentHeightsForHeight(height) + body := m.renderDetailBody(width, bodyHeight) + if subIssueHeight == 0 || len(m.detailData.SubIssues) == 0 { + return body + } + + subIssues := m.renderDetailSubIssues(width, subIssueHeight) + return strings.Join([]string{body, "", subIssues}, "\n") +} + +func (m browserModel) renderDetailBody(width, height int) string { + detail := render.RenderDetail( + m.detailData.Issue, + nil, + m.detailData.Relations, + m.detailData.Comments, + m.detailData.Activity, + ) + wrapped := render.WrapUIContent(detail, width) + lines := strings.Split(wrapped, "\n") + start, end := detailWindow(m.detailScroll, len(lines), height) + visible := lines[start:end] + if len(visible) == 0 { + return "" + } + return strings.Join(visible, "\n") +} + +func (m browserModel) renderDetailSubIssues(width, height int) string { + doneCount := 0 + for _, subIssue := range m.detailData.SubIssues { + if subIssue.Status == model.StatusDone { + doneCount++ + } + } + + header := render.RenderUIDetailSubIssuesHeader(doneCount, len(m.detailData.SubIssues), m.detailFocus == detailFocusSubIssues && (m.focus == focusDetail || m.detailExpanded)) + rowHeight := max(height-1, 1) + start, end := windowBounds(m.detailSubIndex, len(m.detailData.SubIssues), rowHeight) + rows := []string{header} + for i := start; i < end; i++ { + rows = append(rows, render.RenderUIDetailSubIssueRow(m.detailData.SubIssues[i], width, i == m.detailSubIndex, m.detailFocus == detailFocusSubIssues && (m.focus == focusDetail || m.detailExpanded))) + } + return strings.Join(rows, "\n") +} + +func (m browserModel) renderHelp() string { + content := strings.Join([]string{ + "docket tui", + "", + "1 list view", + "2 board view", + "s cycle list sort field", + "S toggle list sort direction", + "j / k move in focused pane", + "J / K move detail from browse pane", + "o drill into selected epic", + "h / l switch board column or detail region", + "tab switch browse/detail pane", + "enter expand detail or open selected sub-issue", + "u go to parent / back", + "ctrl+u/d half-page in focused detail region", + "esc collapse expanded detail, then back out", + "r refresh current view", + "p pause/resume auto-refresh", + "? toggle help", + "q quit", + "", + "This preview is read-only.", + }, "\n") + + box := render.RenderUIModal("Help", content, min(max(m.width-8, 40), 72), min(max(m.height-6, 14), 20)) + return render.PlaceUICentered(m.width, m.height, box) +} + +func (m browserModel) refreshStatusValue() render.RefreshStatus { + return render.RefreshStatus{ + Enabled: m.refreshPolicy.Enabled, + Pending: m.refreshState.Pending, + LastSuccess: m.refreshState.LastSuccess, + LastError: m.refreshState.LastError, + Interval: m.refreshPolicy.Interval, + } +} + +func (m browserModel) paneWidths() (browseWidth, detailWidth int, stacked bool) { + if m.width < 110 { + return m.width, m.width, true + } + browseWidth = max(int(float64(m.width)*0.44), 36) + detailWidth = max(m.width-browseWidth, 32) + return browseWidth, detailWidth, false +} + +func (m browserModel) detailPaneDimensions() (width, height int) { + contentHeight := m.availableContentHeight() + if m.detailExpanded { + return m.width, contentHeight + } + _, detailWidth, stacked := m.paneWidths() + if stacked { + if contentHeight < minimumStackedContentHeight { + if m.focus == focusDetail { + return detailWidth, contentHeight + } + return detailWidth, 0 + } + _, detailHeight := stackedPaneHeights(contentHeight) + return detailWidth, detailHeight + } + return detailWidth, contentHeight +} + +func (m browserModel) availableContentHeight() int { + return max(m.height-uiLineCount(m.renderHeader())-uiLineCount(m.renderFooter()), 1) +} + +func stackedPaneHeights(contentHeight int) (browseHeight, detailHeight int) { + if contentHeight < minimumStackedContentHeight { + return max(contentHeight, 1), 0 + } + if contentHeight <= 1 { + return max(contentHeight, 1), 0 + } + if contentHeight < 12 { + browseHeight = (contentHeight + 1) / 2 + return browseHeight, contentHeight - browseHeight + } + browseHeight = max(contentHeight/2, 6) + return browseHeight, contentHeight - browseHeight +} + +const minimumStackedContentHeight = 8 + +func (m browserModel) maxDetailScroll(visibleLines int) int { + width, _ := m.detailPaneDimensions() + innerWidth := max(width-4, 1) + if m.detailIssueID == 0 || m.detailIssueID != m.currentDetailTargetID() || m.detailErr != "" { + return 0 + } + detail := render.RenderDetail( + m.detailData.Issue, + nil, + m.detailData.Relations, + m.detailData.Comments, + m.detailData.Activity, + ) + wrapped := render.WrapUIContent(detail, innerWidth) + lines := strings.Split(wrapped, "\n") + return max(len(lines)-visibleLines, 0) +} + +func (m browserModel) detailContentHeights() (bodyHeight, subIssueHeight int) { + _, detailHeight := m.detailPaneDimensions() + return m.detailContentHeightsForHeight(max(detailHeight-4, 1)) +} + +func (m browserModel) detailContentHeightsForHeight(height int) (bodyHeight, subIssueHeight int) { + bodyHeight = max(height, 1) + if len(m.detailData.SubIssues) == 0 || height < 6 { + return bodyHeight, 0 + } + maxSubIssueHeight := max(height/3, 4) + subIssueHeight = min(len(m.detailData.SubIssues)+1, maxSubIssueHeight) + bodyHeight = max(height-subIssueHeight-1, 2) + return bodyHeight, subIssueHeight +} + +func (m browserModel) listHierarchyDecoration(issue *model.Issue) render.HierarchyDecoration { + decoration := render.HierarchyDecoration{ + IsEpic: issue.Kind == model.IssueKindEpic, + IsChild: issue.ParentID != nil, + } + if prog, ok := m.listData.Progress[issue.ID]; ok { + decoration.ChildCount = prog.Total + decoration.Done = prog.Done + decoration.Total = prog.Total + } + return decoration +} + +func (m browserModel) boardHierarchyDecorations() map[int]render.HierarchyDecoration { + decorations := make(map[int]render.HierarchyDecoration, len(m.boardData.Issues)) + for _, issue := range m.boardData.Issues { + decoration := render.HierarchyDecoration{ + IsEpic: issue.Kind == model.IssueKindEpic, + IsChild: issue.ParentID != nil, + } + if prog, ok := m.boardData.Progress[issue.ID]; ok { + decoration.ChildCount = prog.Total + decoration.Done = prog.Done + decoration.Total = prog.Total + } + decorations[issue.ID] = decoration + } + return decorations +} diff --git a/internal/tui/browser_state.go b/internal/tui/browser_state.go new file mode 100644 index 0000000..caf190c --- /dev/null +++ b/internal/tui/browser_state.go @@ -0,0 +1,179 @@ +package tui + +import ( + "database/sql" + "path/filepath" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ALT-F4-LLC/docket/internal/app" + "github.com/ALT-F4-LLC/docket/internal/model" +) + +type viewMode string + +const ( + viewList viewMode = "list" + viewBoard viewMode = "board" +) + +type paneFocus string + +const ( + focusBrowse paneFocus = "browse" + focusDetail paneFocus = "detail" +) + +type detailFocusRegion string + +const ( + detailFocusBody detailFocusRegion = "body" + detailFocusSubIssues detailFocusRegion = "subissues" +) + +const defaultListLimit = 50 + +type sortMode struct { + Field string + Dir string +} + +var defaultListSort = sortMode{Field: "id", Dir: "desc"} + +type refreshPolicy struct { + Enabled bool + Interval time.Duration +} + +type refreshState struct { + Pending bool + LastSuccess time.Time + LastError string + LoadedOnce bool +} + +const defaultRefreshInterval = 5 * time.Second + +type detailNavState struct { + issueID int + scroll int + subIssueIndex int + focusRegion detailFocusRegion +} + +type boardColumn struct { + Status model.Status + Issues []*model.Issue +} + +type listLoadedMsg struct { + view viewMode + requestID int + data app.IssueListData + err error +} + +type boardLoadedMsg struct { + view viewMode + requestID int + data app.BoardData + err error +} + +type detailLoadedMsg struct { + requestID int + id int + data app.IssueDetailData + err error +} + +type refreshTickMsg struct { + tickID int +} + +type browserModel struct { + conn *sql.DB + projectName string + + width int + height int + + view viewMode + focus paneFocus + showHelp bool + detailExpanded bool + + loading bool + loadingDetail bool + errMsg string + detailErr string + + listData app.IssueListData + boardData app.BoardData + boardColumns []boardColumn + listSort sortMode + refreshPolicy refreshPolicy + refreshState refreshState + + selectedIssueID int + listIndex int + boardColumnIdx int + boardRowIdx int + detailScroll int + detailTargetID int + detailIssueID int + detailData app.IssueDetailData + detailFocus detailFocusRegion + detailSubIndex int + detailHistory []detailNavState + pendingDetailFocus detailFocusRegion + viewRequestID int + detailRequestID int + refreshTickID int +} + +func NewBrowser(conn *sql.DB, docketDir string) tea.Model { + projectName := filepath.Base(filepath.Dir(docketDir)) + if projectName == "." || projectName == string(filepath.Separator) || projectName == "" { + projectName = "docket" + } + + return browserModel{ + conn: conn, + projectName: projectName, + view: viewList, + focus: focusBrowse, + listSort: defaultListSort, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + detailFocus: detailFocusBody, + loading: true, + viewRequestID: 1, + } +} + +func (m browserModel) currentDetailTargetID() int { + if m.detailTargetID != 0 { + return m.detailTargetID + } + return m.selectedIssueID +} + +func (m browserModel) selectedIssue() *model.Issue { + switch m.view { + case viewBoard: + if len(m.boardColumns) == 0 { + return nil + } + col := m.boardColumns[m.boardColumnIdx] + if len(col.Issues) == 0 { + return nil + } + return col.Issues[m.boardRowIdx] + default: + if len(m.listData.Issues) == 0 { + return nil + } + return m.listData.Issues[m.listIndex] + } +} diff --git a/internal/tui/browser_test.go b/internal/tui/browser_test.go new file mode 100644 index 0000000..cd3f8b6 --- /dev/null +++ b/internal/tui/browser_test.go @@ -0,0 +1,2037 @@ +package tui + +import ( + "database/sql" + "os" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ALT-F4-LLC/docket/internal/app" + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/render" +) + +func mustOpenBrowserDB(t *testing.T) *sql.DB { + t.Helper() + conn, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open(:memory:): %v", err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Initialize(conn); err != nil { + t.Fatalf("Initialize: %v", err) + } + return conn +} + +func createBrowserIssue(t *testing.T, conn *sql.DB, issue model.Issue) int { + t.Helper() + id, err := db.CreateIssue(conn, &issue, nil, nil) + if err != nil { + t.Fatalf("CreateIssue(%q): %v", issue.Title, err) + } + return id +} + +func testIssue(id int, status model.Status) *model.Issue { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + return &model.Issue{ + ID: id, + Title: model.FormatID(id) + " title", + Status: status, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + CreatedAt: now, + UpdatedAt: now, + } +} + +func testIssues(startID, count int, status model.Status) []*model.Issue { + issues := make([]*model.Issue, 0, count) + for i := 0; i < count; i++ { + issues = append(issues, testIssue(startID+i, status)) + } + return issues +} + +func batchCommandCount(cmd tea.Cmd) int { + if cmd == nil { + return 0 + } + if msg, ok := cmd().(tea.BatchMsg); ok { + return len(msg) + } + return 1 +} + +func testDetailModel(issue *model.Issue, subIssues ...*model.Issue) browserModel { + return browserModel{ + width: 100, + height: 30, + focus: focusDetail, + selectedIssueID: issue.ID, + detailTargetID: issue.ID, + detailIssueID: issue.ID, + detailFocus: detailFocusBody, + detailData: app.IssueDetailData{ + Issue: issue, + SubIssues: subIssues, + }, + } +} + +func TestMoveListSelection(t *testing.T) { + m := browserModel{ + view: viewList, + focus: focusBrowse, + listData: app.IssueListData{Issues: []*model.Issue{testIssue(1, model.StatusTodo), testIssue(2, model.StatusTodo)}}, + selectedIssueID: 1, + listIndex: 0, + } + + if !m.moveListSelection(1) { + t.Fatalf("expected selection to move") + } + if m.listIndex != 1 || m.selectedIssueID != 2 { + t.Fatalf("got index=%d id=%d, want 1/2", m.listIndex, m.selectedIssueID) + } + if m.moveListSelection(1) { + t.Fatalf("expected move beyond end to be ignored") + } +} + +func TestNewBrowserStartsInListViewWithInitialLoad(t *testing.T) { + model := NewBrowser(nil, "/tmp/example/.docket") + browser, ok := model.(browserModel) + if !ok { + t.Fatalf("model = %T, want browserModel", model) + } + if browser.projectName != "example" { + t.Fatalf("projectName = %q, want example", browser.projectName) + } + if browser.view != viewList { + t.Fatalf("view = %s, want %s", browser.view, viewList) + } + if browser.focus != focusBrowse { + t.Fatalf("focus = %s, want %s", browser.focus, focusBrowse) + } + if browser.listSort != defaultListSort { + t.Fatalf("listSort = %#v, want %#v", browser.listSort, defaultListSort) + } + if !browser.loading { + t.Fatal("expected initial loading state") + } + if browser.viewRequestID != 1 { + t.Fatalf("viewRequestID = %d, want 1", browser.viewRequestID) + } + if browser.Init() == nil { + t.Fatal("expected initial load command") + } +} + +func TestNewBrowserFallsBackToDefaultProjectName(t *testing.T) { + model := NewBrowser(nil, "/.docket") + browser := model.(browserModel) + if browser.projectName != "docket" { + t.Fatalf("projectName = %q, want docket", browser.projectName) + } +} + +func TestMoveBoardSelection(t *testing.T) { + m := browserModel{ + view: viewBoard, + focus: focusBrowse, + boardColumns: []boardColumn{ + {Status: model.StatusTodo, Issues: []*model.Issue{testIssue(1, model.StatusTodo), testIssue(2, model.StatusTodo)}}, + {Status: model.StatusReview, Issues: []*model.Issue{testIssue(3, model.StatusReview)}}, + }, + selectedIssueID: 1, + } + + if !m.moveBoardRow(1) { + t.Fatalf("expected board row move") + } + if m.boardRowIdx != 1 || m.selectedIssueID != 2 { + t.Fatalf("got row=%d id=%d, want 1/2", m.boardRowIdx, m.selectedIssueID) + } + if !m.moveBoardColumn(1) { + t.Fatalf("expected board column move") + } + if m.boardColumnIdx != 1 || m.boardRowIdx != 0 || m.selectedIssueID != 3 { + t.Fatalf("got col=%d row=%d id=%d, want 1/0/3", m.boardColumnIdx, m.boardRowIdx, m.selectedIssueID) + } +} + +func TestSelectedIssueReturnsCurrentBrowseSelection(t *testing.T) { + listIssue := testIssue(1, model.StatusTodo) + boardIssue := testIssue(2, model.StatusReview) + + listModel := browserModel{ + view: viewList, + listData: app.IssueListData{Issues: []*model.Issue{listIssue}}, + listIndex: 0, + } + if got := listModel.selectedIssue(); got == nil || got.ID != listIssue.ID { + t.Fatalf("selectedIssue() = %#v, want %d", got, listIssue.ID) + } + + boardModel := browserModel{ + view: viewBoard, + boardColumns: []boardColumn{{ + Status: model.StatusReview, + Issues: []*model.Issue{boardIssue}, + }}, + } + if got := boardModel.selectedIssue(); got == nil || got.ID != boardIssue.ID { + t.Fatalf("selectedIssue() = %#v, want %d", got, boardIssue.ID) + } + + emptyModel := browserModel{view: viewList} + if got := emptyModel.selectedIssue(); got != nil { + t.Fatalf("selectedIssue() = %#v, want nil", got) + } +} + +func TestReconcileListSelectionKeepsMatchAndFallsBack(t *testing.T) { + issues := []*model.Issue{testIssue(3, model.StatusTodo), testIssue(4, model.StatusReview)} + + keep := browserModel{ + focus: focusBrowse, + selectedIssueID: 4, + listData: app.IssueListData{Issues: issues}, + } + keep.reconcileListSelection() + if keep.listIndex != 1 || keep.selectedIssueID != 4 { + t.Fatalf("kept selection = %d/%d, want 1/4", keep.listIndex, keep.selectedIssueID) + } + + fallback := browserModel{ + focus: focusDetail, + selectedIssueID: 99, + listData: app.IssueListData{Issues: issues}, + } + fallback.reconcileListSelection() + if fallback.listIndex != 0 || fallback.selectedIssueID != 3 { + t.Fatalf("fallback selection = %d/%d, want 0/3", fallback.listIndex, fallback.selectedIssueID) + } + if fallback.focus != focusBrowse { + t.Fatalf("focus = %s, want %s", fallback.focus, focusBrowse) + } + + empty := browserModel{ + selectedIssueID: 5, + detailIssueID: 5, + detailScroll: 3, + } + empty.reconcileListSelection() + if empty.selectedIssueID != 0 || empty.listIndex != 0 || empty.detailIssueID != 0 || empty.detailScroll != 0 { + t.Fatalf("empty reconcile = %#v", empty) + } +} + +func TestReconcileBoardSelectionFallsBackToFirstIssue(t *testing.T) { + m := browserModel{ + view: viewBoard, + selectedIssueID: 99, + boardColumns: []boardColumn{ + {Status: model.StatusBacklog, Issues: []*model.Issue{testIssue(5, model.StatusBacklog)}}, + {Status: model.StatusTodo, Issues: []*model.Issue{testIssue(6, model.StatusTodo)}}, + }, + } + + m.reconcileBoardSelection() + if m.boardColumnIdx != 0 || m.boardRowIdx != 0 || m.selectedIssueID != 5 { + t.Fatalf("got col=%d row=%d id=%d, want 0/0/5", m.boardColumnIdx, m.boardRowIdx, m.selectedIssueID) + } +} + +func TestReconcileBoardSelectionKeepsMatchAndClearsEmptyState(t *testing.T) { + matched := browserModel{ + selectedIssueID: 6, + boardColumns: []boardColumn{ + {Status: model.StatusBacklog, Issues: []*model.Issue{testIssue(5, model.StatusBacklog)}}, + {Status: model.StatusTodo, Issues: []*model.Issue{testIssue(6, model.StatusTodo)}}, + }, + } + matched.reconcileBoardSelection() + if matched.boardColumnIdx != 1 || matched.boardRowIdx != 0 || matched.selectedIssueID != 6 { + t.Fatalf("matched selection = %d/%d/%d, want 1/0/6", matched.boardColumnIdx, matched.boardRowIdx, matched.selectedIssueID) + } + + empty := browserModel{selectedIssueID: 5, detailIssueID: 5, detailScroll: 2} + empty.reconcileBoardSelection() + if empty.selectedIssueID != 0 || empty.boardColumnIdx != 0 || empty.boardRowIdx != 0 || empty.detailIssueID != 0 || empty.detailScroll != 0 { + t.Fatalf("empty reconcile = %#v", empty) + } +} + +func TestStaleViewLoadMessagesAreIgnored(t *testing.T) { + m := browserModel{ + view: viewList, + viewRequestID: 3, + listData: app.IssueListData{Issues: []*model.Issue{testIssue(1, model.StatusTodo)}}, + } + + updated, _ := m.Update(boardLoadedMsg{ + view: viewBoard, + requestID: 2, + data: app.BoardData{Issues: []*model.Issue{ + testIssue(9, model.StatusReview), + }}, + }) + + next := updated.(browserModel) + if next.view != viewList { + t.Fatalf("expected view to remain list, got %s", next.view) + } + if len(next.boardColumns) != 0 { + t.Fatalf("expected stale board payload to be ignored") + } +} + +func TestStaleListLoadMessagesAreIgnored(t *testing.T) { + m := browserModel{ + view: viewList, + viewRequestID: 4, + listData: app.IssueListData{Issues: []*model.Issue{testIssue(1, model.StatusTodo)}}, + } + + updated, _ := m.Update(listLoadedMsg{ + view: viewList, + requestID: 3, + data: app.IssueListData{Issues: []*model.Issue{ + testIssue(9, model.StatusDone), + }}, + }) + + next := updated.(browserModel) + if len(next.listData.Issues) != 1 || next.listData.Issues[0].ID != 1 { + t.Fatalf("stale list payload changed state: %#v", next.listData.Issues) + } + if next.view != viewList { + t.Fatalf("expected view to remain list, got %s", next.view) + } + if next.viewRequestID != 4 { + t.Fatalf("viewRequestID = %d, want 4", next.viewRequestID) + } +} + +func TestListLoadMessageUpdatesSelectionAndDetailTarget(t *testing.T) { + m := browserModel{ + view: viewList, + viewRequestID: 2, + loading: true, + selectedIssueID: 99, + detailTargetID: 99, + } + + updated, cmd := m.Update(listLoadedMsg{ + view: viewList, + requestID: 2, + data: app.IssueListData{ + Issues: []*model.Issue{testIssue(5, model.StatusTodo), testIssue(6, model.StatusReview)}, + Total: 2, + }, + }) + + next := updated.(browserModel) + if next.loading { + t.Fatal("expected loading to stop") + } + if next.selectedIssueID != 5 || next.listIndex != 0 { + t.Fatalf("selection = %d/%d, want 5/0", next.selectedIssueID, next.listIndex) + } + if next.detailTargetID != 5 { + t.Fatalf("detailTargetID = %d, want 5", next.detailTargetID) + } + if next.refreshState.LastSuccess.IsZero() { + t.Fatal("expected refresh success time to be set") + } + if cmd == nil { + t.Fatal("expected follow-up detail load command") + } +} + +func TestRefreshTickReloadKeepsValidSelection(t *testing.T) { + m := browserModel{ + view: viewList, + listSort: defaultListSort, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + refreshTickID: 4, + viewRequestID: 7, + selectedIssueID: 9, + listIndex: 1, + listData: app.IssueListData{Issues: []*model.Issue{ + testIssue(8, model.StatusTodo), + testIssue(9, model.StatusInProgress), + }}, + refreshState: refreshState{LoadedOnce: true}, + } + + updated, cmd := m.Update(refreshTickMsg{tickID: 4}) + afterTick := updated.(browserModel) + if !afterTick.loading { + t.Fatal("expected refresh tick to start a view load") + } + if !afterTick.refreshState.Pending { + t.Fatal("expected refresh state to become pending") + } + if afterTick.viewRequestID != 8 { + t.Fatalf("viewRequestID = %d, want 8", afterTick.viewRequestID) + } + if cmd == nil { + t.Fatal("expected refresh tick to return load command") + } + + updated, _ = afterTick.Update(listLoadedMsg{ + view: viewList, + requestID: 8, + data: app.IssueListData{Issues: []*model.Issue{ + testIssue(9, model.StatusDone), + testIssue(10, model.StatusTodo), + }}, + }) + afterLoad := updated.(browserModel) + if afterLoad.selectedIssueID != 9 { + t.Fatalf("selectedIssueID = %d, want 9", afterLoad.selectedIssueID) + } + if afterLoad.listIndex != 0 { + t.Fatalf("listIndex = %d, want 0", afterLoad.listIndex) + } + if afterLoad.refreshState.LastSuccess.IsZero() { + t.Fatal("expected refresh success time to be set") + } + if afterLoad.refreshState.LastError != "" { + t.Fatalf("LastError = %q, want empty", afterLoad.refreshState.LastError) + } +} + +func TestSuccessfulListLoadDoesNotScheduleInputFlush(t *testing.T) { + m := browserModel{ + view: viewList, + viewRequestID: 2, + loading: true, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + } + + updated, cmd := m.Update(listLoadedMsg{ + view: viewList, + requestID: 2, + data: app.IssueListData{ + Issues: []*model.Issue{testIssue(5, model.StatusTodo), testIssue(6, model.StatusReview)}, + Total: 2, + }, + }) + + next := updated.(browserModel) + if next.selectedIssueID != 5 || next.listIndex != 0 { + t.Fatalf("selection = %d/%d, want 5/0", next.selectedIssueID, next.listIndex) + } + if got := batchCommandCount(cmd); got != 2 { + t.Fatalf("successful list load scheduled %d commands, want 2 without input flush", got) + } +} + +func TestSuccessfulDetailLoadDoesNotScheduleInputFlush(t *testing.T) { + issue := testIssue(5, model.StatusTodo) + m := browserModel{ + detailRequestID: 3, + detailTargetID: issue.ID, + loadingDetail: true, + } + + updated, cmd := m.Update(detailLoadedMsg{ + requestID: 3, + id: issue.ID, + data: app.IssueDetailData{Issue: issue}, + }) + + next := updated.(browserModel) + if next.detailIssueID != issue.ID { + t.Fatalf("detailIssueID = %d, want %d", next.detailIssueID, issue.ID) + } + if got := batchCommandCount(cmd); got != 0 { + t.Fatalf("successful detail load scheduled %d commands, want 0 without input flush", got) + } +} + +func TestRapidListNavigationKeepsVisibleWindowSynchronized(t *testing.T) { + issues := testIssues(100, 50, model.StatusTodo) + m := browserModel{ + view: viewList, + focus: focusBrowse, + width: 100, + height: 20, + listData: app.IssueListData{Issues: issues, Total: len(issues)}, + selectedIssueID: issues[0].ID, + } + + for range 30 { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + m = updated.(browserModel) + } + + if m.listIndex != 30 || m.selectedIssueID != issues[30].ID { + t.Fatalf("selection = %d/%d, want 30/%d", m.listIndex, m.selectedIssueID, issues[30].ID) + } + start, end := windowBounds(m.listIndex, len(issues), 6) + if m.listIndex < start || m.listIndex >= end { + t.Fatalf("window = %d/%d does not include selected index %d", start, end, m.listIndex) + } + rendered := m.renderList(96, 6) + if !strings.Contains(rendered, model.FormatID(issues[30].ID)) { + t.Fatalf("rendered list does not include selected issue %s: %q", model.FormatID(issues[30].ID), rendered) + } + if lines := strings.Split(rendered, "\n"); len(lines) != 6 { + t.Fatalf("rendered lines = %d, want 6", len(lines)) + } +} + +func TestRapidListReverseNavigationRestoresExpectedWindow(t *testing.T) { + issues := testIssues(200, 50, model.StatusTodo) + m := browserModel{ + view: viewList, + focus: focusBrowse, + width: 100, + height: 20, + listData: app.IssueListData{Issues: issues, Total: len(issues)}, + selectedIssueID: issues[40].ID, + listIndex: 40, + } + + for range 25 { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + m = updated.(browserModel) + } + + if m.listIndex != 15 || m.selectedIssueID != issues[15].ID { + t.Fatalf("selection = %d/%d, want 15/%d", m.listIndex, m.selectedIssueID, issues[15].ID) + } + start, end := windowBounds(m.listIndex, len(issues), 6) + if m.listIndex < start || m.listIndex >= end { + t.Fatalf("window = %d/%d does not include selected index %d", start, end, m.listIndex) + } + rendered := m.renderList(96, 6) + if !strings.Contains(rendered, model.FormatID(issues[15].ID)) { + t.Fatalf("rendered list does not include selected issue %s: %q", model.FormatID(issues[15].ID), rendered) + } +} + +func TestBoardNavigationAcrossLargeColumnsKeepsSelectedIssueContext(t *testing.T) { + todoIssues := testIssues(300, 24, model.StatusTodo) + reviewIssues := testIssues(400, 24, model.StatusReview) + m := browserModel{ + view: viewBoard, + focus: focusBrowse, + boardColumns: []boardColumn{ + {Status: model.StatusTodo, Issues: todoIssues}, + {Status: model.StatusReview, Issues: reviewIssues}, + }, + selectedIssueID: todoIssues[18].ID, + boardColumnIdx: 0, + boardRowIdx: 18, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + afterRight := updated.(browserModel) + if afterRight.boardColumnIdx != 1 || afterRight.boardRowIdx != 18 { + t.Fatalf("board position = %d/%d, want 1/18", afterRight.boardColumnIdx, afterRight.boardRowIdx) + } + if afterRight.selectedIssueID != reviewIssues[18].ID { + t.Fatalf("selectedIssueID = %d, want %d", afterRight.selectedIssueID, reviewIssues[18].ID) + } + + updated, _ = afterRight.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + afterLeft := updated.(browserModel) + if afterLeft.boardColumnIdx != 0 || afterLeft.boardRowIdx != 18 { + t.Fatalf("board position = %d/%d, want 0/18", afterLeft.boardColumnIdx, afterLeft.boardRowIdx) + } + if afterLeft.selectedIssueID != todoIssues[18].ID { + t.Fatalf("selectedIssueID = %d, want %d", afterLeft.selectedIssueID, todoIssues[18].ID) + } +} + +func TestRefreshChurnDoesNotCauseLargeListSelectionJump(t *testing.T) { + initial := testIssues(500, 50, model.StatusTodo) + refreshed := append([]*model.Issue{testIssue(999, model.StatusTodo)}, initial...) + m := browserModel{ + view: viewList, + focus: focusBrowse, + listSort: defaultListSort, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + refreshState: refreshState{LoadedOnce: true}, + viewRequestID: 9, + refreshTickID: 4, + selectedIssueID: initial[10].ID, + listIndex: 10, + listData: app.IssueListData{Issues: initial, Total: len(initial)}, + } + + updated, cmd := m.Update(refreshTickMsg{tickID: 4}) + afterTick := updated.(browserModel) + if cmd == nil { + t.Fatal("expected refresh tick to start a reload") + } + + for range 7 { + updated, _ = afterTick.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + afterTick = updated.(browserModel) + } + if afterTick.selectedIssueID != initial[17].ID || afterTick.listIndex != 17 { + t.Fatalf("pre-refresh selection = %d/%d, want 17/%d", afterTick.listIndex, afterTick.selectedIssueID, initial[17].ID) + } + + updated, _ = afterTick.Update(listLoadedMsg{ + view: viewList, + requestID: 10, + data: app.IssueListData{Issues: refreshed, Total: len(refreshed)}, + }) + afterLoad := updated.(browserModel) + if afterLoad.selectedIssueID != initial[17].ID { + t.Fatalf("selectedIssueID = %d, want %d", afterLoad.selectedIssueID, initial[17].ID) + } + if afterLoad.listIndex != 18 { + t.Fatalf("listIndex = %d, want 18 after inserted row", afterLoad.listIndex) + } +} + +func TestLargeDatasetRemainsNavigableAcrossListAndBoardViews(t *testing.T) { + conn := mustOpenBrowserDB(t) + for i := 0; i < 40; i++ { + status := model.StatusTodo + if i%2 == 1 { + status = model.StatusReview + } + createBrowserIssue(t, conn, model.Issue{ + Title: model.FormatID(i+1) + " issue", + Status: status, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }) + } + + m := NewBrowser(conn, "/tmp/example/.docket").(browserModel) + m.viewRequestID = 21 + updated, _ := m.Update(loadListCmd(conn, defaultListSort, 21)().(listLoadedMsg)) + afterListLoad := updated.(browserModel) + if len(afterListLoad.listData.Issues) != 40 { + t.Fatalf("list issues = %d, want 40", len(afterListLoad.listData.Issues)) + } + + for range 12 { + updated, _ = afterListLoad.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + afterListLoad = updated.(browserModel) + } + if afterListLoad.listIndex != 12 { + t.Fatalf("listIndex = %d, want 12", afterListLoad.listIndex) + } + + updated, cmd := afterListLoad.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + afterBoardSwitch := updated.(browserModel) + if afterBoardSwitch.view != viewBoard || cmd == nil { + t.Fatalf("expected board switch with load command, view=%s cmd=%v", afterBoardSwitch.view, cmd != nil) + } + + afterBoardSwitch.viewRequestID = 22 + updated, _ = afterBoardSwitch.Update(loadBoardCmd(conn, 22)().(boardLoadedMsg)) + afterBoardLoad := updated.(browserModel) + if len(afterBoardLoad.boardColumns) == 0 { + t.Fatal("expected populated board columns") + } + startRow := afterBoardLoad.boardRowIdx + + updated, _ = afterBoardLoad.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + afterBoardMove := updated.(browserModel) + if afterBoardMove.boardRowIdx != startRow+1 { + t.Fatalf("boardRowIdx = %d, want %d", afterBoardMove.boardRowIdx, startRow+1) + } + if afterBoardMove.selectedIssue() == nil { + t.Fatal("expected board selection to remain valid") + } +} + +func TestRefreshErrorStaysNonFatalAfterSuccessfulLoad(t *testing.T) { + m := browserModel{ + view: viewList, + viewRequestID: 3, + loading: true, + listData: app.IssueListData{Issues: []*model.Issue{ + testIssue(5, model.StatusTodo), + }}, + selectedIssueID: 5, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + refreshState: refreshState{ + LoadedOnce: true, + LastSuccess: time.Date(2026, 3, 28, 12, 34, 56, 0, time.UTC), + }, + } + + updated, cmd := m.Update(listLoadedMsg{view: viewList, requestID: 3, err: os.ErrPermission}) + next := updated.(browserModel) + if next.errMsg != "" { + t.Fatalf("errMsg = %q, want empty for non-fatal refresh failure", next.errMsg) + } + if next.refreshState.LastError == "" { + t.Fatal("expected refresh error to be surfaced") + } + if len(next.listData.Issues) != 1 || next.listData.Issues[0].ID != 5 { + t.Fatalf("expected prior list data to be preserved, got %#v", next.listData.Issues) + } + if cmd == nil { + t.Fatal("expected refresh loop to continue after non-fatal failure") + } +} + +func TestAutoRefreshTogglePausesAndResumesPolling(t *testing.T) { + m := browserModel{ + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + refreshTickID: 2, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + paused := updated.(browserModel) + if paused.refreshPolicy.Enabled { + t.Fatal("expected auto-refresh to pause") + } + if paused.refreshTickID != 3 { + t.Fatalf("refreshTickID = %d, want 3", paused.refreshTickID) + } + if cmd != nil { + t.Fatal("expected pause to stop scheduling ticks") + } + + updated, cmd = paused.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + resumed := updated.(browserModel) + if !resumed.refreshPolicy.Enabled { + t.Fatal("expected auto-refresh to resume") + } + if resumed.refreshTickID != 4 { + t.Fatalf("refreshTickID = %d, want 4", resumed.refreshTickID) + } + if cmd == nil { + t.Fatal("expected resume to schedule the next tick") + } +} + +func TestListLoadErrorClearsListData(t *testing.T) { + m := browserModel{ + view: viewList, + viewRequestID: 2, + listData: app.IssueListData{Issues: []*model.Issue{testIssue(1, model.StatusTodo)}}, + } + + updated, _ := m.Update(listLoadedMsg{view: viewList, requestID: 2, err: os.ErrNotExist}) + next := updated.(browserModel) + if next.errMsg == "" { + t.Fatal("expected error message") + } + if len(next.listData.Issues) != 0 { + t.Fatalf("listData = %#v, want empty", next.listData.Issues) + } +} + +func TestBoardProbeKeysDoNotBlockListSwitch(t *testing.T) { + m := browserModel{ + view: viewBoard, + focus: focusBrowse, + viewRequestID: 2, + selectedIssueID: 1, + boardColumns: []boardColumn{ + {Status: model.StatusTodo, Issues: []*model.Issue{testIssue(1, model.StatusTodo)}}, + }, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Alt: true, Runes: []rune{']'}}) + afterProbe := updated.(browserModel) + if afterProbe.view != viewBoard || afterProbe.viewRequestID != 2 { + t.Fatalf("probe key changed state: view=%s request=%d", afterProbe.view, afterProbe.viewRequestID) + } + + updated, _ = afterProbe.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + afterListKey := updated.(browserModel) + if afterListKey.view != viewList { + t.Fatalf("view = %s, want %s", afterListKey.view, viewList) + } + if !afterListKey.loading { + t.Fatalf("expected list reload to start") + } + if afterListKey.viewRequestID != 3 { + t.Fatalf("viewRequestID = %d, want 3", afterListKey.viewRequestID) + } +} + +func TestViewSwitchClearsExpandedDetailState(t *testing.T) { + tests := []struct { + name string + startView viewMode + key rune + wantView viewMode + }{ + {name: "list hotkey", startView: viewBoard, key: '1', wantView: viewList}, + {name: "board hotkey", startView: viewList, key: '2', wantView: viewBoard}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := browserModel{ + view: tt.startView, + focus: focusDetail, + detailExpanded: true, + selectedIssueID: 7, + detailTargetID: 42, + detailFocus: detailFocusSubIssues, + detailSubIndex: 2, + detailHistory: []detailNavState{{ + issueID: 99, + scroll: 4, + subIssueIndex: 1, + focusRegion: detailFocusSubIssues, + }}, + viewRequestID: 5, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tt.key}}) + afterSwitch := updated.(browserModel) + + if afterSwitch.view != tt.wantView { + t.Fatalf("view = %s, want %s", afterSwitch.view, tt.wantView) + } + if afterSwitch.detailExpanded { + t.Fatal("expected expanded detail to close on view switch") + } + if afterSwitch.focus != focusBrowse { + t.Fatalf("focus = %s, want %s", afterSwitch.focus, focusBrowse) + } + if afterSwitch.detailTargetID != afterSwitch.selectedIssueID { + t.Fatalf("detailTargetID = %d, want selected issue %d", afterSwitch.detailTargetID, afterSwitch.selectedIssueID) + } + if afterSwitch.detailFocus != detailFocusBody { + t.Fatalf("detailFocus = %s, want %s", afterSwitch.detailFocus, detailFocusBody) + } + if afterSwitch.detailSubIndex != 0 { + t.Fatalf("detailSubIndex = %d, want 0", afterSwitch.detailSubIndex) + } + if len(afterSwitch.detailHistory) != 0 { + t.Fatalf("detailHistory = %#v, want empty", afterSwitch.detailHistory) + } + if !afterSwitch.loading { + t.Fatal("expected view reload to start") + } + if afterSwitch.viewRequestID != 6 { + t.Fatalf("viewRequestID = %d, want 6", afterSwitch.viewRequestID) + } + if cmd == nil { + t.Fatal("expected load command") + } + }) + } +} + +func TestQuestionMarkStillOpensHelpAfterBoardLoad(t *testing.T) { + m := browserModel{ + view: viewBoard, + focus: focusBrowse, + viewRequestID: 2, + } + + updated, _ := m.Update(boardLoadedMsg{ + view: viewBoard, + requestID: 2, + data: app.BoardData{Issues: []*model.Issue{ + testIssue(1, model.StatusTodo), + }}, + }) + afterBoard := updated.(browserModel) + if afterBoard.selectedIssueID != 1 { + t.Fatalf("selectedIssueID = %d, want 1", afterBoard.selectedIssueID) + } + + updated, _ = afterBoard.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + afterHelp := updated.(browserModel) + if !afterHelp.showHelp { + t.Fatalf("expected help overlay to open") + } + + updated, _ = afterHelp.Update(tea.KeyMsg{Type: tea.KeyEsc}) + afterEsc := updated.(browserModel) + if afterEsc.showHelp { + t.Fatalf("expected help overlay to close") + } +} + +func TestLoaderCommandsReturnExpectedMessages(t *testing.T) { + conn := mustOpenBrowserDB(t) + parentID := createBrowserIssue(t, conn, model.Issue{ + Title: "Priority-first issue", + Status: model.StatusInProgress, + Priority: model.PriorityCritical, + Kind: model.IssueKindEpic, + }) + createBrowserIssue(t, conn, model.Issue{ + ParentID: &parentID, + Title: "Child", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindTask, + }) + newestID := createBrowserIssue(t, conn, model.Issue{ + Title: "Newest by id", + Status: model.StatusBacklog, + Priority: model.PriorityLow, + Kind: model.IssueKindTask, + }) + + listMsg, ok := loadListCmd(conn, defaultListSort, 11)().(listLoadedMsg) + if !ok || listMsg.requestID != 11 || listMsg.err != nil || len(listMsg.data.Issues) == 0 { + t.Fatalf("loadListCmd() = %#v", listMsg) + } + if listMsg.data.Issues[0].ID != newestID { + t.Fatalf("loadListCmd() first issue = %d, want newest id %d", listMsg.data.Issues[0].ID, newestID) + } + + boardMsg, ok := loadBoardCmd(conn, 12)().(boardLoadedMsg) + if !ok || boardMsg.requestID != 12 || boardMsg.err != nil || len(boardMsg.data.Issues) == 0 { + t.Fatalf("loadBoardCmd() = %#v", boardMsg) + } + + detailMsg, ok := loadDetailCmd(conn, parentID, 13)().(detailLoadedMsg) + if !ok || detailMsg.requestID != 13 || detailMsg.id != parentID || detailMsg.err != nil || detailMsg.data.Issue == nil { + t.Fatalf("loadDetailCmd() = %#v", detailMsg) + } + + if _, ok := loadViewCmd(conn, viewList, defaultListSort, 14)().(listLoadedMsg); !ok { + t.Fatal("expected list view loader message") + } + if _, ok := loadViewCmd(conn, viewBoard, defaultListSort, 15)().(boardLoadedMsg); !ok { + t.Fatal("expected board view loader message") + } +} + +func TestLoadListCmdPassesExplicitSortState(t *testing.T) { + originalListIssues := listIssues + t.Cleanup(func() { + listIssues = originalListIssues + }) + + var got app.ListIssuesParams + listIssues = func(conn *sql.DB, params app.ListIssuesParams) (app.IssueListData, error) { + got = params + return app.IssueListData{}, nil + } + + msg, ok := loadListCmd(nil, defaultListSort, 23)().(listLoadedMsg) + if !ok { + t.Fatalf("loadListCmd() returned %T, want listLoadedMsg", msg) + } + if msg.requestID != 23 { + t.Fatalf("requestID = %d, want 23", msg.requestID) + } + if got.Sort != defaultListSort.Field || got.SortDir != defaultListSort.Dir { + t.Fatalf("sort params = %q/%q, want %q/%q", got.Sort, got.SortDir, defaultListSort.Field, defaultListSort.Dir) + } + if got.Limit != defaultListLimit { + t.Fatalf("limit = %d, want %d", got.Limit, defaultListLimit) + } + if msg.err != nil { + t.Fatalf("loadListCmd() error = %v", msg.err) + } + if msg.view != viewList { + t.Fatalf("view = %s, want %s", msg.view, viewList) + } +} + +func TestListLoadMessageKeepsSelectionAcrossExplicitOrderChange(t *testing.T) { + selected := testIssue(4, model.StatusTodo) + m := browserModel{ + view: viewList, + listSort: defaultListSort, + viewRequestID: 5, + selectedIssueID: selected.ID, + listIndex: 2, + listData: app.IssueListData{Issues: []*model.Issue{ + testIssue(2, model.StatusTodo), + testIssue(3, model.StatusTodo), + selected, + }}, + } + + updated, _ := m.Update(listLoadedMsg{ + view: viewList, + requestID: 5, + data: app.IssueListData{Issues: []*model.Issue{ + selected, + testIssue(3, model.StatusTodo), + testIssue(2, model.StatusTodo), + }}, + }) + + next := updated.(browserModel) + if next.selectedIssueID != selected.ID { + t.Fatalf("selectedIssueID = %d, want %d", next.selectedIssueID, selected.ID) + } + if next.listIndex != 0 { + t.Fatalf("listIndex = %d, want 0", next.listIndex) + } + if next.detailTargetID != selected.ID { + t.Fatalf("detailTargetID = %d, want %d", next.detailTargetID, selected.ID) + } +} + +func TestListBrowseKeyCyclesSortFieldAndReloads(t *testing.T) { + m := browserModel{ + view: viewList, + focus: focusBrowse, + listSort: defaultListSort, + viewRequestID: 2, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + next := updated.(browserModel) + if next.listSort.Field != "title" { + t.Fatalf("listSort.Field = %q, want title", next.listSort.Field) + } + if next.listSort.Dir != defaultListSort.Dir { + t.Fatalf("listSort.Dir = %q, want %q", next.listSort.Dir, defaultListSort.Dir) + } + if !next.loading { + t.Fatal("expected list sort change to start a reload") + } + if next.viewRequestID != 3 { + t.Fatalf("viewRequestID = %d, want 3", next.viewRequestID) + } + if cmd == nil { + t.Fatal("expected sort field change to return a load command") + } +} + +func TestListBrowseKeyTogglesSortDirectionAndReloads(t *testing.T) { + m := browserModel{ + view: viewList, + focus: focusBrowse, + listSort: defaultListSort, + viewRequestID: 4, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'S'}}) + next := updated.(browserModel) + if next.listSort.Field != defaultListSort.Field { + t.Fatalf("listSort.Field = %q, want %q", next.listSort.Field, defaultListSort.Field) + } + if next.listSort.Dir != "asc" { + t.Fatalf("listSort.Dir = %q, want asc", next.listSort.Dir) + } + if !next.loading { + t.Fatal("expected sort direction change to start a reload") + } + if next.viewRequestID != 5 { + t.Fatalf("viewRequestID = %d, want 5", next.viewRequestID) + } + if cmd == nil { + t.Fatal("expected sort direction change to return a load command") + } +} + +func TestHelpOverlayViewRendersKeyboardReference(t *testing.T) { + m := browserModel{width: 100, height: 20, showHelp: true} + rendered := m.View() + for _, fragment := range []string{"docket tui", "s cycle list sort field", "S toggle list sort direction", "ctrl+u/d half-page", "This preview is read-only."} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("rendered help missing %q: %q", fragment, rendered) + } + } +} + +func TestBoardViewNarrowLayoutUsesSingleColumnHeader(t *testing.T) { + m := browserModel{ + width: 90, + height: 18, + view: viewBoard, + focus: focusBrowse, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + boardColumns: []boardColumn{{ + Status: model.StatusTodo, + Issues: []*model.Issue{testIssue(1, model.StatusTodo)}, + }}, + selectedIssueID: 1, + } + + rendered := m.View() + if !strings.Contains(rendered, "refresh auto 5s not loaded") { + t.Fatalf("expected refresh context in board view, got %q", rendered) + } + if strings.Count(rendered, "TODO (1)") != 1 { + t.Fatalf("expected a single TODO board column title in narrow board view, got %q", rendered) + } + if !strings.Contains(rendered, "TODO (1)") { + t.Fatalf("expected board column title with count, got %q", rendered) + } + if !strings.Contains(rendered, "DKT-1") { + t.Fatalf("expected selected issue to remain visible, got %q", rendered) + } + + lines := strings.Split(rendered, "\n") + for i, line := range lines { + if strings.Contains(line, "Issues · BOARD") { + if i+1 >= len(lines) { + t.Fatalf("expected columns after board title, got %q", rendered) + } + if !strings.Contains(lines[i+1], "╭") { + t.Fatalf("expected board columns immediately after board title, got %q", rendered) + } + break + } + } +} + +func TestStackedLayoutStaysWithinShortTerminalHeight(t *testing.T) { + m := browserModel{ + width: 90, + height: 10, + view: viewList, + focus: focusBrowse, + selectedIssueID: 1, + listData: app.IssueListData{Issues: []*model.Issue{testIssue(1, model.StatusTodo)}, Total: 1}, + detailTargetID: 1, + detailIssueID: 1, + detailData: app.IssueDetailData{Issue: testIssue(1, model.StatusTodo)}, + } + + rendered := m.View() + if got := len(strings.Split(rendered, "\n")); got > m.height { + t.Fatalf("rendered %d lines for %d-row terminal: %q", got, m.height, rendered) + } + + _, detailHeight := m.detailPaneDimensions() + if detailHeight != 0 { + t.Fatalf("detailPaneDimensions() height = %d, want 0", detailHeight) + } + if strings.Count(rendered, "Issues · LIST") != 1 { + t.Fatalf("expected stacked browse pane title once, got %q", rendered) + } + if strings.Contains(rendered, "Detail · DKT-1") { + t.Fatalf("expected short stacked layout to collapse to the focused pane, got %q", rendered) + } +} + +func TestListViewShowsActiveSortAndRefreshCue(t *testing.T) { + m := browserModel{ + width: 100, + height: 24, + view: viewList, + focus: focusBrowse, + listSort: defaultListSort, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + refreshState: refreshState{LastSuccess: time.Date(2026, 3, 28, 12, 34, 56, 0, time.UTC)}, + listData: app.IssueListData{Issues: []*model.Issue{{ + ID: 14, + Title: "Render richer list metadata without wrapping rows", + Status: model.StatusInProgress, + Priority: model.PriorityHigh, + Kind: model.IssueKindFeature, + }}, Total: 1}, + } + + rendered := m.View() + for _, fragment := range []string{"SORT ID DESC", "refreshed 12:34:56", "s sort-field", "S sort-dir", "INPROG", "FEATURE"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected list view to include %q: %q", fragment, rendered) + } + } +} + +func TestListViewWrapsFooterHintsAtNarrowWidths(t *testing.T) { + m := browserModel{ + width: 78, + height: 16, + view: viewList, + focus: focusBrowse, + listSort: defaultListSort, + refreshPolicy: refreshPolicy{Enabled: true, Interval: defaultRefreshInterval}, + listData: app.IssueListData{Issues: []*model.Issue{{ + ID: 14, + Title: "Render richer list metadata without wrapping rows", + Status: model.StatusInProgress, + Priority: model.PriorityHigh, + Kind: model.IssueKindFeature, + }}, Total: 1}, + } + + rendered := m.View() + if got := len(strings.Split(rendered, "\n")); got != m.height { + t.Fatalf("view lines = %d, want %d", got, m.height) + } + for _, fragment := range []string{"s sort-field", "S sort-dir", "J/K detail", "o drill-down", "q quit"} { + if !strings.Contains(rendered, fragment) { + t.Fatalf("expected narrow list view to include %q: %q", fragment, rendered) + } + } + if !strings.Contains(rendered, "\n J/K detail") { + t.Fatalf("expected footer hints to wrap onto a dedicated continuation line: %q", rendered) + } +} + +func TestBrowseDrillDownFocusesEpicSubIssuesAndKeepsSelection(t *testing.T) { + epic := testIssue(7, model.StatusTodo) + epic.Kind = model.IssueKindEpic + childA := testIssue(8, model.StatusTodo) + childB := testIssue(9, model.StatusDone) + m := browserModel{ + view: viewList, + focus: focusBrowse, + selectedIssueID: epic.ID, + listIndex: 0, + listData: app.IssueListData{ + Issues: []*model.Issue{epic}, + Total: 1, + Progress: map[int]render.SubIssueProgress{epic.ID: {Done: 1, Total: 2}}, + }, + detailIssueID: epic.ID, + detailTargetID: epic.ID, + detailData: app.IssueDetailData{Issue: epic, SubIssues: []*model.Issue{childA, childB}}, + } + + updated, _ := m.handleBrowseKey("o") + next := updated.(browserModel) + if next.focus != focusDetail { + t.Fatalf("focus = %s, want %s", next.focus, focusDetail) + } + if next.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", next.detailFocus, detailFocusSubIssues) + } + if next.selectedIssueID != epic.ID { + t.Fatalf("selectedIssueID = %d, want %d", next.selectedIssueID, epic.ID) + } +} + +func TestBrowseDrillDownPendingFocusAppliesAfterDetailLoad(t *testing.T) { + epic := testIssue(11, model.StatusTodo) + epic.Kind = model.IssueKindEpic + child := testIssue(12, model.StatusTodo) + m := browserModel{ + view: viewList, + focus: focusBrowse, + selectedIssueID: epic.ID, + listData: app.IssueListData{ + Issues: []*model.Issue{epic}, + Progress: map[int]render.SubIssueProgress{epic.ID: {Done: 0, Total: 1}}, + }, + detailTargetID: epic.ID, + loadingDetail: true, + } + + updated, _ := m.handleBrowseKey("o") + queued := updated.(browserModel) + if queued.pendingDetailFocus != detailFocusSubIssues { + t.Fatalf("pendingDetailFocus = %s, want %s", queued.pendingDetailFocus, detailFocusSubIssues) + } + + updated, _ = queued.Update(detailLoadedMsg{ + requestID: queued.detailRequestID, + id: epic.ID, + data: app.IssueDetailData{Issue: epic, SubIssues: []*model.Issue{child}}, + }) + afterLoad := updated.(browserModel) + if afterLoad.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", afterLoad.detailFocus, detailFocusSubIssues) + } + if afterLoad.pendingDetailFocus != "" { + t.Fatalf("pendingDetailFocus = %q, want empty", afterLoad.pendingDetailFocus) + } +} + +func TestBrowseDrillDownFocusesNonEpicParentSubIssues(t *testing.T) { + parent := testIssue(13, model.StatusTodo) + parent.Kind = model.IssueKindFeature + child := testIssue(14, model.StatusTodo) + m := browserModel{ + view: viewList, + focus: focusBrowse, + selectedIssueID: parent.ID, + listIndex: 0, + listData: app.IssueListData{ + Issues: []*model.Issue{parent}, + Total: 1, + Progress: map[int]render.SubIssueProgress{parent.ID: {Done: 0, Total: 1}}, + }, + detailIssueID: parent.ID, + detailTargetID: parent.ID, + detailData: app.IssueDetailData{Issue: parent, SubIssues: []*model.Issue{child}}, + } + + updated, _ := m.handleBrowseKey("o") + next := updated.(browserModel) + if next.focus != focusDetail { + t.Fatalf("focus = %s, want %s", next.focus, focusDetail) + } + if next.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", next.detailFocus, detailFocusSubIssues) + } + if next.selectedIssueID != parent.ID { + t.Fatalf("selectedIssueID = %d, want %d", next.selectedIssueID, parent.ID) + } +} + +func TestBrowseDrillDownPendingFocusAppliesForNonEpicParent(t *testing.T) { + parent := testIssue(15, model.StatusTodo) + parent.Kind = model.IssueKindTask + child := testIssue(16, model.StatusTodo) + m := browserModel{ + view: viewList, + focus: focusBrowse, + selectedIssueID: parent.ID, + listData: app.IssueListData{ + Issues: []*model.Issue{parent}, + Progress: map[int]render.SubIssueProgress{parent.ID: {Done: 0, Total: 1}}, + }, + detailTargetID: parent.ID, + loadingDetail: true, + } + + updated, _ := m.handleBrowseKey("o") + queued := updated.(browserModel) + if queued.pendingDetailFocus != detailFocusSubIssues { + t.Fatalf("pendingDetailFocus = %s, want %s", queued.pendingDetailFocus, detailFocusSubIssues) + } + + updated, _ = queued.Update(detailLoadedMsg{ + requestID: queued.detailRequestID, + id: parent.ID, + data: app.IssueDetailData{Issue: parent, SubIssues: []*model.Issue{child}}, + }) + afterLoad := updated.(browserModel) + if afterLoad.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", afterLoad.detailFocus, detailFocusSubIssues) + } + if afterLoad.pendingDetailFocus != "" { + t.Fatalf("pendingDetailFocus = %q, want empty", afterLoad.pendingDetailFocus) + } +} + +func TestBrowseToEpicToSubIssueFlowWithSQLite(t *testing.T) { + conn := mustOpenBrowserDB(t) + epicID := createBrowserIssue(t, conn, model.Issue{ + Title: "Epic root", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }) + childEpicID := createBrowserIssue(t, conn, model.Issue{ + ParentID: &epicID, + Title: "Child epic", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindEpic, + }) + createBrowserIssue(t, conn, model.Issue{ + ParentID: &childEpicID, + Title: "Leaf task", + Status: model.StatusDone, + Priority: model.PriorityLow, + Kind: model.IssueKindTask, + }) + + listMsg := loadListCmd(conn, defaultListSort, 41)().(listLoadedMsg) + m := NewBrowser(conn, "/tmp/example/.docket").(browserModel) + m.viewRequestID = 41 + updated, cmd := m.Update(listMsg) + loaded := updated.(browserModel) + if cmd == nil { + t.Fatal("expected follow-up detail load after list load") + } + loaded.selectedIssueID = epicID + loaded.listIndex = findIssueIndex(loaded.listData.Issues, epicID) + loaded.resetDetailNavigationToBrowseSelection() + + detailEpic := loadDetailCmd(conn, epicID, 1)().(detailLoadedMsg) + loaded.detailRequestID = 1 + updated, _ = loaded.Update(detailEpic) + afterEpicLoad := updated.(browserModel) + + updated, _ = afterEpicLoad.handleBrowseKey("o") + drilled := updated.(browserModel) + if drilled.focus != focusDetail || drilled.detailFocus != detailFocusSubIssues { + t.Fatalf("drill-down focus = %s/%s, want %s/%s", drilled.focus, drilled.detailFocus, focusDetail, detailFocusSubIssues) + } + + updated, cmd = drilled.handleDetailKey("enter") + afterChildOpen := updated.(browserModel) + if cmd == nil { + t.Fatal("expected child detail load command") + } + if afterChildOpen.detailTargetID != childEpicID { + t.Fatalf("detailTargetID = %d, want %d", afterChildOpen.detailTargetID, childEpicID) + } + + detailChild := loadDetailCmd(conn, childEpicID, afterChildOpen.detailRequestID)().(detailLoadedMsg) + updated, _ = afterChildOpen.Update(detailChild) + afterChildLoad := updated.(browserModel) + + updated, _ = afterChildLoad.handleDetailKey("l") + afterChildFocus := updated.(browserModel) + if afterChildFocus.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", afterChildFocus.detailFocus, detailFocusSubIssues) + } + + updated, cmd = afterChildFocus.handleDetailKey("enter") + afterLeafOpen := updated.(browserModel) + if cmd == nil { + t.Fatal("expected leaf detail load command") + } + if afterLeafOpen.detailTargetID == childEpicID { + t.Fatalf("expected navigation to leaf issue, still at %d", afterLeafOpen.detailTargetID) + } + if len(afterLeafOpen.detailHistory) < 2 { + t.Fatalf("expected stacked detail history, got %#v", afterLeafOpen.detailHistory) + } +} + +func TestHierarchyDecorationsStayConsistentBetweenListAndBoard(t *testing.T) { + conn := mustOpenBrowserDB(t) + epicID := createBrowserIssue(t, conn, model.Issue{ + Title: "Epic row", + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindEpic, + }) + createBrowserIssue(t, conn, model.Issue{ParentID: &epicID, Title: "Child A", Status: model.StatusDone, Priority: model.PriorityMedium, Kind: model.IssueKindTask}) + createBrowserIssue(t, conn, model.Issue{ParentID: &epicID, Title: "Child B", Status: model.StatusTodo, Priority: model.PriorityMedium, Kind: model.IssueKindTask}) + + listMsg := loadListCmd(conn, defaultListSort, 51)().(listLoadedMsg) + boardMsg := loadBoardCmd(conn, 52)().(boardLoadedMsg) + epic := listMsg.data.ParentMap[epicID] + if epic == nil { + for _, issue := range listMsg.data.Issues { + if issue.ID == epicID { + epic = issue + break + } + } + } + if epic == nil { + t.Fatalf("expected epic %d in list data", epicID) + } + + listRendered := render.RenderUIListRow(epic, render.HierarchyDecoration{IsEpic: true, ChildCount: listMsg.data.Progress[epicID].Total, Done: listMsg.data.Progress[epicID].Done, Total: listMsg.data.Progress[epicID].Total}, 64, false) + boardRendered := render.RenderUIBoardColumn(model.StatusTodo, []*model.Issue{epic}, map[int]render.HierarchyDecoration{epicID: {IsEpic: true, ChildCount: boardMsg.data.Progress[epicID].Total, Done: boardMsg.data.Progress[epicID].Done, Total: boardMsg.data.Progress[epicID].Total}}, 64, 8, true, true, 0) + + for _, fragment := range []string{"[2 sub 1/2]"} { + if !strings.Contains(listRendered, fragment) { + t.Fatalf("expected list row to include %q: %q", fragment, listRendered) + } + if !strings.Contains(boardRendered, fragment) { + t.Fatalf("expected board row to include %q: %q", fragment, boardRendered) + } + } +} + +func TestDetailFocusDoesNotIncreaseViewHeightAtNarrowWidths(t *testing.T) { + issueA := testIssue(7, model.StatusTodo) + issueA.Title = "Epic: full read-only docket ui roadmap" + issueB := testIssue(9, model.StatusTodo) + issueB.Title = "Implement: refactor docket ui into a routeable multi-surface read browser" + + m := browserModel{ + width: 90, + height: 28, + view: viewList, + focus: focusDetail, + selectedIssueID: issueA.ID, + listData: app.IssueListData{Issues: []*model.Issue{issueB, issueA}, Total: 15}, + listIndex: 1, + detailIssueID: issueA.ID, + detailTargetID: issueA.ID, + detailData: app.IssueDetailData{Issue: issueA}, + detailFocus: detailFocusBody, + } + + rendered := m.View() + if got := len(strings.Split(rendered, "\n")); got != m.height { + t.Fatalf("view lines = %d, want %d", got, m.height) + } +} + +func TestSubIssueFocusDoesNotIncreaseViewHeight(t *testing.T) { + parent := testIssue(7, model.StatusTodo) + parent.Title = "Epic: full read-only docket ui roadmap" + childA := testIssue(8, model.StatusTodo) + childA.Title = "Phase 1: full-read tui foundation and shared navigation" + childB := testIssue(12, model.StatusTodo) + childB.Title = "Phase 2: issue-centric read parity inside docket ui" + childB.Kind = model.IssueKindEpic + + m := browserModel{ + width: 120, + height: 28, + view: viewList, + focus: focusDetail, + selectedIssueID: parent.ID, + listData: app.IssueListData{Issues: []*model.Issue{parent}, Total: 15}, + detailIssueID: parent.ID, + detailTargetID: parent.ID, + detailData: app.IssueDetailData{Issue: parent, SubIssues: []*model.Issue{childA, childB}}, + detailFocus: detailFocusSubIssues, + detailSubIndex: 1, + } + + rendered := m.View() + if got := len(strings.Split(rendered, "\n")); got != m.height { + t.Fatalf("view lines = %d, want %d", got, m.height) + } +} + +func TestBoardSubIssueFocusDoesNotIncreaseViewHeight(t *testing.T) { + parent := testIssue(7, model.StatusTodo) + parent.Title = "Epic: full read-only docket ui roadmap" + childA := testIssue(8, model.StatusTodo) + childA.Title = "Phase 1: full-read tui foundation and shared navigation" + childA.Kind = model.IssueKindEpic + done := testIssue(1, model.StatusDone) + done.Title = "Feature: read-only interaction" + + m := browserModel{ + width: 120, + height: 28, + view: viewBoard, + focus: focusDetail, + selectedIssueID: parent.ID, + boardColumns: []boardColumn{ + {Status: model.StatusTodo, Issues: []*model.Issue{parent}}, + {Status: model.StatusDone, Issues: []*model.Issue{done}}, + }, + detailIssueID: parent.ID, + detailTargetID: parent.ID, + detailData: app.IssueDetailData{Issue: parent, SubIssues: []*model.Issue{childA}}, + detailFocus: detailFocusSubIssues, + detailSubIndex: 0, + } + + rendered := m.View() + if got := len(strings.Split(rendered, "\n")); got != m.height { + t.Fatalf("view lines = %d, want %d", got, m.height) + } +} + +func TestBoardDetailFocusDoesNotIncreaseViewHeight(t *testing.T) { + parent := testIssue(7, model.StatusTodo) + parent.Title = "Epic: full read-only docket ui roadmap" + done := testIssue(1, model.StatusDone) + done.Title = "Feature: read-only interaction" + + m := browserModel{ + width: 120, + height: 28, + view: viewBoard, + focus: focusDetail, + selectedIssueID: parent.ID, + boardColumns: []boardColumn{ + {Status: model.StatusTodo, Issues: []*model.Issue{parent}}, + {Status: model.StatusDone, Issues: []*model.Issue{done}}, + }, + detailIssueID: parent.ID, + detailTargetID: parent.ID, + detailData: app.IssueDetailData{Issue: parent}, + detailFocus: detailFocusBody, + } + + rendered := m.View() + if got := len(strings.Split(rendered, "\n")); got != m.height { + t.Fatalf("view lines = %d, want %d", got, m.height) + } +} + +func TestTerminalProbeDetection(t *testing.T) { + tests := []struct { + name string + key string + want bool + }{ + {name: "osc response with prefix", key: "]11;rgb:1616/1818/1a1a", want: true}, + {name: "osc response body", key: "11;rgb:1616/1818/1a1a", want: true}, + {name: "alt close bracket", key: "alt+]", want: true}, + {name: "alt backslash", key: "alt+\\", want: true}, + {name: "normal view hotkey", key: "1", want: false}, + {name: "help hotkey", key: "?", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTerminalProbe(tt.key); got != tt.want { + t.Fatalf("isTerminalProbe(%q) = %t, want %t", tt.key, got, tt.want) + } + }) + } +} + +func TestStaleDetailMessagesAreIgnored(t *testing.T) { + m := browserModel{ + view: viewBoard, + selectedIssueID: 2, + detailTargetID: 2, + detailRequestID: 4, + detailIssueID: 2, + detailData: app.IssueDetailData{ + Issue: testIssue(2, model.StatusReview), + }, + } + + updated, _ := m.Update(detailLoadedMsg{ + requestID: 3, + id: 1, + data: app.IssueDetailData{ + Issue: testIssue(1, model.StatusTodo), + }, + }) + + next := updated.(browserModel) + if next.detailIssueID != 2 { + t.Fatalf("detailIssueID = %d, want 2", next.detailIssueID) + } + if next.detailData.Issue == nil || next.detailData.Issue.ID != 2 { + t.Fatalf("detail issue changed unexpectedly: %#v", next.detailData.Issue) + } +} + +func TestDetailLoadMessageUpdatesDataAndNormalizesSubIssueState(t *testing.T) { + parent := testIssue(10, model.StatusTodo) + child := testIssue(11, model.StatusDone) + m := browserModel{ + selectedIssueID: 10, + detailTargetID: 10, + detailRequestID: 7, + loadingDetail: true, + detailFocus: detailFocusSubIssues, + detailSubIndex: 9, + } + + updated, cmd := m.Update(detailLoadedMsg{ + requestID: 7, + id: 10, + data: app.IssueDetailData{ + Issue: parent, + SubIssues: []*model.Issue{child}, + }, + }) + + next := updated.(browserModel) + if next.loadingDetail { + t.Fatal("expected detail loading to stop") + } + if next.detailIssueID != 10 { + t.Fatalf("detailIssueID = %d, want 10", next.detailIssueID) + } + if next.detailSubIndex != 0 { + t.Fatalf("detailSubIndex = %d, want 0", next.detailSubIndex) + } + if next.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", next.detailFocus, detailFocusSubIssues) + } + if cmd != nil { + t.Fatal("expected no follow-up command after successful detail load") + } +} + +func TestDetailLoadErrorClearsDetailIssueID(t *testing.T) { + m := browserModel{ + selectedIssueID: 2, + detailTargetID: 2, + detailRequestID: 3, + loadingDetail: true, + detailIssueID: 2, + } + + updated, _ := m.Update(detailLoadedMsg{requestID: 3, id: 2, err: os.ErrPermission}) + next := updated.(browserModel) + if next.detailErr == "" { + t.Fatal("expected detail error") + } + if next.detailIssueID != 0 { + t.Fatalf("detailIssueID = %d, want 0", next.detailIssueID) + } +} + +func TestBeginDetailLoadWithoutTargetClearsDetailState(t *testing.T) { + m := browserModel{ + detailIssueID: 5, + loadingDetail: true, + detailData: app.IssueDetailData{Issue: testIssue(5, model.StatusTodo)}, + selectedIssueID: 0, + } + if cmd := m.beginDetailLoad(); cmd != nil { + t.Fatal("expected nil detail load command") + } + if m.loadingDetail || m.detailIssueID != 0 || m.detailData.Issue != nil { + t.Fatalf("detail state = %#v", m) + } +} + +func TestNormalizeDetailStateResetsBodyWhenNoSubIssues(t *testing.T) { + m := browserModel{ + detailFocus: detailFocusSubIssues, + detailSubIndex: 3, + } + m.normalizeDetailState() + if m.detailFocus != detailFocusBody || m.detailSubIndex != 0 { + t.Fatalf("normalizeDetailState() = focus:%s index:%d", m.detailFocus, m.detailSubIndex) + } +} + +func TestDetailSubIssueNavigationOpensChild(t *testing.T) { + parent := testIssue(1, model.StatusTodo) + childA := testIssue(2, model.StatusTodo) + childB := testIssue(3, model.StatusReview) + m := testDetailModel(parent, childA, childB) + + updated, _ := m.handleDetailKey("l") + afterFocus := updated.(browserModel) + if afterFocus.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", afterFocus.detailFocus, detailFocusSubIssues) + } + + updated, _ = afterFocus.handleDetailKey("j") + afterMove := updated.(browserModel) + if afterMove.detailSubIndex != 1 { + t.Fatalf("detailSubIndex = %d, want 1", afterMove.detailSubIndex) + } + + updated, _ = afterMove.handleDetailKey("enter") + afterOpen := updated.(browserModel) + if afterOpen.detailTargetID != childB.ID { + t.Fatalf("detailTargetID = %d, want %d", afterOpen.detailTargetID, childB.ID) + } + if !afterOpen.loadingDetail { + t.Fatalf("expected loadingDetail to be true") + } + if len(afterOpen.detailHistory) != 1 || afterOpen.detailHistory[0].issueID != parent.ID { + t.Fatalf("detailHistory = %#v, want parent issue on stack", afterOpen.detailHistory) + } + if afterOpen.detailFocus != detailFocusBody { + t.Fatalf("detailFocus = %s, want %s", afterOpen.detailFocus, detailFocusBody) + } +} + +func TestEscClosesExpandedBeforeGoingBack(t *testing.T) { + parent := testIssue(1, model.StatusTodo) + child := testIssue(2, model.StatusTodo) + m := testDetailModel(child) + m.detailExpanded = true + m.detailHistory = []detailNavState{{issueID: parent.ID, focusRegion: detailFocusSubIssues, subIssueIndex: 0}} + + updated, _ := m.handleDetailKey("esc") + afterClose := updated.(browserModel) + if afterClose.detailExpanded { + t.Fatalf("expected expanded detail to close first") + } + if afterClose.detailTargetID != child.ID { + t.Fatalf("detailTargetID = %d, want %d", afterClose.detailTargetID, child.ID) + } + if len(afterClose.detailHistory) != 1 { + t.Fatalf("history length = %d, want 1", len(afterClose.detailHistory)) + } + if afterClose.focus != focusDetail { + t.Fatalf("focus = %s, want %s", afterClose.focus, focusDetail) + } + + updated, _ = afterClose.handleDetailKey("esc") + afterBack := updated.(browserModel) + if afterBack.detailTargetID != parent.ID { + t.Fatalf("detailTargetID = %d, want %d", afterBack.detailTargetID, parent.ID) + } + if len(afterBack.detailHistory) != 0 { + t.Fatalf("history length = %d, want 0", len(afterBack.detailHistory)) + } + if !afterBack.loadingDetail { + t.Fatalf("expected loadingDetail to be true after back navigation") + } + if afterBack.detailFocus != detailFocusSubIssues { + t.Fatalf("detailFocus = %s, want %s", afterBack.detailFocus, detailFocusSubIssues) + } +} + +func TestCtrlDAndCtrlUPageFocusedDetailRegion(t *testing.T) { + issue := testIssue(1, model.StatusTodo) + issue.Description = strings.Repeat("line\n", 80) + childA := testIssue(2, model.StatusTodo) + childB := testIssue(3, model.StatusTodo) + childC := testIssue(4, model.StatusTodo) + childD := testIssue(5, model.StatusTodo) + m := testDetailModel(issue, childA, childB, childC, childD) + + updated, _ := m.handleDetailKey("ctrl+d") + afterPageDown := updated.(browserModel) + if afterPageDown.detailScroll <= 0 { + t.Fatalf("detailScroll = %d, want > 0", afterPageDown.detailScroll) + } + + updated, _ = afterPageDown.handleDetailKey("ctrl+u") + afterPageUp := updated.(browserModel) + if afterPageUp.detailScroll >= afterPageDown.detailScroll { + t.Fatalf("detailScroll = %d, want < %d", afterPageUp.detailScroll, afterPageDown.detailScroll) + } + + updated, _ = afterPageUp.handleDetailKey("l") + afterFocus := updated.(browserModel) + updated, _ = afterFocus.handleDetailKey("ctrl+d") + afterSubPageDown := updated.(browserModel) + if afterSubPageDown.detailSubIndex <= 0 { + t.Fatalf("detailSubIndex = %d, want > 0", afterSubPageDown.detailSubIndex) + } + + updated, _ = afterSubPageDown.handleDetailKey("ctrl+u") + afterSubPageUp := updated.(browserModel) + if afterSubPageUp.detailSubIndex >= afterSubPageDown.detailSubIndex { + t.Fatalf("detailSubIndex = %d, want < %d", afterSubPageUp.detailSubIndex, afterSubPageDown.detailSubIndex) + } +} + +func TestUGoesToParentWhenHistoryIsEmpty(t *testing.T) { + parentID := 1 + child := testIssue(2, model.StatusTodo) + child.ParentID = &parentID + m := testDetailModel(child) + + updated, _ := m.handleDetailKey("u") + afterParent := updated.(browserModel) + if afterParent.detailTargetID != parentID { + t.Fatalf("detailTargetID = %d, want %d", afterParent.detailTargetID, parentID) + } + if len(afterParent.detailHistory) != 1 || afterParent.detailHistory[0].issueID != child.ID { + t.Fatalf("detailHistory = %#v, want child issue on stack", afterParent.detailHistory) + } + if !afterParent.loadingDetail { + t.Fatalf("expected loadingDetail to be true") + } +} + +func TestEscLeavesSubIssueFocusBeforeNavigatingBack(t *testing.T) { + parent := testIssue(1, model.StatusTodo) + child := testIssue(2, model.StatusTodo) + m := testDetailModel(parent, child) + m.detailFocus = detailFocusSubIssues + m.detailHistory = []detailNavState{{issueID: 99, focusRegion: detailFocusBody}} + + updated, _ := m.handleDetailKey("esc") + afterEsc := updated.(browserModel) + if afterEsc.detailFocus != detailFocusBody { + t.Fatalf("detailFocus = %s, want %s", afterEsc.detailFocus, detailFocusBody) + } + if len(afterEsc.detailHistory) != 1 { + t.Fatalf("history length = %d, want 1", len(afterEsc.detailHistory)) + } + if afterEsc.detailTargetID != parent.ID { + t.Fatalf("detailTargetID = %d, want %d", afterEsc.detailTargetID, parent.ID) + } +} + +func TestUPrefersHistoryBeforeParent(t *testing.T) { + parentID := 1 + otherID := 99 + child := testIssue(2, model.StatusTodo) + child.ParentID = &parentID + m := testDetailModel(child) + m.detailHistory = []detailNavState{{issueID: otherID, focusRegion: detailFocusBody}} + + updated, _ := m.handleDetailKey("u") + afterBack := updated.(browserModel) + if afterBack.detailTargetID != otherID { + t.Fatalf("detailTargetID = %d, want %d", afterBack.detailTargetID, otherID) + } + if len(afterBack.detailHistory) != 0 { + t.Fatalf("history length = %d, want 0", len(afterBack.detailHistory)) + } +} + +func TestTabIgnoredWhileDetailExpanded(t *testing.T) { + m := testDetailModel(testIssue(1, model.StatusTodo)) + m.detailExpanded = true + m.focus = focusDetail + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + afterTab := updated.(browserModel) + if afterTab.focus != focusDetail { + t.Fatalf("focus = %s, want %s", afterTab.focus, focusDetail) + } + if !afterTab.detailExpanded { + t.Fatalf("expected detailExpanded to remain true") + } +} + +func TestTabSwitchesBetweenBrowseAndDetail(t *testing.T) { + m := testDetailModel(testIssue(1, model.StatusTodo)) + m.focus = focusBrowse + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + afterDetail := updated.(browserModel) + if afterDetail.focus != focusDetail { + t.Fatalf("focus = %s, want %s", afterDetail.focus, focusDetail) + } + + updated, _ = afterDetail.Update(tea.KeyMsg{Type: tea.KeyTab}) + afterBrowse := updated.(browserModel) + if afterBrowse.focus != focusBrowse { + t.Fatalf("focus = %s, want %s", afterBrowse.focus, focusBrowse) + } +} + +func TestBrowseEnterExpandsDetailInListAndBoardViews(t *testing.T) { + listModel := browserModel{ + view: viewList, + focus: focusBrowse, + selectedIssueID: 1, + listData: app.IssueListData{Issues: []*model.Issue{testIssue(1, model.StatusTodo)}}, + } + updated, _ := listModel.handleBrowseKey("enter") + afterList := updated.(browserModel) + if !afterList.detailExpanded || afterList.focus != focusDetail { + t.Fatalf("list expand = expanded:%t focus:%s", afterList.detailExpanded, afterList.focus) + } + + boardModel := browserModel{ + view: viewBoard, + focus: focusBrowse, + selectedIssueID: 2, + boardColumns: []boardColumn{{ + Status: model.StatusTodo, + Issues: []*model.Issue{testIssue(2, model.StatusTodo)}, + }}, + } + updated, _ = boardModel.handleBrowseKey("enter") + afterBoard := updated.(browserModel) + if !afterBoard.detailExpanded || afterBoard.focus != focusDetail { + t.Fatalf("board expand = expanded:%t focus:%s", afterBoard.detailExpanded, afterBoard.focus) + } +} + +func TestUtilityHelpersCoverEdgeCases(t *testing.T) { + issues := []*model.Issue{testIssue(1, model.StatusTodo), testIssue(2, model.StatusDone)} + if got := findIssueIndex(issues, 2); got != 1 { + t.Fatalf("findIssueIndex = %d, want 1", got) + } + if got := findIssueIndex(issues, 99); got != -1 { + t.Fatalf("findIssueIndex = %d, want -1", got) + } + if got := truncate("abcdef", 4); got != "a..." { + t.Fatalf("truncate = %q, want %q", got, "a...") + } + if got := truncate("abcdef", 3); got != "abc" { + t.Fatalf("truncate short = %q, want %q", got, "abc") + } + start, end := windowBounds(5, 10, 3) + if start != 4 || end != 7 { + t.Fatalf("windowBounds = %d/%d, want 4/7", start, end) + } + start, end = detailWindow(20, 10, 3) + if start != 7 || end != 10 { + t.Fatalf("detailWindow = %d/%d, want 7/10", start, end) + } +} + +func TestDebugLogfWritesWhenPathConfigured(t *testing.T) { + path := t.TempDir() + "/tui.log" + t.Setenv("DOCKET_TUI_DEBUG_LOG", path) + debugLogf("hello %s", "world") + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(data), "hello world") { + t.Fatalf("log = %q, want message", string(data)) + } +} + +func TestDebugLogfIgnoresLegacyUIDebugPath(t *testing.T) { + legacyPath := t.TempDir() + "/ui.log" + t.Setenv("DOCKET_UI_DEBUG_LOG", legacyPath) + debugLogf("legacy path should stay unused") + + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("expected legacy ui debug path to remain unused, stat err = %v", err) + } +} + +func TestBrowseFocusedJScrollsDetailBody(t *testing.T) { + issue := testIssue(1, model.StatusTodo) + issue.Description = strings.Repeat("line\n", 80) + m := testDetailModel(issue) + m.focus = focusBrowse + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}) + afterProxy := updated.(browserModel) + if afterProxy.detailScroll <= 0 { + t.Fatalf("detailScroll = %d, want > 0", afterProxy.detailScroll) + } + if afterProxy.focus != focusBrowse { + t.Fatalf("focus = %s, want %s", afterProxy.focus, focusBrowse) + } + + updated, _ = afterProxy.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'K'}}) + afterReverse := updated.(browserModel) + if afterReverse.detailScroll >= afterProxy.detailScroll { + t.Fatalf("detailScroll = %d, want < %d", afterReverse.detailScroll, afterProxy.detailScroll) + } +} + +func TestBrowseFocusedJMovesCurrentDetailSubIssueRegion(t *testing.T) { + parent := testIssue(1, model.StatusTodo) + childA := testIssue(2, model.StatusTodo) + childB := testIssue(3, model.StatusTodo) + m := testDetailModel(parent, childA, childB) + m.focus = focusBrowse + m.detailFocus = detailFocusSubIssues + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}) + afterProxy := updated.(browserModel) + if afterProxy.detailSubIndex != 1 { + t.Fatalf("detailSubIndex = %d, want 1", afterProxy.detailSubIndex) + } + if afterProxy.focus != focusBrowse { + t.Fatalf("focus = %s, want %s", afterProxy.focus, focusBrowse) + } + + updated, _ = afterProxy.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'K'}}) + afterReverse := updated.(browserModel) + if afterReverse.detailSubIndex != 0 { + t.Fatalf("detailSubIndex = %d, want 0", afterReverse.detailSubIndex) + } +} + +func TestDetailFocusedJDoesNotMoveBrowsePane(t *testing.T) { + issueA := testIssue(1, model.StatusTodo) + issueB := testIssue(2, model.StatusTodo) + m := testDetailModel(issueA) + m.view = viewList + m.focus = focusDetail + m.listData = app.IssueListData{Issues: []*model.Issue{issueA, issueB}} + m.listIndex = 0 + m.selectedIssueID = issueA.ID + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}) + afterKey := updated.(browserModel) + if afterKey.listIndex != 0 { + t.Fatalf("listIndex = %d, want 0", afterKey.listIndex) + } + if afterKey.selectedIssueID != issueA.ID { + t.Fatalf("selectedIssueID = %d, want %d", afterKey.selectedIssueID, issueA.ID) + } +} + +func TestBrowseFocusedJNoOpWhenDetailExpanded(t *testing.T) { + issue := testIssue(1, model.StatusTodo) + issue.Description = strings.Repeat("line\n", 80) + m := testDetailModel(issue) + m.focus = focusBrowse + m.detailExpanded = true + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}) + afterKey := updated.(browserModel) + if afterKey.detailScroll != 0 { + t.Fatalf("detailScroll = %d, want 0", afterKey.detailScroll) + } + if !afterKey.detailExpanded { + t.Fatalf("expected detailExpanded to remain true") + } +} diff --git a/internal/tui/browser_update.go b/internal/tui/browser_update.go new file mode 100644 index 0000000..2e06afe --- /dev/null +++ b/internal/tui/browser_update.go @@ -0,0 +1,366 @@ +package tui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m browserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + debugLogf("window size: %dx%d", msg.Width, msg.Height) + m.width = msg.Width + m.height = msg.Height + return m, nil + case listLoadedMsg: + return m.updateListLoaded(msg) + case boardLoadedMsg: + return m.updateBoardLoaded(msg) + case detailLoadedMsg: + return m.updateDetailLoaded(msg) + case refreshTickMsg: + return m.updateRefreshTick(msg) + case tea.KeyMsg: + debugLogf("key: %q view=%s focus=%s selected=%d expanded=%t", msg.String(), m.view, m.focus, m.selectedIssueID, m.detailExpanded) + if isTerminalProbe(msg.String()) { + debugLogf("ignoring terminal probe key: %q", msg.String()) + return m, nil + } + return m.handleKey(msg) + default: + return m, nil + } +} + +func (m browserModel) updateListLoaded(msg listLoadedMsg) (tea.Model, tea.Cmd) { + debugLogf("list loaded: request=%d current=%d view=%s currentView=%s err=%v total=%d", msg.requestID, m.viewRequestID, msg.view, m.view, msg.err, msg.data.Total) + if msg.requestID != m.viewRequestID || msg.view != m.view { + debugEventf("stale_response_ignored", "kind=list request_id=%d current_request_id=%d view=%s current_view=%s", msg.requestID, m.viewRequestID, msg.view, m.view) + return m, nil + } + m.loading = false + m.refreshState.Pending = false + if msg.err != nil { + m.refreshState.LastError = msg.err.Error() + debugEventf("refresh_failed", "view=%s request_id=%d err=%q", msg.view, msg.requestID, msg.err.Error()) + if !m.refreshState.LoadedOnce { + m.errMsg = msg.err.Error() + m.listData = listLoadedMsg{}.data + } + return m, m.nextRefreshCmd() + } + m.errMsg = "" + m.refreshState.LoadedOnce = true + m.refreshState.LastError = "" + m.refreshState.LastSuccess = time.Now() + m.listData = msg.data + m.reconcileListSelection() + debugEventf("refresh_completed", "view=%s request_id=%d issue_count=%d", msg.view, msg.requestID, len(msg.data.Issues)) + cmd := m.beginDetailLoad() + return m, tea.Batch(cmd, m.nextRefreshCmd()) +} + +func (m browserModel) updateBoardLoaded(msg boardLoadedMsg) (tea.Model, tea.Cmd) { + debugLogf("board loaded: request=%d current=%d view=%s currentView=%s err=%v total=%d", msg.requestID, m.viewRequestID, msg.view, m.view, msg.err, msg.data.Total) + if msg.requestID != m.viewRequestID || msg.view != m.view { + debugEventf("stale_response_ignored", "kind=board request_id=%d current_request_id=%d view=%s current_view=%s", msg.requestID, m.viewRequestID, msg.view, m.view) + return m, nil + } + m.loading = false + m.refreshState.Pending = false + if msg.err != nil { + m.refreshState.LastError = msg.err.Error() + debugEventf("refresh_failed", "view=%s request_id=%d err=%q", msg.view, msg.requestID, msg.err.Error()) + if !m.refreshState.LoadedOnce { + m.errMsg = msg.err.Error() + m.boardData = boardLoadedMsg{}.data + m.boardColumns = nil + } + return m, m.nextRefreshCmd() + } + m.errMsg = "" + m.refreshState.LoadedOnce = true + m.refreshState.LastError = "" + m.refreshState.LastSuccess = time.Now() + m.boardData = msg.data + m.boardColumns = groupBoardColumns(msg.data.Issues) + m.reconcileBoardSelection() + debugEventf("refresh_completed", "view=%s request_id=%d issue_count=%d", msg.view, msg.requestID, len(msg.data.Issues)) + cmd := m.beginDetailLoad() + return m, tea.Batch(cmd, m.nextRefreshCmd()) +} + +func (m browserModel) updateDetailLoaded(msg detailLoadedMsg) (tea.Model, tea.Cmd) { + debugLogf("detail loaded: request=%d current=%d id=%d target=%d err=%v", msg.requestID, m.detailRequestID, msg.id, m.currentDetailTargetID(), msg.err) + if msg.requestID != m.detailRequestID || msg.id != m.currentDetailTargetID() { + debugEventf("stale_response_ignored", "kind=detail request_id=%d current_request_id=%d issue_id=%d target_issue_id=%d", msg.requestID, m.detailRequestID, msg.id, m.currentDetailTargetID()) + return m, nil + } + m.loadingDetail = false + if msg.err != nil { + m.detailErr = msg.err.Error() + m.detailIssueID = 0 + return m, nil + } + m.detailErr = "" + m.detailIssueID = msg.id + m.detailData = msg.data + m.normalizeDetailState() + if m.pendingDetailFocus != "" { + if m.pendingDetailFocus == detailFocusSubIssues && len(m.detailData.SubIssues) > 0 { + m.detailFocus = detailFocusSubIssues + } else { + m.detailFocus = detailFocusBody + } + m.pendingDetailFocus = "" + } + return m, nil +} + +func (m browserModel) updateRefreshTick(msg refreshTickMsg) (tea.Model, tea.Cmd) { + if msg.tickID != m.refreshTickID { + debugEventf("stale_response_ignored", "kind=refresh_tick tick_id=%d current_tick_id=%d", msg.tickID, m.refreshTickID) + return m, nil + } + if !m.refreshPolicy.Enabled || m.loading { + return m, nil + } + return m, m.beginViewLoad() +} + +func (m browserModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" || key == "q" { + return m, tea.Quit + } + + if m.showHelp { + switch key { + case "?", "esc", "enter": + m.showHelp = false + } + return m, nil + } + + switch key { + case "?": + m.showHelp = true + return m, nil + case "r": + return m, m.beginViewLoad() + case "p": + m.refreshPolicy.Enabled = !m.refreshPolicy.Enabled + debugEventf("auto_refresh_toggled", "enabled=%t interval=%s", m.refreshPolicy.Enabled, m.refreshPolicy.Interval) + if !m.refreshPolicy.Enabled { + m.refreshTickID++ + return m, nil + } + return m, m.nextRefreshCmd() + case "1": + if m.view == viewList { + return m, nil + } + m.view = viewList + m.detailExpanded = false + m.focus = focusBrowse + m.resetDetailNavigationToBrowseSelection() + return m, m.beginViewLoad() + case "2": + if m.view == viewBoard { + return m, nil + } + m.view = viewBoard + m.detailExpanded = false + m.focus = focusBrowse + m.resetDetailNavigationToBrowseSelection() + return m, m.beginViewLoad() + case "tab": + if m.selectedIssueID == 0 || m.detailExpanded { + return m, nil + } + if m.focus == focusBrowse { + m.focus = focusDetail + } else { + m.focus = focusBrowse + } + return m, nil + case "J": + if m.focus == focusBrowse && !m.detailExpanded && m.proxyDetailMovement(1) { + return m, nil + } + case "K": + if m.focus == focusBrowse && !m.detailExpanded && m.proxyDetailMovement(-1) { + return m, nil + } + } + + if m.focus == focusDetail || m.detailExpanded { + return m.handleDetailKey(key) + } + + return m.handleBrowseKey(key) +} + +func (m browserModel) handleBrowseKey(key string) (tea.Model, tea.Cmd) { + var changed bool + + switch m.view { + case viewList: + switch key { + case "j", "down": + changed = m.moveListSelection(1) + case "k", "up": + changed = m.moveListSelection(-1) + case "s": + if !m.cycleListSortField(1) { + return m, nil + } + return m, m.beginViewLoad() + case "S": + m.toggleListSortDirection() + return m, m.beginViewLoad() + case "o": + return m, m.enterSelectedIssueHierarchy() + case "enter": + if m.selectedIssueID == 0 { + return m, nil + } + m.detailExpanded = !m.detailExpanded + if m.detailExpanded { + m.focus = focusDetail + } + return m, nil + } + case viewBoard: + switch key { + case "j", "down": + changed = m.moveBoardRow(1) + case "k", "up": + changed = m.moveBoardRow(-1) + case "o": + return m, m.enterSelectedIssueHierarchy() + case "h", "left": + changed = m.moveBoardColumn(-1) + case "l", "right": + changed = m.moveBoardColumn(1) + case "enter": + if m.selectedIssueID == 0 { + return m, nil + } + m.detailExpanded = !m.detailExpanded + if m.detailExpanded { + m.focus = focusDetail + } + return m, nil + } + } + + if !changed { + return m, nil + } + + m.resetDetailNavigationToBrowseSelection() + return m, m.beginDetailLoad() +} + +func (m browserModel) handleDetailKey(key string) (tea.Model, tea.Cmd) { + bodyVisibleLines, subIssueVisibleRows := m.detailContentHeights() + maxScroll := max(m.maxDetailScroll(bodyVisibleLines), 0) + + switch key { + case "esc": + if m.detailExpanded { + m.detailExpanded = false + m.focus = focusDetail + return m, nil + } + if m.detailFocus == detailFocusSubIssues { + m.detailFocus = detailFocusBody + return m, nil + } + if cmd, ok := m.navigateDetailBack(); ok { + return m, cmd + } + return m, nil + case "u": + if cmd, ok := m.navigateDetailParentOrBack(); ok { + return m, cmd + } + return m, nil + case "h", "left": + if m.detailFocus == detailFocusSubIssues { + m.detailFocus = detailFocusBody + } + return m, nil + case "l", "right": + if len(m.detailData.SubIssues) > 0 { + m.detailFocus = detailFocusSubIssues + } + return m, nil + case "enter": + if m.detailFocus == detailFocusSubIssues { + if cmd, ok := m.openSelectedSubIssue(); ok { + return m, cmd + } + return m, nil + } + if m.currentDetailTargetID() == 0 { + return m, nil + } + m.detailExpanded = !m.detailExpanded + m.focus = focusDetail + return m, nil + case "j", "down": + if m.detailFocus == detailFocusSubIssues { + m.moveDetailSubIssueSelection(1) + return m, nil + } + m.detailScroll = min(m.detailScroll+1, maxScroll) + case "k", "up": + if m.detailFocus == detailFocusSubIssues { + m.moveDetailSubIssueSelection(-1) + return m, nil + } + m.detailScroll = max(m.detailScroll-1, 0) + case "pgdown": + if m.detailFocus == detailFocusSubIssues { + m.moveDetailSubIssueSelection(subIssueVisibleRows) + return m, nil + } + m.detailScroll = min(m.detailScroll+bodyVisibleLines, maxScroll) + case "pgup": + if m.detailFocus == detailFocusSubIssues { + m.moveDetailSubIssueSelection(-subIssueVisibleRows) + return m, nil + } + m.detailScroll = max(m.detailScroll-bodyVisibleLines, 0) + case "ctrl+d": + if m.detailFocus == detailFocusSubIssues { + m.moveDetailSubIssueSelection(max(subIssueVisibleRows/2, 1)) + return m, nil + } + m.detailScroll = min(m.detailScroll+max(bodyVisibleLines/2, 1), maxScroll) + case "ctrl+u": + if m.detailFocus == detailFocusSubIssues { + m.moveDetailSubIssueSelection(-max(subIssueVisibleRows/2, 1)) + return m, nil + } + m.detailScroll = max(m.detailScroll-max(bodyVisibleLines/2, 1), 0) + case "home": + if m.detailFocus == detailFocusSubIssues { + m.detailSubIndex = 0 + return m, nil + } + m.detailScroll = 0 + case "end": + if m.detailFocus == detailFocusSubIssues { + m.detailSubIndex = max(len(m.detailData.SubIssues)-1, 0) + return m, nil + } + m.detailScroll = maxScroll + } + + return m, nil +} diff --git a/internal/tui/browser_util.go b/internal/tui/browser_util.go new file mode 100644 index 0000000..f04e10f --- /dev/null +++ b/internal/tui/browser_util.go @@ -0,0 +1,151 @@ +package tui + +import ( + "strings" + + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/render" +) + +var listSortFields = []string{"id", "title", "status", "priority", "kind", "assignee", "created_at", "updated_at"} + +func isTerminalProbe(key string) bool { + if key == "alt+\\" || key == "alt+]" { + return true + } + return strings.Contains(key, "rgb:") || strings.Contains(key, "]11;") +} + +func groupBoardColumns(issues []*model.Issue) []boardColumn { + grouped := make(map[model.Status][]*model.Issue) + for _, issue := range issues { + grouped[issue.Status] = append(grouped[issue.Status], issue) + } + + cols := make([]boardColumn, 0, len(render.StatusOrder)) + for _, status := range render.StatusOrder { + if len(grouped[status]) == 0 { + continue + } + cols = append(cols, boardColumn{Status: status, Issues: grouped[status]}) + } + return cols +} + +func findIssueIndex(issues []*model.Issue, id int) int { + for i, issue := range issues { + if issue.ID == id { + return i + } + } + return -1 +} + +func findBoardIssue(columns []boardColumn, id int) (int, int) { + for colIdx, col := range columns { + for rowIdx, issue := range col.Issues { + if issue.ID == id { + return colIdx, rowIdx + } + } + } + return -1, -1 +} + +func windowBounds(selected, total, height int) (int, int) { + if total == 0 { + return 0, 0 + } + if height >= total { + return 0, total + } + start := selected - height/2 + if start < 0 { + start = 0 + } + end := start + height + if end > total { + end = total + start = end - height + } + return start, end +} + +func detailWindow(scroll, total, height int) (int, int) { + if total == 0 { + return 0, 0 + } + if height >= total { + return 0, total + } + start := clamp(scroll, 0, max(total-height, 0)) + return start, min(start+height, total) +} + +func clamp(n, low, high int) int { + if n < low { + return low + } + if n > high { + return high + } + return n +} + +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= width { + return s + } + if width <= 3 { + return string(runes[:width]) + } + return string(runes[:width-3]) + "..." +} + +func (m *browserModel) cycleListSortField(delta int) bool { + if len(listSortFields) == 0 { + return false + } + idx := 0 + for i, field := range listSortFields { + if field == m.listSort.Field { + idx = i + break + } + } + next := (idx + delta) % len(listSortFields) + if next < 0 { + next += len(listSortFields) + } + if listSortFields[next] == m.listSort.Field { + return false + } + m.listSort.Field = listSortFields[next] + return true +} + +func (m *browserModel) toggleListSortDirection() { + if strings.EqualFold(m.listSort.Dir, "asc") { + m.listSort.Dir = "desc" + return + } + m.listSort.Dir = "asc" +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/scripts/qa.sh b/scripts/qa.sh index 6fca8d6..a9d8397 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -104,6 +104,7 @@ SECTIONS=( ZA:test_za_stats ZB:test_zb_board ZC:test_zc_export_import + ZD:test_zd_ui ) REACHED_TARGET=false diff --git a/scripts/qa/test_zd_ui.sh b/scripts/qa/test_zd_ui.sh new file mode 100644 index 0000000..8da49b3 --- /dev/null +++ b/scripts/qa/test_zd_ui.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Section ZD: TUI Command + +test_zd_ui() { + printf "Section ZD: TUI Command" + + # ZD1: Help text renders successfully. + run tui --help + assert_exit "ZD" "ZD1" 0 + assert_stdout_contains "ZD" "ZD1_use" "docket tui" + + # ZD2: Non-interactive mode rejects the TUI command. + run tui + assert_exit "ZD" "ZD2" 3 + assert_stderr_contains "ZD" "ZD2_err" "requires an interactive terminal" + + # ZD3: --json remains an explicit unsupported exception with JSON envelope. + run tui --json + assert_exit "ZD" "ZD3" 3 + assert_json "ZD" "ZD3_ok" ".ok" "false" + assert_json "ZD" "ZD3_code" ".code" "VALIDATION_ERROR" + assert_json "ZD" "ZD3_error" ".error" "--json is not supported with 'docket tui'" +}