savepoint 1.0.1 → 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 +15 -1
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +52 -46
- package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
- package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md +124 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
- 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 +45 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Audit.md +167 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
- 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 +54 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +45 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +40 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +47 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +98 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +33 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +62 -0
- 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 +139 -0
- package/.savepoint/router.md +29 -108
- package/AGENTS.md +69 -111
- package/Makefile +19 -3
- package/README.md +6 -6
- package/agent-skills/savepoint-audit/SKILL.md +87 -35
- package/agent-skills/savepoint-build-task/SKILL.md +9 -4
- package/agent-skills/savepoint-create-plan/SKILL.md +10 -5
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +8 -3
- package/agent-skills/savepoint-system-design/SKILL.md +8 -3
- 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 +69 -49
- package/internal/board/board_test.go +83 -67
- package/internal/board/card.go +71 -20
- package/internal/board/card_test.go +141 -12
- package/internal/board/column.go +77 -11
- package/internal/board/column_test.go +63 -13
- package/internal/board/detail.go +107 -72
- package/internal/board/detail_test.go +117 -26
- package/internal/board/epic_panel.go +211 -18
- package/internal/board/epic_panel_test.go +637 -14
- 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/layout.go +12 -2
- package/internal/board/layout_test.go +17 -0
- package/internal/board/model.go +130 -52
- 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/render_policy_test.go +77 -0
- package/internal/board/status.go +23 -0
- 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 +472 -94
- package/internal/board/update_test.go +447 -0
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +139 -22
- package/internal/board/view_test.go +171 -3
- package/internal/board/watch.go +57 -9
- package/internal/buildtool/main.go +211 -0
- 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 +29 -6
- package/internal/data/parser_test.go +66 -7
- package/internal/data/task.go +1 -0
- 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/palette.go +3 -3
- package/internal/styles/styles.go +39 -12
- 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 +107 -1
- 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 +15 -14
- package/templates/project/AGENTS.md +56 -98
- 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 +35 -30
- package/templates/prompts/design.prompt.md +3 -1
- package/templates/prompts/epic-design.prompt.md +3 -3
- package/templates/prompts/task-breakdown.prompt.md +1 -1
- package/templates/prompts/task-building.prompt.md +1 -1
- package/templates/prompts/task-planning.prompt.md +1 -1
- package/.savepoint/audit/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
- package/ink-cli-ui-design.zip +0 -0
- package/main.exe +0 -0
- package/savepoint.exe +0 -0
- /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
- /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
package/internal/board/help.go
CHANGED
|
@@ -23,6 +23,7 @@ func RenderHelp(width int) string {
|
|
|
23
23
|
helpRow("enter", "open task detail / select item"),
|
|
24
24
|
helpRow("e", "open epic selector on narrow screens"),
|
|
25
25
|
helpRow("r", "open release selector"),
|
|
26
|
+
helpRow("p", "mark focused task as priority"),
|
|
26
27
|
helpRow("up / k", "move selector up"),
|
|
27
28
|
helpRow("down / j", "move selector down"),
|
|
28
29
|
helpRow("?", "open help"),
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
11
|
+
"github.com/opencode/savepoint/internal/data"
|
|
12
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// writeTaskWithBody creates a task file with a body section to verify content preservation.
|
|
16
|
+
func writeTaskWithBody(t *testing.T, root, release, epic, taskSlug string, column data.ColumnType, body string) string {
|
|
17
|
+
t.Helper()
|
|
18
|
+
tf := testutil.TaskFixture{
|
|
19
|
+
Slug: taskSlug,
|
|
20
|
+
Release: release,
|
|
21
|
+
Status: string(column),
|
|
22
|
+
Objective: "Test task",
|
|
23
|
+
Body: body,
|
|
24
|
+
}
|
|
25
|
+
if column == data.ColumnInProgress {
|
|
26
|
+
tf.Phase = "build"
|
|
27
|
+
}
|
|
28
|
+
testutil.WriteTask(t, root, release, epic, tf)
|
|
29
|
+
return filepath.Join(root, "releases", release, "epics", epic, "tasks", taskSlug+".md")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// TestBoardPipeline_endToEnd loads a real project from disk and renders the full board view.
|
|
33
|
+
func TestBoardPipeline_endToEnd(t *testing.T) {
|
|
34
|
+
projectRoot := t.TempDir()
|
|
35
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
36
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
37
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned)
|
|
38
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T002-validate", data.ColumnInProgress)
|
|
39
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T003-done-task", data.ColumnDone)
|
|
40
|
+
|
|
41
|
+
model, err := newProjectModel(projectRoot, "", "")
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
44
|
+
}
|
|
45
|
+
if model.Watcher != nil {
|
|
46
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
model.Width = 120
|
|
50
|
+
model.Height = 40
|
|
51
|
+
view := model.View()
|
|
52
|
+
|
|
53
|
+
for _, want := range []string{"PLANNED", "IN PROGRESS", "DONE", "T001", "T002", "T003"} {
|
|
54
|
+
if !strings.Contains(view, want) {
|
|
55
|
+
t.Errorf("board view missing %q", want)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TestRunPlainOutput_endToEnd calls runPlainOutput against a real temp project root.
|
|
61
|
+
func TestRunPlainOutput_endToEnd(t *testing.T) {
|
|
62
|
+
projectRoot := t.TempDir()
|
|
63
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
64
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
65
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned)
|
|
66
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T002-validate", data.ColumnDone)
|
|
67
|
+
|
|
68
|
+
model, err := newProjectModel(projectRoot, "", "")
|
|
69
|
+
if err != nil {
|
|
70
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
71
|
+
}
|
|
72
|
+
if model.Watcher != nil {
|
|
73
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
out := RenderPlainTable(model)
|
|
77
|
+
|
|
78
|
+
if !strings.Contains(out, plainNonTTYWarning) {
|
|
79
|
+
t.Error("plain output missing non-TTY warning")
|
|
80
|
+
}
|
|
81
|
+
for _, want := range []string{"PLANNED", "DONE", "T001-scaffold", "T002-validate"} {
|
|
82
|
+
if !strings.Contains(out, want) {
|
|
83
|
+
t.Errorf("plain output missing %q", want)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// TestStatusWrite_preservesTaskBody advances a task via space key and verifies the body text is unchanged.
|
|
89
|
+
func TestStatusWrite_preservesTaskBody(t *testing.T) {
|
|
90
|
+
root := t.TempDir()
|
|
91
|
+
body := "## Acceptance Criteria\n\n- [ ] thing one\n- [ ] thing two\n"
|
|
92
|
+
path := writeTaskWithBody(t, root, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned, body)
|
|
93
|
+
|
|
94
|
+
fi, err := os.Stat(path)
|
|
95
|
+
if err != nil {
|
|
96
|
+
t.Fatal(err)
|
|
97
|
+
}
|
|
98
|
+
task := data.Task{
|
|
99
|
+
ID: "E01-init/T001-scaffold",
|
|
100
|
+
Column: data.ColumnPlanned,
|
|
101
|
+
Path: path,
|
|
102
|
+
Mtime: fi.ModTime(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
m := NewModel([]data.Task{task}, "v1", "E01-init")
|
|
106
|
+
m.FocusedColumn = data.ColumnPlanned
|
|
107
|
+
|
|
108
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
109
|
+
msg := cmd()
|
|
110
|
+
got2, _ := got.Update(msg)
|
|
111
|
+
updated := requireModel(t, got2)
|
|
112
|
+
|
|
113
|
+
if updated.AllTasks[0].Column != data.ColumnInProgress {
|
|
114
|
+
t.Errorf("Column = %q, want in_progress", updated.AllTasks[0].Column)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
raw, err := os.ReadFile(path)
|
|
118
|
+
if err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
if !strings.Contains(string(raw), body) {
|
|
122
|
+
t.Errorf("task body was altered after status write; got:\n%s", raw)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// TestMtimeConflict_directDetection verifies WriteTaskStatus returns ErrMtimeConflict on mtime mismatch.
|
|
127
|
+
func TestMtimeConflict_directDetection(t *testing.T) {
|
|
128
|
+
dir := t.TempDir()
|
|
129
|
+
path := filepath.Join(dir, "T001.md")
|
|
130
|
+
content := "---\nid: E01/T001\nstatus: planned\nphase: build\n---\n\n# Task\n"
|
|
131
|
+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
132
|
+
t.Fatal(err)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
task := &data.Task{
|
|
136
|
+
ID: "E01/T001",
|
|
137
|
+
Column: data.ColumnInProgress,
|
|
138
|
+
Stage: data.StageBuild,
|
|
139
|
+
}
|
|
140
|
+
staleTime := time.Now().Add(-time.Hour)
|
|
141
|
+
err := data.WriteTaskStatus(path, task, staleTime)
|
|
142
|
+
if err != data.ErrMtimeConflict {
|
|
143
|
+
t.Errorf("WriteTaskStatus with stale mtime = %v, want ErrMtimeConflict", err)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// TestMtimeConflict_boardWarns verifies the board surfaces an mtime conflict instead of overwriting external edits.
|
|
148
|
+
func TestMtimeConflict_boardWarns(t *testing.T) {
|
|
149
|
+
path := filepath.Join(t.TempDir(), "T001.md")
|
|
150
|
+
content := "---\nid: E01/T001\nstatus: in_progress\nphase: build\n---\n\n# Task\n"
|
|
151
|
+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
152
|
+
t.Fatal(err)
|
|
153
|
+
}
|
|
154
|
+
fi, err := os.Stat(path)
|
|
155
|
+
if err != nil {
|
|
156
|
+
t.Fatal(err)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
task := data.Task{
|
|
160
|
+
ID: "E01/T001",
|
|
161
|
+
Column: data.ColumnInProgress,
|
|
162
|
+
Stage: data.StageBuild,
|
|
163
|
+
Path: path,
|
|
164
|
+
Mtime: fi.ModTime().Add(-time.Minute), // intentionally stale
|
|
165
|
+
}
|
|
166
|
+
m := NewModel([]data.Task{task}, "v1", "E01")
|
|
167
|
+
m.FocusedColumn = data.ColumnInProgress
|
|
168
|
+
|
|
169
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
170
|
+
msg := cmd()
|
|
171
|
+
got2, _ := got.Update(msg)
|
|
172
|
+
updated := requireModel(t, got2)
|
|
173
|
+
|
|
174
|
+
if !strings.Contains(updated.StatusMessage, "mtime conflict") {
|
|
175
|
+
t.Errorf("StatusMessage = %q, want mtime conflict warning", updated.StatusMessage)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
raw, err := os.ReadFile(path)
|
|
179
|
+
if err != nil {
|
|
180
|
+
t.Fatal(err)
|
|
181
|
+
}
|
|
182
|
+
if !strings.Contains(string(raw), "phase: build") {
|
|
183
|
+
t.Errorf("task file was overwritten despite mtime conflict:\n%s", raw)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// TestReleaseFilter_showsOnlyMatchingRelease verifies the --release flag filters tasks.
|
|
188
|
+
func TestReleaseFilter_showsOnlyMatchingRelease(t *testing.T) {
|
|
189
|
+
projectRoot := t.TempDir()
|
|
190
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
191
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
192
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-v1-task", data.ColumnPlanned)
|
|
193
|
+
writeTask(t, savepointRoot, "v2", "E01-init", "T001-v2-task", data.ColumnPlanned)
|
|
194
|
+
|
|
195
|
+
model, err := newProjectModel(projectRoot, "v2", "")
|
|
196
|
+
if err != nil {
|
|
197
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
198
|
+
}
|
|
199
|
+
if model.Watcher != nil {
|
|
200
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if model.SelectedRelease != "v2" {
|
|
204
|
+
t.Errorf("SelectedRelease = %q, want v2", model.SelectedRelease)
|
|
205
|
+
}
|
|
206
|
+
planned := model.Tasks[data.ColumnPlanned]
|
|
207
|
+
for _, task := range planned {
|
|
208
|
+
if task.Release != "v2" {
|
|
209
|
+
t.Errorf("task %q has release %q, want v2 only", task.ID, task.Release)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// TestEpicFilter_showsOnlyMatchingEpic verifies the --epic flag filters tasks.
|
|
215
|
+
func TestEpicFilter_showsOnlyMatchingEpic(t *testing.T) {
|
|
216
|
+
projectRoot := t.TempDir()
|
|
217
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
218
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
219
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-alpha", data.ColumnPlanned)
|
|
220
|
+
writeTask(t, savepointRoot, "v1", "E02-build", "T001-beta", data.ColumnPlanned)
|
|
221
|
+
|
|
222
|
+
model, err := newProjectModel(projectRoot, "v1", "E02-build")
|
|
223
|
+
if err != nil {
|
|
224
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
225
|
+
}
|
|
226
|
+
if model.Watcher != nil {
|
|
227
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if model.SelectedEpic != "E02-build" {
|
|
231
|
+
t.Errorf("SelectedEpic = %q, want E02-build", model.SelectedEpic)
|
|
232
|
+
}
|
|
233
|
+
planned := model.Tasks[data.ColumnPlanned]
|
|
234
|
+
for _, task := range planned {
|
|
235
|
+
if task.Epic != "E02-build" {
|
|
236
|
+
t.Errorf("task %q has epic %q, want E02-build only", task.ID, task.Epic)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// TestDetailPane_opensOnEnter verifies enter key opens the detail overlay.
|
|
242
|
+
func TestDetailPane_opensOnEnter(t *testing.T) {
|
|
243
|
+
tasks := []data.Task{{ID: "E01/T001", Title: "Scaffold init", Column: data.ColumnPlanned}}
|
|
244
|
+
m := NewModel(tasks, "v1", "E01")
|
|
245
|
+
m.FocusedColumn = data.ColumnPlanned
|
|
246
|
+
|
|
247
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
248
|
+
updated := requireModel(t, got)
|
|
249
|
+
|
|
250
|
+
if updated.Overlay != OverlayDetail {
|
|
251
|
+
t.Errorf("Overlay = %q, want %q", updated.Overlay, OverlayDetail)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// TestDetailPane_escClosesOverlay verifies esc dismisses the detail overlay.
|
|
256
|
+
func TestDetailPane_escClosesOverlay(t *testing.T) {
|
|
257
|
+
m := NewModel(nil, "v1", "E01")
|
|
258
|
+
m.Overlay = OverlayDetail
|
|
259
|
+
|
|
260
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
261
|
+
updated := requireModel(t, got)
|
|
262
|
+
|
|
263
|
+
if updated.Overlay != OverlayNone {
|
|
264
|
+
t.Errorf("Overlay = %q after esc, want none", updated.Overlay)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import "github.com/opencode/savepoint/internal/data"
|
|
4
|
+
|
|
5
|
+
// taskDiscoverer provides project traversal for board loading.
|
|
6
|
+
type taskDiscoverer interface {
|
|
7
|
+
FindSavepointRoot(start string) (string, error)
|
|
8
|
+
ListReleases(root string) ([]data.ReleaseInfo, error)
|
|
9
|
+
ListEpics(root, release string) ([]data.EpicInfo, error)
|
|
10
|
+
ListTasks(root, release, epic string) ([]data.TaskInfo, error)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// taskParser parses Savepoint frontmatter and task files for board loading.
|
|
14
|
+
type taskParser interface {
|
|
15
|
+
ParseFrontmatter(content string) (map[string]any, error)
|
|
16
|
+
ParseTaskFile(path string, content string) (*data.Task, error)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// configReader reads board display configuration.
|
|
20
|
+
type configReader interface {
|
|
21
|
+
Read(path string) (*data.Config, error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// routerReader parses router state from router.md content.
|
|
25
|
+
type routerReader interface {
|
|
26
|
+
ReadState(content string) (*data.RouterState, error)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ModelDependencies contains board data-access dependencies.
|
|
30
|
+
type ModelDependencies struct {
|
|
31
|
+
Discoverer taskDiscoverer
|
|
32
|
+
Parser taskParser
|
|
33
|
+
ConfigReader configReader
|
|
34
|
+
RouterReader routerReader
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func defaultModelDependencies() ModelDependencies {
|
|
38
|
+
return ModelDependencies{
|
|
39
|
+
Discoverer: data.NewDiscover(),
|
|
40
|
+
Parser: data.NewParser(),
|
|
41
|
+
ConfigReader: data.NewConfigReader(),
|
|
42
|
+
RouterReader: data.NewRouterReader(),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func modelDependencies(overrides []ModelDependencies) ModelDependencies {
|
|
47
|
+
deps := defaultModelDependencies()
|
|
48
|
+
if len(overrides) == 0 {
|
|
49
|
+
return deps
|
|
50
|
+
}
|
|
51
|
+
override := overrides[0]
|
|
52
|
+
if override.Discoverer != nil {
|
|
53
|
+
deps.Discoverer = override.Discoverer
|
|
54
|
+
}
|
|
55
|
+
if override.Parser != nil {
|
|
56
|
+
deps.Parser = override.Parser
|
|
57
|
+
}
|
|
58
|
+
if override.ConfigReader != nil {
|
|
59
|
+
deps.ConfigReader = override.ConfigReader
|
|
60
|
+
}
|
|
61
|
+
if override.RouterReader != nil {
|
|
62
|
+
deps.RouterReader = override.RouterReader
|
|
63
|
+
}
|
|
64
|
+
return deps
|
|
65
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"path/filepath"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/opencode/savepoint/internal/data"
|
|
8
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type stubBoardDiscoverer struct {
|
|
12
|
+
root string
|
|
13
|
+
releases []data.ReleaseInfo
|
|
14
|
+
epics map[string][]data.EpicInfo
|
|
15
|
+
tasks map[string][]data.TaskInfo
|
|
16
|
+
findCalls int
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (d *stubBoardDiscoverer) FindSavepointRoot(start string) (string, error) {
|
|
20
|
+
d.findCalls++
|
|
21
|
+
return d.root, nil
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func (d *stubBoardDiscoverer) ListReleases(root string) ([]data.ReleaseInfo, error) {
|
|
25
|
+
return d.releases, nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (d *stubBoardDiscoverer) ListEpics(root, release string) ([]data.EpicInfo, error) {
|
|
29
|
+
return d.epics[release], nil
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func (d *stubBoardDiscoverer) ListTasks(root, release, epic string) ([]data.TaskInfo, error) {
|
|
33
|
+
return d.tasks[release+"/"+epic], nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type countingBoardParser struct {
|
|
37
|
+
parser *data.Parser
|
|
38
|
+
frontmatterCalls int
|
|
39
|
+
taskFileCalls int
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (p *countingBoardParser) ParseFrontmatter(content string) (map[string]any, error) {
|
|
43
|
+
p.frontmatterCalls++
|
|
44
|
+
return p.parser.ParseFrontmatter(content)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (p *countingBoardParser) ParseTaskFile(path string, content string) (*data.Task, error) {
|
|
48
|
+
p.taskFileCalls++
|
|
49
|
+
return p.parser.ParseTaskFile(path, content)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type stubBoardRouterReader struct {
|
|
53
|
+
state *data.RouterState
|
|
54
|
+
calls int
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (r *stubBoardRouterReader) ReadState(content string) (*data.RouterState, error) {
|
|
58
|
+
r.calls++
|
|
59
|
+
return r.state, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestNewProjectModelUsesInjectedInterfaces(t *testing.T) {
|
|
63
|
+
projectRoot := t.TempDir()
|
|
64
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
65
|
+
epicPath := filepath.Join(savepointRoot, "releases", "v9", "epics", "E01-mock")
|
|
66
|
+
taskPath := filepath.Join(epicPath, "tasks", "T001-mock.md")
|
|
67
|
+
|
|
68
|
+
testutil.WriteFile(t, filepath.Join(savepointRoot, "router.md"), "# router")
|
|
69
|
+
testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# Epic\n")
|
|
70
|
+
testutil.WriteFile(t, taskPath, "---\nid: E01-mock/T001-mock\nstatus: planned\nobjective: Mock task\ndepends_on: []\n---\n\n# Task\n")
|
|
71
|
+
|
|
72
|
+
discoverer := &stubBoardDiscoverer{
|
|
73
|
+
root: savepointRoot,
|
|
74
|
+
releases: []data.ReleaseInfo{{
|
|
75
|
+
ID: "v9",
|
|
76
|
+
Path: filepath.Join(savepointRoot, "releases", "v9"),
|
|
77
|
+
}},
|
|
78
|
+
epics: map[string][]data.EpicInfo{
|
|
79
|
+
"v9": {{ID: "E01-mock", Path: epicPath}},
|
|
80
|
+
},
|
|
81
|
+
tasks: map[string][]data.TaskInfo{
|
|
82
|
+
"v9/E01-mock": {{ID: "T001-mock", Path: taskPath}},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
parser := &countingBoardParser{parser: data.NewParser()}
|
|
86
|
+
router := &stubBoardRouterReader{state: &data.RouterState{
|
|
87
|
+
State: "task-building",
|
|
88
|
+
Release: "v9",
|
|
89
|
+
Epic: "E01-mock",
|
|
90
|
+
Task: "E01-mock/T001-mock",
|
|
91
|
+
}}
|
|
92
|
+
|
|
93
|
+
model, err := newProjectModelWithDependencies(projectRoot, "", "", ModelDependencies{
|
|
94
|
+
Discoverer: discoverer,
|
|
95
|
+
Parser: parser,
|
|
96
|
+
RouterReader: router,
|
|
97
|
+
})
|
|
98
|
+
if err != nil {
|
|
99
|
+
t.Fatalf("newProjectModelWithDependencies() error = %v", err)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if discoverer.findCalls != 1 {
|
|
103
|
+
t.Fatalf("FindSavepointRoot calls = %d, want 1", discoverer.findCalls)
|
|
104
|
+
}
|
|
105
|
+
if router.calls != 1 {
|
|
106
|
+
t.Fatalf("ReadState calls = %d, want 1", router.calls)
|
|
107
|
+
}
|
|
108
|
+
if parser.frontmatterCalls != 1 || parser.taskFileCalls != 1 {
|
|
109
|
+
t.Fatalf("parser calls = frontmatter:%d task:%d, want 1 each", parser.frontmatterCalls, parser.taskFileCalls)
|
|
110
|
+
}
|
|
111
|
+
if got := model.Tasks[data.ColumnPlanned][0].ID; got != "E01-mock/T001-mock" {
|
|
112
|
+
t.Fatalf("loaded task = %q, want injected task", got)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
9
|
+
"github.com/opencode/savepoint/internal/data"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func writeRouterTaskCmd(root string, task data.Task, reader routerReader) tea.Cmd {
|
|
13
|
+
return func() tea.Msg {
|
|
14
|
+
routerPath := filepath.Join(root, "router.md")
|
|
15
|
+
fi, err := os.Stat(routerPath)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return errorMsg{message: err.Error()}
|
|
18
|
+
}
|
|
19
|
+
content, err := os.ReadFile(routerPath)
|
|
20
|
+
if err != nil {
|
|
21
|
+
return errorMsg{message: err.Error()}
|
|
22
|
+
}
|
|
23
|
+
state, err := reader.ReadState(string(content))
|
|
24
|
+
if err != nil {
|
|
25
|
+
return errorMsg{message: err.Error()}
|
|
26
|
+
}
|
|
27
|
+
state.Release = task.Release
|
|
28
|
+
state.Epic = task.Epic
|
|
29
|
+
state.State = "task-building"
|
|
30
|
+
state.Task = task.ID
|
|
31
|
+
state.NextAction = "Build " + task.ID + "."
|
|
32
|
+
if err := data.WriteRouterState(root, state, fi.ModTime()); err != nil {
|
|
33
|
+
return errorMsg{message: err.Error()}
|
|
34
|
+
}
|
|
35
|
+
message := "Router set to " + task.Release + " " + task.Epic + "/" + shortID(task.ID)
|
|
36
|
+
return routerWriteMsg{message: message, state: state, taskID: task.ID}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func writeRouterReleaseEpicCmd(root, selectedEpic, selectedRelease string, reader routerReader) tea.Cmd {
|
|
41
|
+
return func() tea.Msg {
|
|
42
|
+
routerPath := filepath.Join(root, "router.md")
|
|
43
|
+
fi, err := os.Stat(routerPath)
|
|
44
|
+
if err != nil {
|
|
45
|
+
return errorMsg{message: err.Error()}
|
|
46
|
+
}
|
|
47
|
+
content, err := os.ReadFile(routerPath)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return errorMsg{message: err.Error()}
|
|
50
|
+
}
|
|
51
|
+
state, err := reader.ReadState(string(content))
|
|
52
|
+
if err != nil {
|
|
53
|
+
return errorMsg{message: err.Error()}
|
|
54
|
+
}
|
|
55
|
+
state.Epic = shortID(selectedEpic)
|
|
56
|
+
state.Release = selectedRelease
|
|
57
|
+
if err := data.WriteRouterState(root, state, fi.ModTime()); err != nil {
|
|
58
|
+
return errorMsg{message: err.Error()}
|
|
59
|
+
}
|
|
60
|
+
return routerWriteMsg{state: state}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func writeTaskStatusCmd(orig, next data.Task, expectedMtime time.Time, prefix string) tea.Cmd {
|
|
65
|
+
return func() tea.Msg {
|
|
66
|
+
if err := data.WriteTaskStatus(next.Path, &next, expectedMtime); err != nil {
|
|
67
|
+
return errorMsg{message: taskWriteErrorMessage(err)}
|
|
68
|
+
}
|
|
69
|
+
fi, err := os.Stat(next.Path)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return errorMsg{message: err.Error()}
|
|
72
|
+
}
|
|
73
|
+
next.Mtime = fi.ModTime()
|
|
74
|
+
return taskWriteMsg{prefix: prefix, next: next}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func readEpicDetailCmd(epicDir, shortIDStr string) tea.Cmd {
|
|
79
|
+
return func() tea.Msg {
|
|
80
|
+
content := readEpicDetailFile(epicDir, shortIDStr)
|
|
81
|
+
return epicDetailMsg{content: content}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func readEpicAuditCmd(epicDir, shortIDStr string) tea.Cmd {
|
|
86
|
+
return func() tea.Msg {
|
|
87
|
+
raw, err := os.ReadFile(filepath.Join(epicDir, shortIDStr+"-Audit.md"))
|
|
88
|
+
if err != nil {
|
|
89
|
+
return auditContentMsg{content: "(no audit available)"}
|
|
90
|
+
}
|
|
91
|
+
return auditContentMsg{content: string(raw)}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/internal/board/layout.go
CHANGED
|
@@ -3,7 +3,8 @@ package board
|
|
|
3
3
|
const (
|
|
4
4
|
colOverhead = 4 // rounded border (1) + padding (1) each side
|
|
5
5
|
|
|
6
|
-
minColWidth
|
|
6
|
+
minColWidth = 10
|
|
7
|
+
minContentHeight = 5
|
|
7
8
|
|
|
8
9
|
epicPanelWidth = 28
|
|
9
10
|
epicPanelOverhead = 4
|
|
@@ -20,6 +21,7 @@ type Layout struct {
|
|
|
20
21
|
EpicPanelWidth int
|
|
21
22
|
ColCount int
|
|
22
23
|
ColWidths []int
|
|
24
|
+
ContentHeight int
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
// CalculateLayout returns the board layout for the given terminal dimensions.
|
|
@@ -27,7 +29,12 @@ type Layout struct {
|
|
|
27
29
|
// - >=120 cols: epic panel (28w) + 3 columns
|
|
28
30
|
// - 80–119 cols: 3 columns only
|
|
29
31
|
// - <80 cols: 1 column
|
|
30
|
-
func CalculateLayout(width,
|
|
32
|
+
func CalculateLayout(width, height int) Layout {
|
|
33
|
+
return CalculateLayoutWithChrome(width, height, 0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func CalculateLayoutWithChrome(width, height, extraHeaderLines int) Layout {
|
|
37
|
+
contentHeight := max(height-10-extraHeaderLines, minContentHeight)
|
|
31
38
|
inner := width - boardFrameOverhead
|
|
32
39
|
switch {
|
|
33
40
|
case width >= breakpointWide:
|
|
@@ -38,6 +45,7 @@ func CalculateLayout(width, _ int) Layout {
|
|
|
38
45
|
EpicPanelWidth: epicPanelWidth,
|
|
39
46
|
ColCount: 3,
|
|
40
47
|
ColWidths: []int{cw, cw, cw},
|
|
48
|
+
ContentHeight: contentHeight,
|
|
41
49
|
}
|
|
42
50
|
case width >= breakpointNarrow:
|
|
43
51
|
available := inner - 3*colOverhead
|
|
@@ -46,6 +54,7 @@ func CalculateLayout(width, _ int) Layout {
|
|
|
46
54
|
EpicPanelVisible: false,
|
|
47
55
|
ColCount: 3,
|
|
48
56
|
ColWidths: []int{cw, cw, cw},
|
|
57
|
+
ContentHeight: contentHeight,
|
|
49
58
|
}
|
|
50
59
|
default:
|
|
51
60
|
cw := max(inner-colOverhead, minColWidth)
|
|
@@ -53,6 +62,7 @@ func CalculateLayout(width, _ int) Layout {
|
|
|
53
62
|
EpicPanelVisible: false,
|
|
54
63
|
ColCount: 1,
|
|
55
64
|
ColWidths: []int{cw},
|
|
65
|
+
ContentHeight: contentHeight,
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
}
|
|
@@ -22,6 +22,9 @@ func TestCalculateLayout_wide(t *testing.T) {
|
|
|
22
22
|
t.Errorf("ColWidths[%d] = %d, want 24", i, w)
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
if l.ContentHeight != 30 {
|
|
26
|
+
t.Errorf("ContentHeight = %d, want 30", l.ContentHeight)
|
|
27
|
+
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
func TestCalculateLayout_medium(t *testing.T) {
|
|
@@ -66,6 +69,20 @@ func TestCalculateLayout_tinyWidth_floorsAtMinColWidth(t *testing.T) {
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
func TestCalculateLayout_tinyHeight_floorsAtMinContentHeight(t *testing.T) {
|
|
73
|
+
l := CalculateLayout(100, 8)
|
|
74
|
+
if l.ContentHeight != minContentHeight {
|
|
75
|
+
t.Errorf("ContentHeight = %d, want %d", l.ContentHeight, minContentHeight)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestCalculateLayoutWithChrome_accountsForExtraHeaderLine(t *testing.T) {
|
|
80
|
+
l := CalculateLayoutWithChrome(120, 40, 1)
|
|
81
|
+
if l.ContentHeight != 29 {
|
|
82
|
+
t.Errorf("ContentHeight = %d, want 29", l.ContentHeight)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
func TestCalculateLayout_breakpointBoundaries(t *testing.T) {
|
|
70
87
|
cases := []struct {
|
|
71
88
|
width int
|