savepoint 1.0.0 → 1.0.1
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 +5 -1
- package/.savepoint/Design.md +8 -4
- package/.savepoint/audit/E06-atari-noir-layout/proposals.md +130 -0
- package/.savepoint/audit/E06-atari-noir-layout/snapshot.md +84 -0
- package/.savepoint/config.yml +3 -3
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/Design.md +24 -6
- 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 +25 -22
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/Design.md +10 -4
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +2 -1
- 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 +50 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +44 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +58 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-next-activity-header.md +55 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/Design.md +40 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +34 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +33 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +32 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +38 -0
- package/.savepoint/router.md +1 -1
- package/.savepoint/visual-identity.md +4 -3
- package/AGENTS.md +3 -3
- package/README.md +1 -1
- package/go.mod +4 -1
- package/go.sum +2 -0
- package/internal/board/board.go +42 -6
- package/internal/board/board_test.go +53 -0
- package/internal/board/card.go +9 -3
- package/internal/board/card_test.go +28 -14
- package/internal/board/column.go +2 -2
- package/internal/board/column_test.go +17 -9
- package/internal/board/detail.go +21 -11
- package/internal/board/detail_test.go +30 -14
- package/internal/board/model.go +7 -1
- package/internal/board/update.go +24 -1
- package/internal/board/view.go +13 -3
- package/internal/board/view_test.go +2 -2
- package/internal/board/watch.go +82 -0
- package/internal/data/parser.go +31 -1
- package/internal/data/parser_test.go +8 -2
- package/internal/data/task.go +12 -2
- package/internal/styles/palette.go +6 -4
- package/internal/styles/styles.go +5 -15
- package/package.json +5 -4
- package/savepoint +0 -0
|
@@ -9,7 +9,7 @@ import (
|
|
|
9
9
|
|
|
10
10
|
func TestRenderCard_containsID(t *testing.T) {
|
|
11
11
|
task := data.Task{ID: "E04/T002", Title: "Build card", Stage: data.StageBuild}
|
|
12
|
-
got := RenderCard(task, 30, false)
|
|
12
|
+
got := RenderCard(task, 30, false, "")
|
|
13
13
|
if !strings.Contains(got, "T002") {
|
|
14
14
|
t.Error("RenderCard missing short task ID")
|
|
15
15
|
}
|
|
@@ -17,7 +17,7 @@ func TestRenderCard_containsID(t *testing.T) {
|
|
|
17
17
|
|
|
18
18
|
func TestRenderCard_containsTitle(t *testing.T) {
|
|
19
19
|
task := data.Task{ID: "T1", Title: "My title", Stage: data.StageBuild}
|
|
20
|
-
got := RenderCard(task, 30, false)
|
|
20
|
+
got := RenderCard(task, 30, false, "")
|
|
21
21
|
if !strings.Contains(got, "My title") {
|
|
22
22
|
t.Error("RenderCard missing task title")
|
|
23
23
|
}
|
|
@@ -25,7 +25,7 @@ func TestRenderCard_containsTitle(t *testing.T) {
|
|
|
25
25
|
|
|
26
26
|
func TestRenderCard_containsBuildGlyph(t *testing.T) {
|
|
27
27
|
task := data.Task{ID: "T1", Stage: data.StageBuild}
|
|
28
|
-
got := RenderCard(task, 30, false)
|
|
28
|
+
got := RenderCard(task, 30, false, "")
|
|
29
29
|
if !strings.Contains(got, glyphBuild) {
|
|
30
30
|
t.Errorf("RenderCard missing build glyph %q", glyphBuild)
|
|
31
31
|
}
|
|
@@ -33,7 +33,7 @@ func TestRenderCard_containsBuildGlyph(t *testing.T) {
|
|
|
33
33
|
|
|
34
34
|
func TestRenderCard_containsTestGlyph(t *testing.T) {
|
|
35
35
|
task := data.Task{ID: "T1", Stage: data.StageTest}
|
|
36
|
-
got := RenderCard(task, 30, false)
|
|
36
|
+
got := RenderCard(task, 30, false, "")
|
|
37
37
|
if !strings.Contains(got, glyphTest) {
|
|
38
38
|
t.Errorf("RenderCard missing test glyph %q", glyphTest)
|
|
39
39
|
}
|
|
@@ -41,7 +41,7 @@ func TestRenderCard_containsTestGlyph(t *testing.T) {
|
|
|
41
41
|
|
|
42
42
|
func TestRenderCard_containsAuditGlyph(t *testing.T) {
|
|
43
43
|
task := data.Task{ID: "T1", Stage: data.StageAudit}
|
|
44
|
-
got := RenderCard(task, 30, false)
|
|
44
|
+
got := RenderCard(task, 30, false, "")
|
|
45
45
|
if !strings.Contains(got, glyphAudit) {
|
|
46
46
|
t.Errorf("RenderCard missing audit glyph %q", glyphAudit)
|
|
47
47
|
}
|
|
@@ -49,28 +49,30 @@ func TestRenderCard_containsAuditGlyph(t *testing.T) {
|
|
|
49
49
|
|
|
50
50
|
func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
|
|
51
51
|
task := data.Task{ID: "T1", Title: "hello", Stage: data.StageBuild}
|
|
52
|
-
got := RenderCard(task, 30, true)
|
|
52
|
+
got := RenderCard(task, 30, true, "")
|
|
53
53
|
if got == "" {
|
|
54
54
|
t.Error("RenderCard focused returned empty string")
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
func
|
|
59
|
-
long := "This is a very long title that should be
|
|
58
|
+
func TestRenderCard_titleWraps(t *testing.T) {
|
|
59
|
+
long := "This is a very long title that should be wrapped for sure"
|
|
60
60
|
task := data.Task{ID: "T1", Title: long, Stage: data.StageBuild}
|
|
61
|
-
got := RenderCard(task, 20, false)
|
|
61
|
+
got := RenderCard(task, 20, false, "")
|
|
62
|
+
// full title as one line does not fit; it must be broken up
|
|
62
63
|
if strings.Contains(got, long) {
|
|
63
|
-
t.Error("RenderCard should
|
|
64
|
+
t.Error("RenderCard should wrap long title, not render it as one line")
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// words from the title must still appear somewhere in the output
|
|
67
|
+
if !strings.Contains(got, "This") {
|
|
68
|
+
t.Error("RenderCard wrapped title missing expected content")
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
func TestRenderCard_idTruncated(t *testing.T) {
|
|
71
73
|
long := "E04-board-components/T999-very-long-id"
|
|
72
74
|
task := data.Task{ID: long, Stage: data.StageBuild}
|
|
73
|
-
got := RenderCard(task, 20, false)
|
|
75
|
+
got := RenderCard(task, 20, false, "")
|
|
74
76
|
if strings.Contains(got, long) {
|
|
75
77
|
t.Error("RenderCard should truncate long ID")
|
|
76
78
|
}
|
|
@@ -104,8 +106,20 @@ func TestTruncate_maxOne(t *testing.T) {
|
|
|
104
106
|
|
|
105
107
|
func TestRenderCard_defaultStageUsesBuildGlyph(t *testing.T) {
|
|
106
108
|
task := data.Task{ID: "T1", Stage: ""}
|
|
107
|
-
got := RenderCard(task, 30, false)
|
|
109
|
+
got := RenderCard(task, 30, false, "")
|
|
108
110
|
if !strings.Contains(got, glyphBuild) {
|
|
109
111
|
t.Error("RenderCard with empty stage should use build glyph")
|
|
110
112
|
}
|
|
111
113
|
}
|
|
114
|
+
|
|
115
|
+
func TestRenderCard_routerPriorityUsesGreenGlyph(t *testing.T) {
|
|
116
|
+
task := data.Task{ID: "E06/T009", Stage: data.StageTest}
|
|
117
|
+
got := RenderCard(task, 30, false, "E06/T009")
|
|
118
|
+
if !strings.Contains(got, glyphBuild) {
|
|
119
|
+
t.Error("router priority card should use build glyph")
|
|
120
|
+
}
|
|
121
|
+
nonPriority := RenderCard(task, 30, false, "")
|
|
122
|
+
if !strings.Contains(nonPriority, glyphTest) {
|
|
123
|
+
t.Error("non-priority test card should use test glyph")
|
|
124
|
+
}
|
|
125
|
+
}
|
package/internal/board/column.go
CHANGED
|
@@ -9,7 +9,7 @@ import (
|
|
|
9
9
|
)
|
|
10
10
|
|
|
11
11
|
// RenderColumn renders a board column: header with label+count, task list, bordered container.
|
|
12
|
-
func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool) string {
|
|
12
|
+
func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool, routerTaskID string) string {
|
|
13
13
|
inner := width - colOverhead
|
|
14
14
|
if inner < minColWidth {
|
|
15
15
|
inner = minColWidth
|
|
@@ -28,7 +28,7 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int
|
|
|
28
28
|
lines = append(lines, styles.TaskItem.Render("(empty)"))
|
|
29
29
|
} else {
|
|
30
30
|
for i, t := range tasks {
|
|
31
|
-
lines = append(lines, RenderCard(t, inner, focused && i == focusedTask))
|
|
31
|
+
lines = append(lines, RenderCard(t, inner, focused && i == focusedTask, routerTaskID))
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -8,7 +8,7 @@ import (
|
|
|
8
8
|
)
|
|
9
9
|
|
|
10
10
|
func TestRenderColumn_headerContainsLabel(t *testing.T) {
|
|
11
|
-
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false)
|
|
11
|
+
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false, "")
|
|
12
12
|
if !strings.Contains(got, "PLANNED") {
|
|
13
13
|
t.Error("RenderColumn missing PLANNED label")
|
|
14
14
|
}
|
|
@@ -16,14 +16,14 @@ func TestRenderColumn_headerContainsLabel(t *testing.T) {
|
|
|
16
16
|
|
|
17
17
|
func TestRenderColumn_headerContainsCount(t *testing.T) {
|
|
18
18
|
tasks := []data.Task{{ID: "T1", Title: "Task one", Column: data.ColumnPlanned}}
|
|
19
|
-
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false)
|
|
19
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false, "")
|
|
20
20
|
if !strings.Contains(got, "(1)") {
|
|
21
21
|
t.Error("RenderColumn missing task count")
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
|
|
26
|
-
got := RenderColumn(nil, data.ColumnDone, 30, 0, false)
|
|
26
|
+
got := RenderColumn(nil, data.ColumnDone, 30, 0, false, "")
|
|
27
27
|
if !strings.Contains(got, "(empty)") {
|
|
28
28
|
t.Error("RenderColumn missing (empty) for empty column")
|
|
29
29
|
}
|
|
@@ -31,7 +31,7 @@ func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
|
|
|
31
31
|
|
|
32
32
|
func TestRenderColumn_focusedDoesNotPanic(t *testing.T) {
|
|
33
33
|
tasks := []data.Task{{ID: "T1", Column: data.ColumnInProgress}}
|
|
34
|
-
got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, true)
|
|
34
|
+
got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, true, "")
|
|
35
35
|
if got == "" {
|
|
36
36
|
t.Error("RenderColumn returned empty string for focused column")
|
|
37
37
|
}
|
|
@@ -47,7 +47,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
|
|
|
47
47
|
{data.ColumnDone, "DONE"},
|
|
48
48
|
}
|
|
49
49
|
for _, tc := range cases {
|
|
50
|
-
got := RenderColumn(nil, tc.col, 30, 0, false)
|
|
50
|
+
got := RenderColumn(nil, tc.col, 30, 0, false, "")
|
|
51
51
|
if !strings.Contains(got, tc.label) {
|
|
52
52
|
t.Errorf("RenderColumn missing label %q for col %q", tc.label, tc.col)
|
|
53
53
|
}
|
|
@@ -56,7 +56,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
|
|
|
56
56
|
|
|
57
57
|
func TestRenderColumn_taskTitleRendered(t *testing.T) {
|
|
58
58
|
tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned}}
|
|
59
|
-
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false)
|
|
59
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false, "")
|
|
60
60
|
if !strings.Contains(got, "Build it") {
|
|
61
61
|
t.Error("RenderColumn missing task title")
|
|
62
62
|
}
|
|
@@ -64,17 +64,25 @@ func TestRenderColumn_taskTitleRendered(t *testing.T) {
|
|
|
64
64
|
|
|
65
65
|
func TestRenderColumn_rendersTaskCards(t *testing.T) {
|
|
66
66
|
tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
|
|
67
|
-
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, true)
|
|
67
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, true, "")
|
|
68
68
|
if !strings.Contains(got, glyphAudit) {
|
|
69
69
|
t.Error("RenderColumn should render task phase glyph from card")
|
|
70
70
|
}
|
|
71
71
|
if !strings.Contains(got, "╭") {
|
|
72
|
-
t.Error("RenderColumn should render
|
|
72
|
+
t.Error("RenderColumn should render focused card border")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestRenderColumn_unfocusedUsesSurfaceWithoutBorder(t *testing.T) {
|
|
77
|
+
tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
|
|
78
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false, "")
|
|
79
|
+
if strings.Contains(got, "╭") {
|
|
80
|
+
t.Error("unfocused column/card should not render rounded borders")
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
func TestRenderColumn_emptyCountIsZero(t *testing.T) {
|
|
77
|
-
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false)
|
|
85
|
+
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false, "")
|
|
78
86
|
if !strings.Contains(got, "(0)") {
|
|
79
87
|
t.Error("RenderColumn missing (0) count for empty column")
|
|
80
88
|
}
|
package/internal/board/detail.go
CHANGED
|
@@ -10,7 +10,8 @@ import (
|
|
|
10
10
|
const detailBorderPad = 4 // rounded border (2) + padding (2×1)
|
|
11
11
|
|
|
12
12
|
// RenderDetail renders a task detail overlay panel at the given display width.
|
|
13
|
-
|
|
13
|
+
// When routerTaskID matches t.ID, a "(router priority)" label is shown.
|
|
14
|
+
func RenderDetail(t data.Task, overlayW int, routerTaskID string) string {
|
|
14
15
|
inner := overlayW - detailBorderPad
|
|
15
16
|
if inner < 4 {
|
|
16
17
|
inner = 4
|
|
@@ -34,29 +35,38 @@ func RenderDetail(t data.Task, overlayW int) string {
|
|
|
34
35
|
"",
|
|
35
36
|
styles.ColumnTitle.Render("Description:"),
|
|
36
37
|
)
|
|
37
|
-
for _, line := range
|
|
38
|
+
for _, line := range WrapText(t.Description, inner) {
|
|
38
39
|
lines = append(lines, styles.CardMeta.Render(line))
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
if len(t.Acceptance) > 0 {
|
|
43
|
-
lines = append(lines, "", styles.ColumnTitle.Render("Acceptance Criteria:"))
|
|
44
|
+
lines = append(lines, "", styles.ColumnTitle.Render("Acceptance Criteria:"), "")
|
|
44
45
|
for _, a := range t.Acceptance {
|
|
45
|
-
for _, line := range
|
|
46
|
+
for _, line := range WrapText(a, inner-2) {
|
|
46
47
|
lines = append(lines, styles.CardMeta.Render(" • "+line))
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
if len(t.Checklist) > 0 {
|
|
52
|
-
lines = append(lines, "", styles.ColumnTitle.Render("Implementation Plan:"))
|
|
53
|
+
lines = append(lines, "", styles.ColumnTitle.Render("Implementation Plan:"), "")
|
|
53
54
|
for _, item := range t.Checklist {
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
glyph := "□ "
|
|
56
|
+
style := styles.CardMeta
|
|
57
|
+
if item.Done {
|
|
58
|
+
glyph = "☑ "
|
|
59
|
+
style = styles.TagDone
|
|
60
|
+
}
|
|
61
|
+
for _, line := range WrapText(item.Text, inner-2) {
|
|
62
|
+
lines = append(lines, style.Render(" "+glyph+line))
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
if routerTaskID != "" && t.ID == routerTaskID {
|
|
68
|
+
lines = append(lines, "", styles.TagDone.Render("(router priority)"))
|
|
69
|
+
}
|
|
60
70
|
lines = append(lines, "", styles.CardMeta.Render("esc:close"))
|
|
61
71
|
|
|
62
72
|
return styles.DetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
|
|
@@ -64,7 +74,7 @@ func RenderDetail(t data.Task, overlayW int) string {
|
|
|
64
74
|
|
|
65
75
|
func detailRow(label, value string, width int) string {
|
|
66
76
|
prefix := label + ": "
|
|
67
|
-
wrapped :=
|
|
77
|
+
wrapped := WrapText(value, width-len(prefix))
|
|
68
78
|
if len(wrapped) == 0 {
|
|
69
79
|
wrapped = []string{""}
|
|
70
80
|
}
|
|
@@ -90,7 +100,7 @@ func phaseLabel(s data.ProgressStage) string {
|
|
|
90
100
|
}
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
func
|
|
103
|
+
func WrapText(s string, width int) []string {
|
|
94
104
|
if width < 4 {
|
|
95
105
|
width = 4
|
|
96
106
|
}
|
|
@@ -106,7 +116,7 @@ func wrapText(s string, width int) []string {
|
|
|
106
116
|
lines = append(lines, current)
|
|
107
117
|
current = ""
|
|
108
118
|
}
|
|
109
|
-
lines = append(lines,
|
|
119
|
+
lines = append(lines, SplitLongWord(word, width)...)
|
|
110
120
|
continue
|
|
111
121
|
}
|
|
112
122
|
if current == "" {
|
|
@@ -126,7 +136,7 @@ func wrapText(s string, width int) []string {
|
|
|
126
136
|
return lines
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
func
|
|
139
|
+
func SplitLongWord(word string, width int) []string {
|
|
130
140
|
runes := []rune(word)
|
|
131
141
|
lines := []string{}
|
|
132
142
|
for len(runes) > width {
|
|
@@ -20,49 +20,49 @@ func sampleTask() data.Task {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
func TestRenderDetail_containsID(t *testing.T) {
|
|
23
|
-
got := RenderDetail(sampleTask(), 60)
|
|
23
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
24
24
|
if !strings.Contains(got, "E04/T001") {
|
|
25
25
|
t.Error("RenderDetail missing task ID")
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
func TestRenderDetail_containsTitle(t *testing.T) {
|
|
30
|
-
got := RenderDetail(sampleTask(), 60)
|
|
30
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
31
31
|
if !strings.Contains(got, "My Task") {
|
|
32
32
|
t.Error("RenderDetail missing task title")
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
func TestRenderDetail_containsEpic(t *testing.T) {
|
|
37
|
-
got := RenderDetail(sampleTask(), 60)
|
|
37
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
38
38
|
if !strings.Contains(got, "E04-board-components") {
|
|
39
39
|
t.Error("RenderDetail missing epic")
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
func TestRenderDetail_containsRelease(t *testing.T) {
|
|
44
|
-
got := RenderDetail(sampleTask(), 60)
|
|
44
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
45
45
|
if !strings.Contains(got, "v1") {
|
|
46
46
|
t.Error("RenderDetail missing release")
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
func TestRenderDetail_containsStatus(t *testing.T) {
|
|
51
|
-
got := RenderDetail(sampleTask(), 60)
|
|
51
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
52
52
|
if !strings.Contains(got, "in_progress") {
|
|
53
53
|
t.Error("RenderDetail missing status")
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
func TestRenderDetail_containsPhase(t *testing.T) {
|
|
58
|
-
got := RenderDetail(sampleTask(), 60)
|
|
58
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
59
59
|
if !strings.Contains(got, "build") {
|
|
60
60
|
t.Error("RenderDetail missing phase")
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
func TestRenderDetail_containsEscHint(t *testing.T) {
|
|
65
|
-
got := RenderDetail(sampleTask(), 60)
|
|
65
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
66
66
|
if !strings.Contains(got, "esc") {
|
|
67
67
|
t.Error("RenderDetail missing esc:close hint")
|
|
68
68
|
}
|
|
@@ -71,14 +71,14 @@ func TestRenderDetail_containsEscHint(t *testing.T) {
|
|
|
71
71
|
func TestRenderDetail_containsDescription(t *testing.T) {
|
|
72
72
|
tk := sampleTask()
|
|
73
73
|
tk.Description = "some description text"
|
|
74
|
-
got := RenderDetail(tk, 60)
|
|
74
|
+
got := RenderDetail(tk, 60, "")
|
|
75
75
|
if !strings.Contains(got, "some description text") {
|
|
76
76
|
t.Error("RenderDetail missing description text")
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
|
|
81
|
-
got := RenderDetail(sampleTask(), 60)
|
|
81
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
82
82
|
if strings.Contains(got, "Description:") {
|
|
83
83
|
t.Error("RenderDetail should not show Description section when empty")
|
|
84
84
|
}
|
|
@@ -87,7 +87,7 @@ func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
|
|
|
87
87
|
func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
|
|
88
88
|
tk := sampleTask()
|
|
89
89
|
tk.Acceptance = []string{"criterion one", "criterion two"}
|
|
90
|
-
got := RenderDetail(tk, 60)
|
|
90
|
+
got := RenderDetail(tk, 60, "")
|
|
91
91
|
if !strings.Contains(got, "criterion one") {
|
|
92
92
|
t.Error("RenderDetail missing first acceptance criterion")
|
|
93
93
|
}
|
|
@@ -98,8 +98,8 @@ func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
|
|
|
98
98
|
|
|
99
99
|
func TestRenderDetail_containsChecklist(t *testing.T) {
|
|
100
100
|
tk := sampleTask()
|
|
101
|
-
tk.Checklist = []
|
|
102
|
-
got := RenderDetail(tk, 60)
|
|
101
|
+
tk.Checklist = []data.CheckItem{{Text: "first implementation item"}, {Text: "second implementation item", Done: true}}
|
|
102
|
+
got := RenderDetail(tk, 60, "")
|
|
103
103
|
if !strings.Contains(got, "Implementation Plan:") {
|
|
104
104
|
t.Error("RenderDetail missing implementation plan heading")
|
|
105
105
|
}
|
|
@@ -114,7 +114,7 @@ func TestRenderDetail_containsChecklist(t *testing.T) {
|
|
|
114
114
|
func TestRenderDetail_wrapsLongDescription(t *testing.T) {
|
|
115
115
|
tk := sampleTask()
|
|
116
116
|
tk.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda"
|
|
117
|
-
got := RenderDetail(tk, 30)
|
|
117
|
+
got := RenderDetail(tk, 30, "")
|
|
118
118
|
if strings.Contains(got, tk.Description) {
|
|
119
119
|
t.Error("RenderDetail should wrap long description text")
|
|
120
120
|
}
|
|
@@ -124,7 +124,7 @@ func TestRenderDetail_wrapsLongDescription(t *testing.T) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
func TestRenderDetail_noAcceptanceSectionWhenEmpty(t *testing.T) {
|
|
127
|
-
got := RenderDetail(sampleTask(), 60)
|
|
127
|
+
got := RenderDetail(sampleTask(), 60, "")
|
|
128
128
|
if strings.Contains(got, "Acceptance Criteria:") {
|
|
129
129
|
t.Error("RenderDetail should not show Acceptance section when empty")
|
|
130
130
|
}
|
|
@@ -214,6 +214,22 @@ func TestView_detailOverlayRendered(t *testing.T) {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
func TestRenderDetail_routerPriorityLabel(t *testing.T) {
|
|
218
|
+
task := sampleTask()
|
|
219
|
+
got := RenderDetail(task, 60, task.ID)
|
|
220
|
+
if !strings.Contains(got, "(router priority)") {
|
|
221
|
+
t.Error("RenderDetail missing router priority label for matching task")
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func TestRenderDetail_noRouterPriorityLabelWhenNoMatch(t *testing.T) {
|
|
226
|
+
task := sampleTask()
|
|
227
|
+
got := RenderDetail(task, 60, "other-id")
|
|
228
|
+
if strings.Contains(got, "(router priority)") {
|
|
229
|
+
t.Error("RenderDetail should not show router priority label for non-matching task")
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
217
233
|
func TestOverlayWidth_clampMax(t *testing.T) {
|
|
218
234
|
if got := overlayWidth(120); got != 80 {
|
|
219
235
|
t.Errorf("overlayWidth(120) = %d, want 80", got)
|
package/internal/board/model.go
CHANGED
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"path/filepath"
|
|
6
6
|
|
|
7
7
|
tea "github.com/charmbracelet/bubbletea"
|
|
8
|
+
"github.com/fsnotify/fsnotify"
|
|
8
9
|
"github.com/opencode/savepoint/internal/data"
|
|
9
10
|
)
|
|
10
11
|
|
|
@@ -36,6 +37,8 @@ type Model struct {
|
|
|
36
37
|
Height int
|
|
37
38
|
StatusMessage string
|
|
38
39
|
Root string
|
|
40
|
+
RouterTask string
|
|
41
|
+
Watcher *fsnotify.Watcher
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
// NewModel groups tasks by column and returns an initialized Model.
|
|
@@ -53,7 +56,10 @@ func NewModel(tasks []data.Task, release, epic string) Model {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
func (m Model) Init() tea.Cmd {
|
|
56
|
-
|
|
59
|
+
if m.Watcher == nil {
|
|
60
|
+
return nil
|
|
61
|
+
}
|
|
62
|
+
return watchFiles(m.Watcher)
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
func groupedTasks(tasks []data.Task) map[data.ColumnType][]data.Task {
|
package/internal/board/update.go
CHANGED
|
@@ -13,12 +13,25 @@ var columnOrder = []data.ColumnType{
|
|
|
13
13
|
|
|
14
14
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
15
15
|
switch msg := msg.(type) {
|
|
16
|
+
case fileChangeMsg:
|
|
17
|
+
if m.Root != "" {
|
|
18
|
+
return m, reloadTasks(m.Root)
|
|
19
|
+
}
|
|
20
|
+
case reloadMsg:
|
|
21
|
+
m.AllTasks = msg.tasks
|
|
22
|
+
m.refreshTasks()
|
|
23
|
+
if m.Watcher != nil {
|
|
24
|
+
return m, watchFiles(m.Watcher)
|
|
25
|
+
}
|
|
16
26
|
case tea.KeyMsg:
|
|
17
27
|
if m.Overlay != OverlayNone {
|
|
18
28
|
return m.updateOverlay(msg)
|
|
19
29
|
}
|
|
20
30
|
switch msg.String() {
|
|
21
31
|
case "q", "ctrl+c":
|
|
32
|
+
if m.Watcher != nil {
|
|
33
|
+
m.Watcher.Close()
|
|
34
|
+
}
|
|
22
35
|
return m, tea.Quit
|
|
23
36
|
case "left", "h":
|
|
24
37
|
m.FocusedColumn = prevColumn(m.FocusedColumn)
|
|
@@ -55,6 +68,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
55
68
|
for i, t := range m.AllTasks {
|
|
56
69
|
if t.ID == task.ID {
|
|
57
70
|
Advance(&m.AllTasks[i])
|
|
71
|
+
if m.AllTasks[i].Path != "" {
|
|
72
|
+
if err := data.WriteTaskStatus(m.AllTasks[i].Path, &m.AllTasks[i], task.Mtime); err != nil && err != data.ErrMtimeConflict {
|
|
73
|
+
m.StatusMessage = err.Error()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
58
76
|
break
|
|
59
77
|
}
|
|
60
78
|
}
|
|
@@ -65,15 +83,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
65
83
|
tasks := m.Tasks[m.FocusedColumn]
|
|
66
84
|
if len(tasks) > 0 && m.FocusedTask < len(tasks) {
|
|
67
85
|
task := tasks[m.FocusedTask]
|
|
86
|
+
m.StatusMessage = ""
|
|
68
87
|
for i, t := range m.AllTasks {
|
|
69
88
|
if t.ID == task.ID {
|
|
70
89
|
Retreat(&m.AllTasks[i])
|
|
90
|
+
if m.AllTasks[i].Path != "" {
|
|
91
|
+
if err := data.WriteTaskStatus(m.AllTasks[i].Path, &m.AllTasks[i], task.Mtime); err != nil && err != data.ErrMtimeConflict {
|
|
92
|
+
m.StatusMessage = err.Error()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
71
95
|
break
|
|
72
96
|
}
|
|
73
97
|
}
|
|
74
98
|
m.refreshTasks()
|
|
75
99
|
}
|
|
76
|
-
m.StatusMessage = ""
|
|
77
100
|
case "e":
|
|
78
101
|
m.Overlay = OverlayEpic
|
|
79
102
|
m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
|
package/internal/board/view.go
CHANGED
|
@@ -18,14 +18,17 @@ func (m Model) View() string {
|
|
|
18
18
|
if w == 0 {
|
|
19
19
|
w = defaultTermW
|
|
20
20
|
}
|
|
21
|
+
m.Width = w
|
|
21
22
|
|
|
22
23
|
layout := CalculateLayout(w, m.Height)
|
|
23
24
|
icon := styles.HeaderIcon.Render("▣")
|
|
24
25
|
text := styles.HeaderText.Render("S A V E P O I N T")
|
|
25
26
|
header := styles.HeaderFrame.Width(w).Render(icon + " " + text)
|
|
27
|
+
topDivider := dividerLine(w)
|
|
26
28
|
board := m.renderBoard(layout)
|
|
29
|
+
bottomDivider := dividerLine(w)
|
|
27
30
|
footer := m.renderFooter(w)
|
|
28
|
-
base := lipgloss.JoinVertical(lipgloss.Left, header, board, footer)
|
|
31
|
+
base := lipgloss.JoinVertical(lipgloss.Left, header, topDivider, board, bottomDivider, footer)
|
|
29
32
|
|
|
30
33
|
h := m.Height
|
|
31
34
|
if h == 0 {
|
|
@@ -53,7 +56,7 @@ func (m Model) View() string {
|
|
|
53
56
|
return base
|
|
54
57
|
}
|
|
55
58
|
ow := overlayWidth(w)
|
|
56
|
-
detail := RenderDetail(task, ow)
|
|
59
|
+
detail := RenderDetail(task, ow, m.RouterTask)
|
|
57
60
|
return overlayOnBase(dimLines(base), detail, w, h)
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -163,7 +166,7 @@ func (m Model) renderEpicPanel(w int) string {
|
|
|
163
166
|
|
|
164
167
|
func (m Model) renderColumn(col data.ColumnType, colW int) string {
|
|
165
168
|
focused := m.FocusedColumn == col
|
|
166
|
-
return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused)
|
|
169
|
+
return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused, m.RouterTask)
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
func (m Model) renderFooter(termW int) string {
|
|
@@ -179,6 +182,13 @@ func (m Model) renderFooter(termW int) string {
|
|
|
179
182
|
return lipgloss.JoinVertical(lipgloss.Center, phase, spacer, hints)
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
func dividerLine(termW int) string {
|
|
186
|
+
if termW <= 0 {
|
|
187
|
+
termW = defaultTermW
|
|
188
|
+
}
|
|
189
|
+
return styles.Divider.Render(strings.Repeat("─", termW))
|
|
190
|
+
}
|
|
191
|
+
|
|
182
192
|
func footerLine(termW int, content string) string {
|
|
183
193
|
if termW <= 0 {
|
|
184
194
|
termW = defaultTermW
|
|
@@ -46,8 +46,8 @@ func TestView_containsDivider(t *testing.T) {
|
|
|
46
46
|
m := NewModel(nil, "v1", "E03")
|
|
47
47
|
m.Width = 120
|
|
48
48
|
got := m.View()
|
|
49
|
-
if !strings.Contains(got, "─") {
|
|
50
|
-
t.Error("View() missing horizontal divider")
|
|
49
|
+
if !strings.Contains(got, strings.Repeat("─", 120)) {
|
|
50
|
+
t.Error("View() missing full-width horizontal divider")
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
9
|
+
"github.com/fsnotify/fsnotify"
|
|
10
|
+
"github.com/opencode/savepoint/internal/data"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type fileChangeMsg struct{}
|
|
14
|
+
type reloadMsg struct{ tasks []data.Task }
|
|
15
|
+
|
|
16
|
+
// watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
|
|
17
|
+
func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
18
|
+
return func() tea.Msg {
|
|
19
|
+
for {
|
|
20
|
+
select {
|
|
21
|
+
case _, ok := <-w.Events:
|
|
22
|
+
if !ok {
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
timer := time.NewTimer(100 * time.Millisecond)
|
|
26
|
+
drain:
|
|
27
|
+
for {
|
|
28
|
+
select {
|
|
29
|
+
case _, ok := <-w.Events:
|
|
30
|
+
if !ok {
|
|
31
|
+
timer.Stop()
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
case <-timer.C:
|
|
35
|
+
break drain
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return fileChangeMsg{}
|
|
39
|
+
case _, ok := <-w.Errors:
|
|
40
|
+
if !ok {
|
|
41
|
+
return nil
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func reloadTasks(root string) tea.Cmd {
|
|
49
|
+
return func() tea.Msg {
|
|
50
|
+
tasks, err := loadAllTasks(root)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
return reloadMsg{tasks: tasks}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// newWatcher watches the releases directory by walking all subdirs (fsnotify v1.10 has no recursive opt).
|
|
59
|
+
func newWatcher(root string) (*fsnotify.Watcher, error) {
|
|
60
|
+
w, err := fsnotify.NewWatcher()
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, err
|
|
63
|
+
}
|
|
64
|
+
releasesPath := filepath.Join(root, ".savepoint", "releases")
|
|
65
|
+
if err := addDirsRecursive(w, releasesPath); err != nil {
|
|
66
|
+
w.Close()
|
|
67
|
+
return nil, err
|
|
68
|
+
}
|
|
69
|
+
return w, nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func addDirsRecursive(w *fsnotify.Watcher, root string) error {
|
|
73
|
+
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
74
|
+
if err != nil {
|
|
75
|
+
return nil // skip unreadable dirs
|
|
76
|
+
}
|
|
77
|
+
if d.IsDir() {
|
|
78
|
+
return w.Add(path)
|
|
79
|
+
}
|
|
80
|
+
return nil
|
|
81
|
+
})
|
|
82
|
+
}
|