savepoint 1.0.0 → 1.0.2
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 +8 -1
- package/.savepoint/Design.md +26 -17
- package/.savepoint/audit/v1/E01/proposals.md +168 -0
- package/.savepoint/audit/v1/E01/snapshot.md +78 -0
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
- package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +130 -0
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +84 -0
- package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
- package/.savepoint/config.yml +3 -3
- 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/E06-Detail.md +62 -0
- 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/T007-detail-card-fixes.md +7 -7
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T008-checkbox-states.md +10 -8
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T009-router-priority-marker.md +16 -9
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +27 -22
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/E01-Detail.md +40 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-next-activity-header.md +56 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +38 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +28 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +51 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +45 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +68 -0
- 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-Detail.md +49 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +37 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +38 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +36 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +59 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -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-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-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
- package/.savepoint/router.md +33 -105
- package/.savepoint/visual-identity.md +4 -3
- package/AGENTS.md +56 -113
- package/Makefile +19 -3
- package/README.md +7 -6
- package/agent-skills/savepoint-audit/SKILL.md +6 -6
- package/agent-skills/savepoint-build-task/SKILL.md +2 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
- package/agent-skills/savepoint-create-task/SKILL.md +2 -2
- package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
- package/agent-skills/savepoint-system-design/SKILL.md +1 -1
- package/go.mod +4 -1
- package/go.sum +2 -0
- package/internal/board/board.go +66 -14
- package/internal/board/board_test.go +124 -0
- package/internal/board/card.go +40 -3
- package/internal/board/card_test.go +121 -14
- package/internal/board/column.go +40 -5
- package/internal/board/column_test.go +65 -10
- package/internal/board/detail.go +115 -23
- package/internal/board/detail_test.go +132 -25
- package/internal/board/epic_panel.go +105 -8
- package/internal/board/epic_panel_test.go +343 -5
- package/internal/board/layout.go +12 -2
- package/internal/board/layout_test.go +17 -0
- package/internal/board/model.go +146 -23
- package/internal/board/render_policy_test.go +77 -0
- package/internal/board/status.go +23 -0
- package/internal/board/update.go +300 -9
- package/internal/board/update_test.go +166 -0
- package/internal/board/view.go +141 -17
- package/internal/board/view_test.go +161 -3
- package/internal/board/watch.go +100 -0
- package/internal/buildtool/main.go +219 -0
- package/internal/data/parser.go +39 -1
- package/internal/data/parser_test.go +43 -2
- package/internal/data/task.go +22 -2
- package/internal/styles/palette.go +9 -7
- package/internal/styles/styles.go +42 -25
- package/main.go +9 -0
- package/package.json +5 -4
- package/savepoint +0 -0
- package/savepoint.exe +0 -0
- package/templates/project/.savepoint/router.md +6 -5
- package/templates/project/AGENTS.md +47 -101
- package/templates/prompts/audit-reconciliation.prompt.md +6 -6
- 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/releases/v1/epics/E06-atari-noir-layout/Design.md +0 -42
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/Design.md +0 -26
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -35
- package/main.exe +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
- /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
- /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
- /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
- /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
- /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"regexp"
|
|
4
5
|
"strings"
|
|
5
6
|
"testing"
|
|
6
7
|
|
|
@@ -8,6 +9,12 @@ import (
|
|
|
8
9
|
"github.com/opencode/savepoint/internal/data"
|
|
9
10
|
)
|
|
10
11
|
|
|
12
|
+
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
|
13
|
+
|
|
14
|
+
func plainTerminal(s string) string {
|
|
15
|
+
return ansiPattern.ReplaceAllString(s, "")
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
func sampleTask() data.Task {
|
|
12
19
|
return data.Task{
|
|
13
20
|
ID: "E04/T001",
|
|
@@ -20,49 +27,49 @@ func sampleTask() data.Task {
|
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
func TestRenderDetail_containsID(t *testing.T) {
|
|
23
|
-
got := RenderDetail(sampleTask(), 60)
|
|
30
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
24
31
|
if !strings.Contains(got, "E04/T001") {
|
|
25
32
|
t.Error("RenderDetail missing task ID")
|
|
26
33
|
}
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
func TestRenderDetail_containsTitle(t *testing.T) {
|
|
30
|
-
got := RenderDetail(sampleTask(), 60)
|
|
37
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
31
38
|
if !strings.Contains(got, "My Task") {
|
|
32
39
|
t.Error("RenderDetail missing task title")
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
func TestRenderDetail_containsEpic(t *testing.T) {
|
|
37
|
-
got := RenderDetail(sampleTask(), 60)
|
|
44
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
38
45
|
if !strings.Contains(got, "E04-board-components") {
|
|
39
46
|
t.Error("RenderDetail missing epic")
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
func TestRenderDetail_containsRelease(t *testing.T) {
|
|
44
|
-
got := RenderDetail(sampleTask(), 60)
|
|
51
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
45
52
|
if !strings.Contains(got, "v1") {
|
|
46
53
|
t.Error("RenderDetail missing release")
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
func TestRenderDetail_containsStatus(t *testing.T) {
|
|
51
|
-
got := RenderDetail(sampleTask(), 60)
|
|
58
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
52
59
|
if !strings.Contains(got, "in_progress") {
|
|
53
60
|
t.Error("RenderDetail missing status")
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
func TestRenderDetail_containsPhase(t *testing.T) {
|
|
58
|
-
got := RenderDetail(sampleTask(), 60)
|
|
65
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
59
66
|
if !strings.Contains(got, "build") {
|
|
60
67
|
t.Error("RenderDetail missing phase")
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
func TestRenderDetail_containsEscHint(t *testing.T) {
|
|
65
|
-
got := RenderDetail(sampleTask(), 60)
|
|
72
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
66
73
|
if !strings.Contains(got, "esc") {
|
|
67
74
|
t.Error("RenderDetail missing esc:close hint")
|
|
68
75
|
}
|
|
@@ -71,35 +78,23 @@ func TestRenderDetail_containsEscHint(t *testing.T) {
|
|
|
71
78
|
func TestRenderDetail_containsDescription(t *testing.T) {
|
|
72
79
|
tk := sampleTask()
|
|
73
80
|
tk.Description = "some description text"
|
|
74
|
-
got := RenderDetail(tk, 60)
|
|
81
|
+
got := RenderDetail(tk, 60, nil, 0, 0)
|
|
75
82
|
if !strings.Contains(got, "some description text") {
|
|
76
83
|
t.Error("RenderDetail missing description text")
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
|
|
81
|
-
got := RenderDetail(sampleTask(), 60)
|
|
88
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
82
89
|
if strings.Contains(got, "Description:") {
|
|
83
90
|
t.Error("RenderDetail should not show Description section when empty")
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
|
|
88
|
-
tk := sampleTask()
|
|
89
|
-
tk.Acceptance = []string{"criterion one", "criterion two"}
|
|
90
|
-
got := RenderDetail(tk, 60)
|
|
91
|
-
if !strings.Contains(got, "criterion one") {
|
|
92
|
-
t.Error("RenderDetail missing first acceptance criterion")
|
|
93
|
-
}
|
|
94
|
-
if !strings.Contains(got, "criterion two") {
|
|
95
|
-
t.Error("RenderDetail missing second acceptance criterion")
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
94
|
func TestRenderDetail_containsChecklist(t *testing.T) {
|
|
100
95
|
tk := sampleTask()
|
|
101
|
-
tk.Checklist = []
|
|
102
|
-
got := RenderDetail(tk, 60)
|
|
96
|
+
tk.Checklist = []data.CheckItem{{Text: "first implementation item"}, {Text: "second implementation item", Done: true}}
|
|
97
|
+
got := RenderDetail(tk, 60, nil, 0, 0)
|
|
103
98
|
if !strings.Contains(got, "Implementation Plan:") {
|
|
104
99
|
t.Error("RenderDetail missing implementation plan heading")
|
|
105
100
|
}
|
|
@@ -111,10 +106,70 @@ func TestRenderDetail_containsChecklist(t *testing.T) {
|
|
|
111
106
|
}
|
|
112
107
|
}
|
|
113
108
|
|
|
109
|
+
func TestRenderDetail_checklistSingleSentenceGetsOneCheckbox(t *testing.T) {
|
|
110
|
+
tk := sampleTask()
|
|
111
|
+
tk.Checklist = []data.CheckItem{{Text: "single sentence task"}}
|
|
112
|
+
|
|
113
|
+
got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
|
|
114
|
+
|
|
115
|
+
if count := strings.Count(got, "[ ]"); count != 1 {
|
|
116
|
+
t.Fatalf("RenderDetail checkbox count = %d, want 1\n%s", count, got)
|
|
117
|
+
}
|
|
118
|
+
if strings.Contains(got, "[x]") {
|
|
119
|
+
t.Fatal("RenderDetail should not render checked marker for unchecked item")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestRenderDetail_checklistMultiSentenceGetsOneCheckboxPerSentence(t *testing.T) {
|
|
124
|
+
tk := sampleTask()
|
|
125
|
+
tk.Checklist = []data.CheckItem{{Text: "First sentence. Second sentence! Third sentence?"}}
|
|
126
|
+
|
|
127
|
+
got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
|
|
128
|
+
|
|
129
|
+
if count := strings.Count(got, "[ ]"); count != 3 {
|
|
130
|
+
t.Fatalf("RenderDetail checkbox count = %d, want 3\n%s", count, got)
|
|
131
|
+
}
|
|
132
|
+
for _, want := range []string{"[ ] First sentence.", "[ ] Second sentence!", "[ ] Third sentence?"} {
|
|
133
|
+
if !strings.Contains(got, want) {
|
|
134
|
+
t.Fatalf("RenderDetail missing sentence checkbox line %q\n%s", want, got)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestRenderDetail_checklistHardWrappedSentenceDoesNotDuplicateCheckbox(t *testing.T) {
|
|
140
|
+
tk := sampleTask()
|
|
141
|
+
tk.Checklist = []data.CheckItem{{
|
|
142
|
+
Text: "This sentence is intentionally long enough to wrap inside a narrow detail overlay while remaining one semantic sentence.",
|
|
143
|
+
}}
|
|
144
|
+
|
|
145
|
+
got := plainTerminal(RenderDetail(tk, 34, nil, 0, 0))
|
|
146
|
+
|
|
147
|
+
if count := strings.Count(got, "[ ]"); count != 1 {
|
|
148
|
+
t.Fatalf("RenderDetail checkbox count = %d, want 1\n%s", count, got)
|
|
149
|
+
}
|
|
150
|
+
if !strings.Contains(got, " intentionally long enough") {
|
|
151
|
+
t.Fatalf("RenderDetail continuation line should align under checkbox text\n%s", got)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func TestRenderDetail_checklistCheckedSentenceUsesCheckedMarker(t *testing.T) {
|
|
156
|
+
tk := sampleTask()
|
|
157
|
+
tk.Checklist = []data.CheckItem{{Text: "already done. still done.", Done: true}}
|
|
158
|
+
|
|
159
|
+
got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
|
|
160
|
+
|
|
161
|
+
if count := strings.Count(got, "[x]"); count != 2 {
|
|
162
|
+
t.Fatalf("RenderDetail checked checkbox count = %d, want 2\n%s", count, got)
|
|
163
|
+
}
|
|
164
|
+
if strings.Contains(got, "[ ]") {
|
|
165
|
+
t.Fatal("RenderDetail should not render unchecked marker for checked item")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
114
169
|
func TestRenderDetail_wrapsLongDescription(t *testing.T) {
|
|
115
170
|
tk := sampleTask()
|
|
116
171
|
tk.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda"
|
|
117
|
-
got := RenderDetail(tk, 30)
|
|
172
|
+
got := RenderDetail(tk, 30, nil, 0, 0)
|
|
118
173
|
if strings.Contains(got, tk.Description) {
|
|
119
174
|
t.Error("RenderDetail should wrap long description text")
|
|
120
175
|
}
|
|
@@ -124,7 +179,7 @@ func TestRenderDetail_wrapsLongDescription(t *testing.T) {
|
|
|
124
179
|
}
|
|
125
180
|
|
|
126
181
|
func TestRenderDetail_noAcceptanceSectionWhenEmpty(t *testing.T) {
|
|
127
|
-
got := RenderDetail(sampleTask(), 60)
|
|
182
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
128
183
|
if strings.Contains(got, "Acceptance Criteria:") {
|
|
129
184
|
t.Error("RenderDetail should not show Acceptance section when empty")
|
|
130
185
|
}
|
|
@@ -214,6 +269,58 @@ func TestView_detailOverlayRendered(t *testing.T) {
|
|
|
214
269
|
}
|
|
215
270
|
}
|
|
216
271
|
|
|
272
|
+
func TestRenderDetail_routerPriorityLabel(t *testing.T) {
|
|
273
|
+
task := sampleTask()
|
|
274
|
+
router := &data.RouterState{Release: task.Release, Epic: task.Epic, Task: task.ID}
|
|
275
|
+
got := RenderDetail(task, 60, router, 0, 0)
|
|
276
|
+
if !strings.Contains(got, "(router priority)") {
|
|
277
|
+
t.Error("RenderDetail missing router priority label for matching task")
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func TestRenderDetail_noRouterPriorityLabelWhenNoMatch(t *testing.T) {
|
|
282
|
+
task := sampleTask()
|
|
283
|
+
router := &data.RouterState{Release: task.Release, Epic: task.Epic, Task: "other-id"}
|
|
284
|
+
got := RenderDetail(task, 60, router, 0, 0)
|
|
285
|
+
if strings.Contains(got, "(router priority)") {
|
|
286
|
+
t.Error("RenderDetail should not show router priority label for non-matching task")
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func TestRenderDetail_viewportShowsScrollIndicators(t *testing.T) {
|
|
291
|
+
task := sampleTask()
|
|
292
|
+
task.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron"
|
|
293
|
+
|
|
294
|
+
got := RenderDetail(task, 32, nil, 8, 2)
|
|
295
|
+
|
|
296
|
+
if !strings.Contains(got, "↑ 2 above") {
|
|
297
|
+
t.Error("RenderDetail missing above indicator")
|
|
298
|
+
}
|
|
299
|
+
if !strings.Contains(got, "↓") || !strings.Contains(got, "more") {
|
|
300
|
+
t.Error("RenderDetail missing more indicator")
|
|
301
|
+
}
|
|
302
|
+
if strings.Contains(got, "ID:") {
|
|
303
|
+
t.Error("RenderDetail should not render body lines above viewport")
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
|
|
308
|
+
m := NewModel([]data.Task{sampleTask()}, "v1", "E04-board-components")
|
|
309
|
+
m.Overlay = OverlayDetail
|
|
310
|
+
|
|
311
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
|
|
312
|
+
updated := requireModel(t, got)
|
|
313
|
+
if updated.DetailOffset != 1 {
|
|
314
|
+
t.Errorf("DetailOffset after j = %d, want 1", updated.DetailOffset)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
|
|
318
|
+
updated = requireModel(t, got)
|
|
319
|
+
if updated.DetailOffset != 0 {
|
|
320
|
+
t.Errorf("DetailOffset after k = %d, want 0", updated.DetailOffset)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
217
324
|
func TestOverlayWidth_clampMax(t *testing.T) {
|
|
218
325
|
if got := overlayWidth(120); got != 80 {
|
|
219
326
|
t.Errorf("overlayWidth(120) = %d, want 80", got)
|
|
@@ -3,14 +3,85 @@ package board
|
|
|
3
3
|
import (
|
|
4
4
|
"strings"
|
|
5
5
|
|
|
6
|
+
"github.com/charmbracelet/lipgloss"
|
|
6
7
|
"github.com/opencode/savepoint/internal/styles"
|
|
7
8
|
)
|
|
8
9
|
|
|
10
|
+
// RenderEpicDetail renders an overlay showing the content of an E##-Detail.md file.
|
|
11
|
+
func RenderEpicDetail(epicSlug, content string, overlayW, maxHeight, offset int) string {
|
|
12
|
+
inner := overlayW - detailBorderPad
|
|
13
|
+
if inner < 4 {
|
|
14
|
+
inner = 4
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
lines := []string{
|
|
18
|
+
styles.EpicTitleFocused.Render("EPIC DETAIL"),
|
|
19
|
+
strings.Repeat("─", inner),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body := epicDetailBody(content, inner)
|
|
23
|
+
body = append(body, "", styles.CardMeta.Render("esc:close"))
|
|
24
|
+
lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead, offset)...)
|
|
25
|
+
|
|
26
|
+
return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// epicDetailBody parses markdown content into display lines, stripping frontmatter.
|
|
30
|
+
func epicDetailBody(content string, width int) []string {
|
|
31
|
+
if strings.TrimSpace(content) == "" || content == "(no detail available)" {
|
|
32
|
+
return []string{styles.CardMeta.Render("(no detail available)")}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines := strings.Split(content, "\n")
|
|
36
|
+
|
|
37
|
+
// Strip YAML frontmatter between leading --- markers.
|
|
38
|
+
start := 0
|
|
39
|
+
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
|
40
|
+
for i := 1; i < len(lines); i++ {
|
|
41
|
+
if strings.TrimSpace(lines[i]) == "---" {
|
|
42
|
+
start = i + 1
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lines = lines[start:]
|
|
48
|
+
|
|
49
|
+
var body []string
|
|
50
|
+
skip := false
|
|
51
|
+
for _, line := range lines {
|
|
52
|
+
trimmed := strings.TrimRight(line, " \t")
|
|
53
|
+
if strings.HasPrefix(trimmed, "## ") {
|
|
54
|
+
heading := strings.ToLower(strings.TrimPrefix(trimmed, "## "))
|
|
55
|
+
skip = strings.Contains(heading, "component") || strings.Contains(heading, "files")
|
|
56
|
+
}
|
|
57
|
+
if skip {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
switch {
|
|
61
|
+
case strings.HasPrefix(trimmed, "# "):
|
|
62
|
+
body = append(body, styles.EpicTitleFocused.Render(strings.TrimPrefix(trimmed, "# ")))
|
|
63
|
+
case strings.HasPrefix(trimmed, "## "):
|
|
64
|
+
body = append(body, "", styles.EpicItemFocused.Render(strings.TrimPrefix(trimmed, "## ")))
|
|
65
|
+
case strings.HasPrefix(trimmed, "### "):
|
|
66
|
+
body = append(body, styles.EpicItemFocused.Render(strings.TrimPrefix(trimmed, "### ")))
|
|
67
|
+
case strings.HasPrefix(trimmed, "|"):
|
|
68
|
+
body = append(body, styles.CardMeta.Render(trimmed))
|
|
69
|
+
case trimmed == "":
|
|
70
|
+
body = append(body, "")
|
|
71
|
+
default:
|
|
72
|
+
for _, wrapped := range WrapText(trimmed, width) {
|
|
73
|
+
body = append(body, styles.CardMeta.Render(wrapped))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return body
|
|
78
|
+
}
|
|
79
|
+
|
|
9
80
|
const epicActiveMarker = "►"
|
|
10
81
|
|
|
11
82
|
// RenderEpicSidebar renders the fixed left sidebar listing epics with active indicator.
|
|
12
83
|
// If epics is empty and selected is non-empty, selected is shown as the sole entry.
|
|
13
|
-
func RenderEpicSidebar(epics []string, selected string, width int) string {
|
|
84
|
+
func RenderEpicSidebar(epics []string, selected string, width int, focus bool, cursor int, status map[string]string) string {
|
|
14
85
|
inner := width - epicPanelOverhead
|
|
15
86
|
if inner < 2 {
|
|
16
87
|
inner = 2
|
|
@@ -20,19 +91,45 @@ func RenderEpicSidebar(epics []string, selected string, width int) string {
|
|
|
20
91
|
list = []string{selected}
|
|
21
92
|
}
|
|
22
93
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
94
|
+
title := styles.ColumnTitle.Render("EPICS")
|
|
95
|
+
if focus {
|
|
96
|
+
title = styles.EpicTitleFocused.Render("EPICS")
|
|
97
|
+
}
|
|
98
|
+
lines := []string{title, strings.Repeat("─", inner)}
|
|
99
|
+
for i, e := range list {
|
|
100
|
+
g := epicSidebarGlyph(status, e)
|
|
101
|
+
gw := lipgloss.Width(g)
|
|
102
|
+
if gw < 1 {
|
|
103
|
+
gw = 1
|
|
104
|
+
}
|
|
105
|
+
label := truncate(e, inner-2-gw)
|
|
106
|
+
if focus && len(epics) > 0 && i == cursor {
|
|
107
|
+
lines = append(lines, styles.EpicItemFocused.Render(epicActiveMarker+" "+g+" "+label))
|
|
108
|
+
} else if !focus && e == selected {
|
|
109
|
+
lines = append(lines, styles.EpicItemFocused.Render(epicActiveMarker+" "+g+" "+label))
|
|
28
110
|
} else {
|
|
29
|
-
lines = append(lines, styles.TaskItem.Render(" "+label))
|
|
111
|
+
lines = append(lines, styles.TaskItem.Render(" "+g+" "+label))
|
|
30
112
|
}
|
|
31
113
|
}
|
|
32
114
|
if len(list) == 0 {
|
|
33
115
|
lines = append(lines, styles.TaskItem.Render("(none)"))
|
|
34
116
|
}
|
|
35
|
-
|
|
117
|
+
style := styles.EpicPanel.Width(width)
|
|
118
|
+
if focus && len(epics) > 0 {
|
|
119
|
+
style = styles.EpicPanelFocused.Width(width)
|
|
120
|
+
}
|
|
121
|
+
return style.Render(strings.Join(lines, "\n"))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func epicSidebarGlyph(status map[string]string, epicID string) string {
|
|
125
|
+
if status == nil {
|
|
126
|
+
return statusGlyphDefault
|
|
127
|
+
}
|
|
128
|
+
s, ok := status[epicID]
|
|
129
|
+
if !ok {
|
|
130
|
+
return statusGlyphDefault
|
|
131
|
+
}
|
|
132
|
+
return statusGlyph(s)
|
|
36
133
|
}
|
|
37
134
|
|
|
38
135
|
// RenderEpicDropdown renders the epic selection dropdown overlay.
|