savepoint 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +12 -1
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +37 -36
- package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
- package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
- package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
- package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
- package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
- package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
- package/.savepoint/router.md +9 -16
- package/AGENTS.md +38 -23
- package/README.md +0 -1
- package/agent-skills/savepoint-audit/SKILL.md +86 -34
- package/agent-skills/savepoint-build-task/SKILL.md +7 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
- package/agent-skills/savepoint-system-design/SKILL.md +7 -2
- package/agent_skills_test.go +91 -0
- package/cmd/board.go +59 -0
- package/cmd/board_test.go +137 -0
- package/cmd/doctor.go +53 -0
- package/cmd/doctor_test.go +146 -0
- package/cmd/init.go +63 -0
- package/cmd/init_test.go +104 -0
- package/internal/board/board.go +40 -36
- package/internal/board/board_test.go +27 -82
- package/internal/board/card.go +43 -23
- package/internal/board/card_test.go +41 -5
- package/internal/board/column.go +44 -13
- package/internal/board/column_test.go +5 -2
- package/internal/board/detail.go +0 -47
- package/internal/board/epic_panel.go +118 -22
- package/internal/board/epic_panel_test.go +302 -17
- package/internal/board/help.go +1 -0
- package/internal/board/help_test.go +1 -0
- package/internal/board/integration_test.go +266 -0
- package/internal/board/interfaces.go +65 -0
- package/internal/board/interfaces_test.go +114 -0
- package/internal/board/io.go +93 -0
- package/internal/board/model.go +79 -118
- package/internal/board/plain.go +88 -0
- package/internal/board/plain_test.go +117 -0
- package/internal/board/release.go +1 -9
- package/internal/board/release_test.go +6 -6
- package/internal/board/status.go +4 -4
- package/internal/board/theme.go +24 -0
- package/internal/board/theme_test.go +31 -0
- package/internal/board/transitions.go +113 -88
- package/internal/board/transitions_test.go +164 -141
- package/internal/board/tui.go +32 -0
- package/internal/board/update.go +325 -215
- package/internal/board/update_test.go +299 -18
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +31 -28
- package/internal/board/view_test.go +12 -2
- package/internal/board/watch.go +35 -5
- package/internal/buildtool/main.go +2 -10
- package/internal/buildtool/main_test.go +46 -0
- package/internal/data/config.go +17 -3
- package/internal/data/config_test.go +49 -0
- package/internal/data/discover.go +26 -0
- package/internal/data/discover_test.go +34 -10
- package/internal/data/errors.go +4 -0
- package/internal/data/lifecycle.go +13 -6
- package/internal/data/lifecycle_test.go +14 -11
- package/internal/data/parser.go +21 -6
- package/internal/data/parser_test.go +31 -7
- package/internal/data/task.go +0 -9
- package/internal/data/write.go +85 -11
- package/internal/data/write_test.go +167 -0
- package/internal/doctor/checks.go +567 -0
- package/internal/doctor/checks_test.go +716 -0
- package/internal/doctor/gates.go +193 -0
- package/internal/doctor/gates_test.go +166 -0
- package/internal/doctor/interfaces.go +64 -0
- package/internal/doctor/interfaces_test.go +104 -0
- package/internal/doctor/repairs.go +80 -0
- package/internal/doctor/repairs_test.go +81 -0
- package/internal/doctor/report.go +157 -0
- package/internal/doctor/report_test.go +89 -0
- package/internal/init/clipboard.go +146 -0
- package/internal/init/clipboard_test.go +74 -0
- package/internal/init/install.go +16 -0
- package/internal/init/integration_test.go +197 -0
- package/internal/init/prompt.go +14 -0
- package/internal/init/prompt_test.go +77 -0
- package/internal/init/scaffold.go +59 -0
- package/internal/init/scaffold_test.go +179 -0
- package/internal/init/template_freshness_test.go +56 -0
- package/internal/init/validate.go +85 -0
- package/internal/init/validate_test.go +141 -0
- package/internal/init/write.go +73 -0
- package/internal/init/write_test.go +91 -0
- package/internal/styles/styles_test.go +133 -0
- package/internal/testutil/fixture.go +113 -0
- package/internal/testutil/fs.go +26 -0
- package/main.go +101 -4
- package/package.json +2 -2
- package/project-audit/audit_report_glm_5.1.md +411 -0
- package/project-audit/audit_report_opus_4.6 +406 -0
- package/project-audit/consolidated-audit-report.md +456 -0
- package/savepoint +0 -0
- package/templates/project/.savepoint/Design.md +2 -2
- package/templates/project/.savepoint/router.md +10 -10
- package/templates/project/AGENTS.md +33 -21
- package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
- package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
- package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
- package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
- package/templates/prompts/audit-reconciliation.prompt.md +33 -28
- package/templates/prompts/design.prompt.md +3 -1
- package/.savepoint/audit/v1/E01/proposals.md +0 -168
- package/.savepoint/audit/v1/E01/snapshot.md +0 -78
- package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
- package/ink-cli-ui-design.zip +0 -0
- package/savepoint.exe +0 -0
|
@@ -5,9 +5,11 @@ import (
|
|
|
5
5
|
"path/filepath"
|
|
6
6
|
"strings"
|
|
7
7
|
"testing"
|
|
8
|
+
"time"
|
|
8
9
|
|
|
9
10
|
tea "github.com/charmbracelet/bubbletea"
|
|
10
11
|
"github.com/opencode/savepoint/internal/data"
|
|
12
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
func requireModel(t *testing.T, got tea.Model) Model {
|
|
@@ -178,7 +180,14 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
|
|
|
178
180
|
}
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
func
|
|
183
|
+
func processCmd(t *testing.T, m Model, cmd tea.Cmd) Model {
|
|
184
|
+
t.Helper()
|
|
185
|
+
msg := cmd()
|
|
186
|
+
got, _ := m.Update(msg)
|
|
187
|
+
return requireModel(t, got)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func TestUpdate_pSetsRouterToFocusedTask(t *testing.T) {
|
|
182
191
|
root := writeRouterFixture(t)
|
|
183
192
|
tasks := []data.Task{
|
|
184
193
|
{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
|
|
@@ -187,8 +196,9 @@ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
|
|
|
187
196
|
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
188
197
|
m.Root = root
|
|
189
198
|
|
|
190
|
-
got,
|
|
191
|
-
|
|
199
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
|
|
200
|
+
first := requireModel(t, got)
|
|
201
|
+
updated := processCmd(t, first, cmd)
|
|
192
202
|
|
|
193
203
|
if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
|
|
194
204
|
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
@@ -208,7 +218,7 @@ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
|
|
|
208
218
|
}
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
func
|
|
221
|
+
func TestUpdate_pSetsRouterToFocusedTaskWhenItIsLastUncompleted(t *testing.T) {
|
|
212
222
|
root := writeRouterFixture(t)
|
|
213
223
|
tasks := []data.Task{
|
|
214
224
|
{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
|
|
@@ -217,28 +227,47 @@ func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
|
|
|
217
227
|
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
218
228
|
m.Root = root
|
|
219
229
|
|
|
220
|
-
got,
|
|
221
|
-
|
|
230
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
|
|
231
|
+
first := requireModel(t, got)
|
|
232
|
+
updated := processCmd(t, first, cmd)
|
|
222
233
|
|
|
223
|
-
if updated.StatusMessage
|
|
234
|
+
if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
|
|
224
235
|
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
225
236
|
}
|
|
226
237
|
state := readRouterFixture(t, root)
|
|
227
|
-
if state.State != "
|
|
228
|
-
t.Errorf("router state = %q, want
|
|
238
|
+
if state.State != "task-building" {
|
|
239
|
+
t.Errorf("router state = %q, want task-building", state.State)
|
|
229
240
|
}
|
|
230
|
-
if state.Task != "" {
|
|
231
|
-
t.Errorf("router task = %q, want
|
|
241
|
+
if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
|
|
242
|
+
t.Errorf("router task = %q, want focused task", state.Task)
|
|
232
243
|
}
|
|
233
244
|
}
|
|
234
245
|
|
|
235
|
-
func
|
|
246
|
+
func TestUpdate_pDoesNothingWhenOverlayOpen(t *testing.T) {
|
|
236
247
|
root := writeRouterFixture(t)
|
|
237
248
|
tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
|
|
238
249
|
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
239
250
|
m.Root = root
|
|
240
251
|
m.Overlay = OverlayHelp
|
|
241
252
|
|
|
253
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
|
|
254
|
+
updated := requireModel(t, got)
|
|
255
|
+
|
|
256
|
+
if updated.StatusMessage != "" {
|
|
257
|
+
t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
|
|
258
|
+
}
|
|
259
|
+
state := readRouterFixture(t, root)
|
|
260
|
+
if state.Task != "T001" {
|
|
261
|
+
t.Errorf("router task = %q, want unchanged T001", state.Task)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func TestUpdate_mDoesNotSetRouterTask(t *testing.T) {
|
|
266
|
+
root := writeRouterFixture(t)
|
|
267
|
+
tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
|
|
268
|
+
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
269
|
+
m.Root = root
|
|
270
|
+
|
|
242
271
|
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
|
|
243
272
|
updated := requireModel(t, got)
|
|
244
273
|
|
|
@@ -251,14 +280,14 @@ func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
|
|
|
251
280
|
}
|
|
252
281
|
}
|
|
253
282
|
|
|
254
|
-
func
|
|
283
|
+
func TestUpdate_pDoesNotSetDoneTask(t *testing.T) {
|
|
255
284
|
root := writeRouterFixture(t)
|
|
256
285
|
tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
|
|
257
286
|
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
258
287
|
m.Root = root
|
|
259
288
|
m.FocusedColumn = data.ColumnDone
|
|
260
289
|
|
|
261
|
-
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("
|
|
290
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
|
|
262
291
|
updated := requireModel(t, got)
|
|
263
292
|
|
|
264
293
|
if updated.StatusMessage != "Router not updated: focused task is done" {
|
|
@@ -270,13 +299,265 @@ func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
|
|
|
270
299
|
}
|
|
271
300
|
}
|
|
272
301
|
|
|
302
|
+
func TestUpdate_spaceShowsPhaseTransitionMessage(t *testing.T) {
|
|
303
|
+
tasks := []data.Task{{ID: "E05/T004", Column: data.ColumnInProgress, Stage: data.StageBuild}}
|
|
304
|
+
m := NewModel(tasks, "v1.1", "E05")
|
|
305
|
+
m.FocusedColumn = data.ColumnInProgress
|
|
306
|
+
|
|
307
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
308
|
+
updated := requireModel(t, got)
|
|
309
|
+
|
|
310
|
+
if updated.StatusMessage != "Moved T004 to test" {
|
|
311
|
+
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
312
|
+
}
|
|
313
|
+
if updated.AllTasks[0].Stage != data.StageTest {
|
|
314
|
+
t.Errorf("Stage = %q, want test", updated.AllTasks[0].Stage)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func TestUpdate_spaceWarnsAfterStaleMtime(t *testing.T) {
|
|
319
|
+
path := filepath.Join(t.TempDir(), "T004-task.md")
|
|
320
|
+
content := "---\nid: E05/T004\nstatus: in_progress\nstage: build\nphase: build\n---\n\n# Task\n"
|
|
321
|
+
testutil.WriteFile(t, path, content)
|
|
322
|
+
fi, err := os.Stat(path)
|
|
323
|
+
if err != nil {
|
|
324
|
+
t.Fatal(err)
|
|
325
|
+
}
|
|
326
|
+
tasks := []data.Task{{
|
|
327
|
+
ID: "E05/T004",
|
|
328
|
+
Column: data.ColumnInProgress,
|
|
329
|
+
Stage: data.StageBuild,
|
|
330
|
+
Path: path,
|
|
331
|
+
Mtime: fi.ModTime().Add(-time.Hour),
|
|
332
|
+
}}
|
|
333
|
+
m := NewModel(tasks, "v1.1", "E05")
|
|
334
|
+
m.FocusedColumn = data.ColumnInProgress
|
|
335
|
+
|
|
336
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
337
|
+
// cmd should be errorMsg since mtime is stale
|
|
338
|
+
msg := cmd()
|
|
339
|
+
if _, ok := msg.(errorMsg); !ok {
|
|
340
|
+
t.Fatalf("expected errorMsg, got %T", msg)
|
|
341
|
+
}
|
|
342
|
+
updated := requireModel(t, got)
|
|
343
|
+
got2, _ := updated.Update(msg)
|
|
344
|
+
updated2 := requireModel(t, got2)
|
|
345
|
+
|
|
346
|
+
if updated2.StatusMessage != "mtime conflict: refresh before retrying" {
|
|
347
|
+
t.Fatalf("StatusMessage = %q", updated2.StatusMessage)
|
|
348
|
+
}
|
|
349
|
+
raw, err := os.ReadFile(path)
|
|
350
|
+
if err != nil {
|
|
351
|
+
t.Fatal(err)
|
|
352
|
+
}
|
|
353
|
+
parsed, err := data.NewParser().ParseTaskFile(path, string(raw))
|
|
354
|
+
if err != nil {
|
|
355
|
+
t.Fatal(err)
|
|
356
|
+
}
|
|
357
|
+
if parsed.Stage != data.StageBuild {
|
|
358
|
+
t.Errorf("persisted Stage = %q, want build", parsed.Stage)
|
|
359
|
+
}
|
|
360
|
+
if !strings.Contains(string(raw), "stage:") {
|
|
361
|
+
t.Error("legacy stage field should remain when write is rejected")
|
|
362
|
+
}
|
|
363
|
+
if updated2.AllTasks[0].Stage != data.StageBuild {
|
|
364
|
+
t.Errorf("model Stage = %q after rejected write, want build", updated2.AllTasks[0].Stage)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func TestUpdate_backspaceShowsRetreatMessageAndSyncsStatus(t *testing.T) {
|
|
369
|
+
tasks := []data.Task{{ID: "E05/T004", Status: string(data.ColumnDone), Column: data.ColumnDone}}
|
|
370
|
+
m := NewModel(tasks, "v1.1", "E05")
|
|
371
|
+
m.FocusedColumn = data.ColumnDone
|
|
372
|
+
|
|
373
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
|
374
|
+
updated := requireModel(t, got)
|
|
375
|
+
|
|
376
|
+
if updated.StatusMessage != "Moved back T004 to audit" {
|
|
377
|
+
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
378
|
+
}
|
|
379
|
+
if updated.AllTasks[0].Column != data.ColumnInProgress {
|
|
380
|
+
t.Errorf("Column = %q, want in_progress", updated.AllTasks[0].Column)
|
|
381
|
+
}
|
|
382
|
+
if updated.AllTasks[0].Stage != data.StageAudit {
|
|
383
|
+
t.Errorf("Stage = %q, want audit", updated.AllTasks[0].Stage)
|
|
384
|
+
}
|
|
385
|
+
if updated.AllTasks[0].Status != string(data.ColumnInProgress) {
|
|
386
|
+
t.Errorf("Status = %q, want in_progress", updated.AllTasks[0].Status)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
func TestUpdate_key1SwitchesToDetailTab(t *testing.T) {
|
|
391
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
392
|
+
m.Epics = []string{"E06-audit-command"}
|
|
393
|
+
m.Overlay = OverlayEpicDetail
|
|
394
|
+
m.EpicDetailTab = 1
|
|
395
|
+
m.EpicDetailOffset = 5
|
|
396
|
+
|
|
397
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
|
|
398
|
+
updated := requireModel(t, got)
|
|
399
|
+
|
|
400
|
+
if updated.EpicDetailTab != 0 {
|
|
401
|
+
t.Errorf("EpicDetailTab = %d, want 0", updated.EpicDetailTab)
|
|
402
|
+
}
|
|
403
|
+
if updated.EpicDetailOffset != 0 {
|
|
404
|
+
t.Errorf("EpicDetailOffset = %d, want 0 (reset on tab switch)", updated.EpicDetailOffset)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
func TestUpdate_key2SwitchesToAuditTabAndLoadsContent(t *testing.T) {
|
|
409
|
+
root := t.TempDir()
|
|
410
|
+
auditDir := filepath.Join(root, "releases", "v1.1", "epics", "E06-audit-command")
|
|
411
|
+
testutil.MkdirAll(t, auditDir)
|
|
412
|
+
auditContent := "# E06 Audit\n\n## Findings\n\n- [x] All good\n"
|
|
413
|
+
testutil.WriteFile(t, filepath.Join(auditDir, "E06-Audit.md"), auditContent)
|
|
414
|
+
|
|
415
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
416
|
+
m.Root = root
|
|
417
|
+
m.Epics = []string{"E06-audit-command"}
|
|
418
|
+
m.EpicPanelCursor = 0
|
|
419
|
+
m.Overlay = OverlayEpicDetail
|
|
420
|
+
m.EpicDetailTab = 0
|
|
421
|
+
m.EpicDetailOffset = 3
|
|
422
|
+
|
|
423
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
|
424
|
+
updated := requireModel(t, got)
|
|
425
|
+
|
|
426
|
+
if updated.EpicDetailTab != 1 {
|
|
427
|
+
t.Errorf("EpicDetailTab = %d, want 1", updated.EpicDetailTab)
|
|
428
|
+
}
|
|
429
|
+
if updated.EpicDetailOffset != 0 {
|
|
430
|
+
t.Errorf("EpicDetailOffset = %d, want 0 (reset on tab switch)", updated.EpicDetailOffset)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
msg := cmd()
|
|
434
|
+
got2, _ := updated.Update(msg)
|
|
435
|
+
updated2 := requireModel(t, got2)
|
|
436
|
+
if updated2.EpicAuditContent != auditContent {
|
|
437
|
+
t.Errorf("EpicAuditContent = %q, want %q", updated2.EpicAuditContent, auditContent)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func TestUpdate_key2LoadsAuditForOpenedEpicWhenPanelCursorStale(t *testing.T) {
|
|
442
|
+
root := t.TempDir()
|
|
443
|
+
epicA := filepath.Join(root, "releases", "v1.1", "epics", "E02-cross-platform-compatibility")
|
|
444
|
+
epicB := filepath.Join(root, "releases", "v1.1", "epics", "E06-audit-command")
|
|
445
|
+
testutil.MkdirAll(t, epicA)
|
|
446
|
+
testutil.MkdirAll(t, epicB)
|
|
447
|
+
auditContent := "# E06 Audit\n\n## Main Findings\nE06 content\n"
|
|
448
|
+
testutil.WriteFile(t, filepath.Join(epicB, "E06-Audit.md"), auditContent)
|
|
449
|
+
|
|
450
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
451
|
+
m.Root = root
|
|
452
|
+
m.Epics = []string{"E02-cross-platform-compatibility", "E06-audit-command"}
|
|
453
|
+
m.SelectedEpic = "E06-audit-command"
|
|
454
|
+
m.EpicDetailEpic = "E06-audit-command"
|
|
455
|
+
m.EpicPanelCursor = 0
|
|
456
|
+
m.Overlay = OverlayEpicDetail
|
|
457
|
+
|
|
458
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
|
459
|
+
updated := requireModel(t, got)
|
|
460
|
+
|
|
461
|
+
msg := cmd()
|
|
462
|
+
got2, _ := updated.Update(msg)
|
|
463
|
+
updated2 := requireModel(t, got2)
|
|
464
|
+
if updated2.EpicAuditContent != auditContent {
|
|
465
|
+
t.Errorf("EpicAuditContent = %q, want opened epic audit content", updated2.EpicAuditContent)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
func TestUpdate_key2FallsBackWhenNoAuditFile(t *testing.T) {
|
|
470
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
471
|
+
m.Epics = []string{"E06-audit-command"}
|
|
472
|
+
m.EpicPanelCursor = 0
|
|
473
|
+
m.Overlay = OverlayEpicDetail
|
|
474
|
+
|
|
475
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
|
476
|
+
updated := requireModel(t, got)
|
|
477
|
+
|
|
478
|
+
if updated.EpicDetailTab != 1 {
|
|
479
|
+
t.Errorf("EpicDetailTab = %d, want 1", updated.EpicDetailTab)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
msg := cmd()
|
|
483
|
+
got2, _ := updated.Update(msg)
|
|
484
|
+
updated2 := requireModel(t, got2)
|
|
485
|
+
if updated2.EpicAuditContent != "(no audit available)" {
|
|
486
|
+
t.Errorf("EpicAuditContent = %q, want \"(no audit available)\"", updated2.EpicAuditContent)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
func TestUpdate_key2CachesAuditContent(t *testing.T) {
|
|
491
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
492
|
+
m.Epics = []string{"E06-audit-command"}
|
|
493
|
+
m.EpicPanelCursor = 0
|
|
494
|
+
m.Overlay = OverlayEpicDetail
|
|
495
|
+
m.EpicAuditContent = "already cached"
|
|
496
|
+
|
|
497
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
|
498
|
+
updated := requireModel(t, got)
|
|
499
|
+
|
|
500
|
+
if updated.EpicAuditContent != "already cached" {
|
|
501
|
+
t.Errorf("EpicAuditContent = %q, want cached value preserved", updated.EpicAuditContent)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
func TestUpdate_openEpicDetailOverlayResetsTabState(t *testing.T) {
|
|
506
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
507
|
+
m.Epics = []string{"E06-audit-command"}
|
|
508
|
+
m.EpicPanelCursor = 0
|
|
509
|
+
m.EpicDetailTab = 1
|
|
510
|
+
m.EpicAuditContent = "stale content"
|
|
511
|
+
|
|
512
|
+
m.openEpicDetailOverlay()
|
|
513
|
+
|
|
514
|
+
if m.EpicDetailTab != 0 {
|
|
515
|
+
t.Errorf("EpicDetailTab = %d, want 0 after overlay open", m.EpicDetailTab)
|
|
516
|
+
}
|
|
517
|
+
if m.EpicAuditContent != "" {
|
|
518
|
+
t.Errorf("EpicAuditContent = %q, want empty after overlay open", m.EpicAuditContent)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
func TestUpdate_tabKeysNoopOutsideEpicDetailOverlay(t *testing.T) {
|
|
523
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
524
|
+
m.Overlay = OverlayHelp
|
|
525
|
+
m.EpicDetailTab = 0
|
|
526
|
+
|
|
527
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
|
528
|
+
updated := requireModel(t, got)
|
|
529
|
+
|
|
530
|
+
if updated.EpicDetailTab != 0 {
|
|
531
|
+
t.Errorf("EpicDetailTab changed outside EpicDetail overlay: got %d", updated.EpicDetailTab)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
func TestReloadMsgUpdatesRouterState(t *testing.T) {
|
|
536
|
+
m := NewModel(nil, "v1", "E01")
|
|
537
|
+
m.RouterState = &data.RouterState{State: "task-building", Task: "E01/T001", NextAction: "Build E01/T001."}
|
|
538
|
+
m.RouterTask = "E01/T001"
|
|
539
|
+
|
|
540
|
+
newState := &data.RouterState{State: "task-building", Task: "E01/T002", NextAction: "Build E01/T002."}
|
|
541
|
+
got, _ := m.Update(reloadMsg{
|
|
542
|
+
tasks: nil,
|
|
543
|
+
releases: []string{"v1"},
|
|
544
|
+
releaseEpics: map[string][]string{"v1": {"E01"}},
|
|
545
|
+
routerState: newState,
|
|
546
|
+
})
|
|
547
|
+
updated := requireModel(t, got)
|
|
548
|
+
|
|
549
|
+
if updated.RouterTask != "E01/T002" {
|
|
550
|
+
t.Errorf("RouterTask = %q, want E01/T002", updated.RouterTask)
|
|
551
|
+
}
|
|
552
|
+
if updated.RouterState == nil || updated.RouterState.NextAction != "Build E01/T002." {
|
|
553
|
+
t.Errorf("RouterState.NextAction = %q, want Build E01/T002.", updated.RouterState.NextAction)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
273
557
|
func writeRouterFixture(t *testing.T) string {
|
|
274
558
|
t.Helper()
|
|
275
559
|
root := t.TempDir()
|
|
276
|
-
|
|
277
|
-
if err := os.WriteFile(filepath.Join(root, "router.md"), []byte(content), 0644); err != nil {
|
|
278
|
-
t.Fatal(err)
|
|
279
|
-
}
|
|
560
|
+
testutil.WriteRouter(t, root, "task-building", "v1.1", "E05", "T001", "Build T001.")
|
|
280
561
|
return root
|
|
281
562
|
}
|
|
282
563
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
// sliceIndex returns the index of target in items, or 0 if not found.
|
|
6
|
+
func sliceIndex(items []string, target string) int {
|
|
7
|
+
for i, e := range items {
|
|
8
|
+
if e == target {
|
|
9
|
+
return i
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return 0
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// WrapText wraps s to fit within width, splitting on word boundaries.
|
|
16
|
+
func WrapText(s string, width int) []string {
|
|
17
|
+
if width < 4 {
|
|
18
|
+
width = 4
|
|
19
|
+
}
|
|
20
|
+
words := strings.Fields(s)
|
|
21
|
+
if len(words) == 0 {
|
|
22
|
+
return nil
|
|
23
|
+
}
|
|
24
|
+
lines := []string{}
|
|
25
|
+
current := ""
|
|
26
|
+
for _, word := range words {
|
|
27
|
+
if len([]rune(word)) > width {
|
|
28
|
+
if current != "" {
|
|
29
|
+
lines = append(lines, current)
|
|
30
|
+
current = ""
|
|
31
|
+
}
|
|
32
|
+
lines = append(lines, SplitLongWord(word, width)...)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if current == "" {
|
|
36
|
+
current = word
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if len([]rune(current))+1+len([]rune(word)) <= width {
|
|
40
|
+
current += " " + word
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
lines = append(lines, current)
|
|
44
|
+
current = word
|
|
45
|
+
}
|
|
46
|
+
if current != "" {
|
|
47
|
+
lines = append(lines, current)
|
|
48
|
+
}
|
|
49
|
+
return lines
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// SplitLongWord splits a long word into chunks of at most width runes.
|
|
53
|
+
func SplitLongWord(word string, width int) []string {
|
|
54
|
+
runes := []rune(word)
|
|
55
|
+
lines := []string{}
|
|
56
|
+
for len(runes) > width {
|
|
57
|
+
lines = append(lines, string(runes[:width]))
|
|
58
|
+
runes = runes[width:]
|
|
59
|
+
}
|
|
60
|
+
if len(runes) > 0 {
|
|
61
|
+
lines = append(lines, string(runes))
|
|
62
|
+
}
|
|
63
|
+
return lines
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// truncate clips s to max runes, appending "…" if clipped.
|
|
67
|
+
func truncate(s string, max int) string {
|
|
68
|
+
runes := []rune(s)
|
|
69
|
+
if len(runes) <= max {
|
|
70
|
+
return s
|
|
71
|
+
}
|
|
72
|
+
if max <= 1 {
|
|
73
|
+
return "…"
|
|
74
|
+
}
|
|
75
|
+
return string(runes[:max-1]) + "…"
|
|
76
|
+
}
|
package/internal/board/view.go
CHANGED
|
@@ -28,9 +28,18 @@ func (m Model) View() string {
|
|
|
28
28
|
|
|
29
29
|
header := m.renderHeader(w)
|
|
30
30
|
nextActivity := m.renderNextActivityLine(w)
|
|
31
|
-
|
|
31
|
+
extra := extraHeaderLines(nextActivity)
|
|
32
|
+
layout := CalculateLayoutWithChrome(w, h, extra)
|
|
32
33
|
topDivider := dividerLine(w)
|
|
33
34
|
board := m.renderBoard(layout)
|
|
35
|
+
boardBudget := h - 8 - extra
|
|
36
|
+
if boardBudget < 0 {
|
|
37
|
+
boardBudget = 0
|
|
38
|
+
}
|
|
39
|
+
boardLines := strings.Split(board, "\n")
|
|
40
|
+
if len(boardLines) > boardBudget {
|
|
41
|
+
board = strings.Join(boardLines[:boardBudget], "\n")
|
|
42
|
+
}
|
|
34
43
|
bottomDivider := dividerLine(w)
|
|
35
44
|
footer := m.renderFooter(w)
|
|
36
45
|
sections := []string{header}
|
|
@@ -67,11 +76,13 @@ func (m Model) View() string {
|
|
|
67
76
|
|
|
68
77
|
if m.Overlay == OverlayEpicDetail {
|
|
69
78
|
ow := overlayWidth(w)
|
|
70
|
-
epicSlug :=
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
epicSlug := m.epicDetailEpic()
|
|
80
|
+
var detail string
|
|
81
|
+
if m.EpicDetailTab == 1 {
|
|
82
|
+
detail = RenderEpicAuditTab(epicSlug, m.EpicAuditContent, ow, detailMaxHeight(h), m.EpicDetailOffset, m.EpicDetailTab)
|
|
83
|
+
} else {
|
|
84
|
+
detail = RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset, m.EpicDetailTab)
|
|
73
85
|
}
|
|
74
|
-
detail := RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset)
|
|
75
86
|
return overlayOnBase(dimLines(base), detail, w, h)
|
|
76
87
|
}
|
|
77
88
|
|
|
@@ -137,13 +148,13 @@ func FormatNextActivity(state *data.RouterState) string {
|
|
|
137
148
|
var s string
|
|
138
149
|
switch state.State {
|
|
139
150
|
case "task-building":
|
|
140
|
-
s = fmt.Sprintf("Build %s %s/%s", state.Release,
|
|
151
|
+
s = fmt.Sprintf("Build %s %s/%s", state.Release, shortID(state.Epic), shortID(state.Task))
|
|
141
152
|
case "audit-pending":
|
|
142
|
-
s = fmt.Sprintf("Audit %s",
|
|
153
|
+
s = fmt.Sprintf("Audit %s", shortID(state.Epic))
|
|
143
154
|
case "epic-design":
|
|
144
|
-
s = fmt.Sprintf("Design %s",
|
|
155
|
+
s = fmt.Sprintf("Design %s", shortID(state.Epic))
|
|
145
156
|
case "epic-task-breakdown":
|
|
146
|
-
s = fmt.Sprintf("Plan %s",
|
|
157
|
+
s = fmt.Sprintf("Plan %s", shortID(state.Epic))
|
|
147
158
|
case "pre-implementation":
|
|
148
159
|
s = fmt.Sprintf("Planning %s", state.Release)
|
|
149
160
|
default:
|
|
@@ -152,19 +163,7 @@ func FormatNextActivity(state *data.RouterState) string {
|
|
|
152
163
|
return xansi.Truncate(s, 20, "…")
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
|
|
156
|
-
// "E01-tui-optimisation/T001-border-resize-fix" → "T001"
|
|
157
|
-
// "E01-tui-optimisation" → "E01"
|
|
158
|
-
func shortRouterID(full string) string {
|
|
159
|
-
part := full
|
|
160
|
-
if i := strings.LastIndex(full, "/"); i >= 0 {
|
|
161
|
-
part = full[i+1:]
|
|
162
|
-
}
|
|
163
|
-
if i := strings.Index(part, "-"); i >= 0 {
|
|
164
|
-
return part[:i]
|
|
165
|
-
}
|
|
166
|
-
return part
|
|
167
|
-
}
|
|
166
|
+
|
|
168
167
|
|
|
169
168
|
func (m Model) focusedTask() (data.Task, bool) {
|
|
170
169
|
tasks := m.Tasks[m.FocusedColumn]
|
|
@@ -243,7 +242,7 @@ func (m Model) renderBoard(layout Layout) string {
|
|
|
243
242
|
cols := m.renderColumns(layout)
|
|
244
243
|
var content string
|
|
245
244
|
if layout.EpicPanelVisible {
|
|
246
|
-
epic := m.renderEpicPanel(layout.EpicPanelWidth)
|
|
245
|
+
epic := m.renderEpicPanel(layout.EpicPanelWidth, layout.ContentHeight)
|
|
247
246
|
content = lipgloss.JoinHorizontal(lipgloss.Top, epic, cols)
|
|
248
247
|
} else {
|
|
249
248
|
content = cols
|
|
@@ -263,8 +262,8 @@ func (m Model) renderColumns(layout Layout) string {
|
|
|
263
262
|
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
|
264
263
|
}
|
|
265
264
|
|
|
266
|
-
func (m Model) renderEpicPanel(w int) string {
|
|
267
|
-
return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
|
|
265
|
+
func (m Model) renderEpicPanel(w int, maxHeight int) string {
|
|
266
|
+
return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus, maxHeight)
|
|
268
267
|
}
|
|
269
268
|
|
|
270
269
|
func (m Model) renderColumn(col data.ColumnType, colW, maxHeight int) string {
|
|
@@ -291,9 +290,13 @@ func (m Model) renderFooter(termW int) string {
|
|
|
291
290
|
styles.FooterDivider.Render(" │ ")+
|
|
292
291
|
styles.FooterPhaseAudit.Render("AUDIT"),
|
|
293
292
|
)
|
|
294
|
-
hints := footerLine(termW, styles.FooterHints.Render("←/→:nav
|
|
295
|
-
|
|
296
|
-
|
|
293
|
+
hints := footerLine(termW, styles.FooterHints.Render("←/→:nav p: Priority R:release ?:help q:quit"))
|
|
294
|
+
status := ""
|
|
295
|
+
if m.StatusMessage != "" {
|
|
296
|
+
status = styles.StatusBar.Render(m.StatusMessage)
|
|
297
|
+
}
|
|
298
|
+
statusLine := footerLine(termW, status)
|
|
299
|
+
return lipgloss.JoinVertical(lipgloss.Center, phase, statusLine, hints)
|
|
297
300
|
}
|
|
298
301
|
|
|
299
302
|
func dividerLine(termW int) string {
|
|
@@ -66,7 +66,7 @@ func TestView_containsFooterHints(t *testing.T) {
|
|
|
66
66
|
m := NewModel(nil, "v1", "E03")
|
|
67
67
|
footer := m.renderFooter(80)
|
|
68
68
|
|
|
69
|
-
if !strings.Contains(footer, "←/→:nav
|
|
69
|
+
if !strings.Contains(footer, "←/→:nav p: Priority R:release ?:help q:quit") {
|
|
70
70
|
t.Fatal("renderFooter() missing navigation hints")
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -75,7 +75,7 @@ func TestView_containsFooterHints(t *testing.T) {
|
|
|
75
75
|
t.Fatalf("renderFooter() returned %d lines, want 3", len(lines))
|
|
76
76
|
}
|
|
77
77
|
if strings.TrimSpace(plainTerminal(lines[1])) != "" {
|
|
78
|
-
t.Fatalf("renderFooter()
|
|
78
|
+
t.Fatalf("renderFooter() status line = %q, want blank", lines[1])
|
|
79
79
|
}
|
|
80
80
|
for i, line := range lines {
|
|
81
81
|
if got := lipgloss.Width(line); got > 80 {
|
|
@@ -84,6 +84,16 @@ func TestView_containsFooterHints(t *testing.T) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
func TestView_footerRendersStatusMessage(t *testing.T) {
|
|
88
|
+
m := NewModel(nil, "v1", "E03")
|
|
89
|
+
m.StatusMessage = "Router set to v1.1 E05-tasking-permissions/T004"
|
|
90
|
+
footer := plainTerminal(m.renderFooter(80))
|
|
91
|
+
|
|
92
|
+
if !strings.Contains(footer, "Router set to v1.1 E05-tasking-permissions/T004") {
|
|
93
|
+
t.Fatal("renderFooter() missing status message")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
func TestView_containsBottomDivider(t *testing.T) {
|
|
88
98
|
m := NewModel(nil, "v1", "E03")
|
|
89
99
|
m.Width = 120
|
package/internal/board/watch.go
CHANGED
|
@@ -16,6 +16,31 @@ type reloadMsg struct {
|
|
|
16
16
|
releases []string
|
|
17
17
|
releaseEpics map[string][]string
|
|
18
18
|
epicStatuses map[string]string
|
|
19
|
+
routerState *data.RouterState
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type routerWriteMsg struct {
|
|
23
|
+
message string
|
|
24
|
+
state *data.RouterState
|
|
25
|
+
taskID string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type taskWriteMsg struct {
|
|
29
|
+
prefix string
|
|
30
|
+
next data.Task
|
|
31
|
+
err error
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type epicDetailMsg struct {
|
|
35
|
+
content string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type auditContentMsg struct {
|
|
39
|
+
content string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type errorMsg struct {
|
|
43
|
+
message string
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
// watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
|
|
@@ -52,22 +77,27 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
|
52
77
|
}
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
func reloadTasks(root string) tea.Cmd {
|
|
80
|
+
func reloadTasks(root string, deps ModelDependencies) tea.Cmd {
|
|
56
81
|
return func() tea.Msg {
|
|
57
|
-
tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
|
|
82
|
+
tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root, deps.Discoverer, deps.Parser)
|
|
58
83
|
if err != nil {
|
|
59
|
-
return
|
|
84
|
+
return errorMsg{message: "reload failed: " + err.Error()}
|
|
60
85
|
}
|
|
61
|
-
|
|
86
|
+
routerState, _ := readRouterState(root, deps.RouterReader)
|
|
87
|
+
return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses, routerState: routerState}
|
|
62
88
|
}
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
// newWatcher watches the
|
|
91
|
+
// newWatcher watches the savepoint root (for router.md) and all releases subdirs.
|
|
66
92
|
func newWatcher(root string) (*fsnotify.Watcher, error) {
|
|
67
93
|
w, err := fsnotify.NewWatcher()
|
|
68
94
|
if err != nil {
|
|
69
95
|
return nil, err
|
|
70
96
|
}
|
|
97
|
+
if err := w.Add(root); err != nil {
|
|
98
|
+
w.Close()
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
71
101
|
releasesPath := filepath.Join(root, "releases")
|
|
72
102
|
if err := addDirsRecursive(w, releasesPath); err != nil {
|
|
73
103
|
w.Close()
|