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
package/internal/board/view.go
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
4
5
|
"strings"
|
|
5
6
|
|
|
6
7
|
"github.com/charmbracelet/lipgloss"
|
|
@@ -18,19 +19,26 @@ func (m Model) View() string {
|
|
|
18
19
|
if w == 0 {
|
|
19
20
|
w = defaultTermW
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
layout := CalculateLayout(w, m.Height)
|
|
23
|
-
icon := styles.HeaderIcon.Render("▣")
|
|
24
|
-
text := styles.HeaderText.Render("S A V E P O I N T")
|
|
25
|
-
header := styles.HeaderFrame.Width(w).Render(icon + " " + text)
|
|
26
|
-
board := m.renderBoard(layout)
|
|
27
|
-
footer := m.renderFooter(w)
|
|
28
|
-
base := lipgloss.JoinVertical(lipgloss.Left, header, board, footer)
|
|
29
|
-
|
|
22
|
+
m.Width = w
|
|
30
23
|
h := m.Height
|
|
31
24
|
if h == 0 {
|
|
32
25
|
h = defaultTermH
|
|
33
26
|
}
|
|
27
|
+
m.Height = h
|
|
28
|
+
|
|
29
|
+
header := m.renderHeader(w)
|
|
30
|
+
nextActivity := m.renderNextActivityLine(w)
|
|
31
|
+
layout := CalculateLayoutWithChrome(w, h, extraHeaderLines(nextActivity))
|
|
32
|
+
topDivider := dividerLine(w)
|
|
33
|
+
board := m.renderBoard(layout)
|
|
34
|
+
bottomDivider := dividerLine(w)
|
|
35
|
+
footer := m.renderFooter(w)
|
|
36
|
+
sections := []string{header}
|
|
37
|
+
if nextActivity != "" {
|
|
38
|
+
sections = append(sections, nextActivity)
|
|
39
|
+
}
|
|
40
|
+
sections = append(sections, topDivider, board, bottomDivider, footer)
|
|
41
|
+
base := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
34
42
|
|
|
35
43
|
if m.Overlay == OverlayEpic {
|
|
36
44
|
overlay := RenderEpicDropdown(m.Epics, m.EpicCursor, min(40, w))
|
|
@@ -53,13 +61,111 @@ func (m Model) View() string {
|
|
|
53
61
|
return base
|
|
54
62
|
}
|
|
55
63
|
ow := overlayWidth(w)
|
|
56
|
-
detail := RenderDetail(task, ow)
|
|
64
|
+
detail := RenderDetail(task, ow, m.RouterState, detailMaxHeight(h), m.DetailOffset)
|
|
65
|
+
return overlayOnBase(dimLines(base), detail, w, h)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if m.Overlay == OverlayEpicDetail {
|
|
69
|
+
ow := overlayWidth(w)
|
|
70
|
+
epicSlug := ""
|
|
71
|
+
if m.EpicPanelCursor >= 0 && m.EpicPanelCursor < len(m.Epics) {
|
|
72
|
+
epicSlug = m.Epics[m.EpicPanelCursor]
|
|
73
|
+
}
|
|
74
|
+
detail := RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset)
|
|
57
75
|
return overlayOnBase(dimLines(base), detail, w, h)
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
return base
|
|
61
79
|
}
|
|
62
80
|
|
|
81
|
+
func (m Model) renderHeader(w int) string {
|
|
82
|
+
icon := styles.HeaderIcon.Render("▣")
|
|
83
|
+
text := styles.HeaderText.Render("S A V E P O I N T")
|
|
84
|
+
left := icon + " " + text
|
|
85
|
+
return styles.HeaderFrame.Width(w).Render(left)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func extraHeaderLines(line string) int {
|
|
89
|
+
if line == "" {
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
return 1
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (m Model) renderNextActivityLine(w int) string {
|
|
96
|
+
if w <= 0 {
|
|
97
|
+
w = defaultTermW
|
|
98
|
+
}
|
|
99
|
+
return renderNextActivityLine(m.RouterState, w)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func renderNextActivityLine(state *data.RouterState, w int) string {
|
|
103
|
+
tag, style, ok := nextActivityPhase(state)
|
|
104
|
+
if !ok || strings.TrimSpace(state.NextAction) == "" {
|
|
105
|
+
return ""
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
content := style.Render(tag+":") + " " + state.NextAction
|
|
109
|
+
if lipgloss.Width(content) > w {
|
|
110
|
+
content = xansi.Truncate(content, w, "…")
|
|
111
|
+
}
|
|
112
|
+
return styles.RootLine.Width(w).Render(content)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func nextActivityPhase(state *data.RouterState) (string, lipgloss.Style, bool) {
|
|
116
|
+
if state == nil {
|
|
117
|
+
return "", lipgloss.Style{}, false
|
|
118
|
+
}
|
|
119
|
+
switch state.State {
|
|
120
|
+
case "task-building":
|
|
121
|
+
return "BUILD", styles.FooterPhaseBuild, true
|
|
122
|
+
case "audit-pending":
|
|
123
|
+
return "AUDIT", styles.FooterPhaseAudit, true
|
|
124
|
+
case "pre-implementation", "epic-design", "epic-task-breakdown":
|
|
125
|
+
return "PLAN", styles.FooterPhasePlan, true
|
|
126
|
+
default:
|
|
127
|
+
return "", lipgloss.Style{}, false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// FormatNextActivity formats a compact activity string from router state.
|
|
132
|
+
// Returns empty string when state is nil. Result is capped at 20 visible chars.
|
|
133
|
+
func FormatNextActivity(state *data.RouterState) string {
|
|
134
|
+
if state == nil {
|
|
135
|
+
return ""
|
|
136
|
+
}
|
|
137
|
+
var s string
|
|
138
|
+
switch state.State {
|
|
139
|
+
case "task-building":
|
|
140
|
+
s = fmt.Sprintf("Build %s %s/%s", state.Release, shortRouterID(state.Epic), shortRouterID(state.Task))
|
|
141
|
+
case "audit-pending":
|
|
142
|
+
s = fmt.Sprintf("Audit %s", shortRouterID(state.Epic))
|
|
143
|
+
case "epic-design":
|
|
144
|
+
s = fmt.Sprintf("Design %s", shortRouterID(state.Epic))
|
|
145
|
+
case "epic-task-breakdown":
|
|
146
|
+
s = fmt.Sprintf("Plan %s", shortRouterID(state.Epic))
|
|
147
|
+
case "pre-implementation":
|
|
148
|
+
s = fmt.Sprintf("Planning %s", state.Release)
|
|
149
|
+
default:
|
|
150
|
+
s = state.State
|
|
151
|
+
}
|
|
152
|
+
return xansi.Truncate(s, 20, "…")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// shortRouterID extracts the compact prefix from a full router slug.
|
|
156
|
+
// "E01-tui-optimisation/T001-border-resize-fix" → "T001"
|
|
157
|
+
// "E01-tui-optimisation" → "E01"
|
|
158
|
+
func shortRouterID(full string) string {
|
|
159
|
+
part := full
|
|
160
|
+
if i := strings.LastIndex(full, "/"); i >= 0 {
|
|
161
|
+
part = full[i+1:]
|
|
162
|
+
}
|
|
163
|
+
if i := strings.Index(part, "-"); i >= 0 {
|
|
164
|
+
return part[:i]
|
|
165
|
+
}
|
|
166
|
+
return part
|
|
167
|
+
}
|
|
168
|
+
|
|
63
169
|
func (m Model) focusedTask() (data.Task, bool) {
|
|
64
170
|
tasks := m.Tasks[m.FocusedColumn]
|
|
65
171
|
if len(tasks) == 0 || m.FocusedTask >= len(tasks) {
|
|
@@ -147,23 +253,34 @@ func (m Model) renderBoard(layout Layout) string {
|
|
|
147
253
|
|
|
148
254
|
func (m Model) renderColumns(layout Layout) string {
|
|
149
255
|
if layout.ColCount == 1 {
|
|
150
|
-
return m.renderColumn(m.FocusedColumn, layout.ColWidths[0])
|
|
256
|
+
return m.renderColumn(m.FocusedColumn, layout.ColWidths[0], layout.ContentHeight)
|
|
151
257
|
}
|
|
152
258
|
allCols := []data.ColumnType{data.ColumnPlanned, data.ColumnInProgress, data.ColumnDone}
|
|
153
259
|
rendered := make([]string, len(allCols))
|
|
154
260
|
for i, col := range allCols {
|
|
155
|
-
rendered[i] = m.renderColumn(col, layout.ColWidths[i])
|
|
261
|
+
rendered[i] = m.renderColumn(col, layout.ColWidths[i], layout.ContentHeight)
|
|
156
262
|
}
|
|
157
263
|
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
|
158
264
|
}
|
|
159
265
|
|
|
160
266
|
func (m Model) renderEpicPanel(w int) string {
|
|
161
|
-
return RenderEpicSidebar(m.Epics, m.SelectedEpic, w)
|
|
267
|
+
return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
|
|
162
268
|
}
|
|
163
269
|
|
|
164
|
-
func (m Model) renderColumn(col data.ColumnType, colW int) string {
|
|
165
|
-
focused := m.FocusedColumn == col
|
|
166
|
-
return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused)
|
|
270
|
+
func (m Model) renderColumn(col data.ColumnType, colW, maxHeight int) string {
|
|
271
|
+
focused := !m.EpicPanelFocus && m.FocusedColumn == col
|
|
272
|
+
return RenderColumn(m.Tasks[col], col, colW, maxHeight, m.ColumnOffsets[col], m.FocusedTask, focused, m.RouterState)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func detailMaxHeight(termH int) int {
|
|
276
|
+
if termH <= 0 {
|
|
277
|
+
termH = defaultTermH
|
|
278
|
+
}
|
|
279
|
+
h := termH * 7 / 10
|
|
280
|
+
if h < 6 {
|
|
281
|
+
h = 6
|
|
282
|
+
}
|
|
283
|
+
return h
|
|
167
284
|
}
|
|
168
285
|
|
|
169
286
|
func (m Model) renderFooter(termW int) string {
|
|
@@ -179,6 +296,13 @@ func (m Model) renderFooter(termW int) string {
|
|
|
179
296
|
return lipgloss.JoinVertical(lipgloss.Center, phase, spacer, hints)
|
|
180
297
|
}
|
|
181
298
|
|
|
299
|
+
func dividerLine(termW int) string {
|
|
300
|
+
if termW <= 0 {
|
|
301
|
+
termW = defaultTermW
|
|
302
|
+
}
|
|
303
|
+
return styles.Divider.Render(strings.Repeat("─", termW))
|
|
304
|
+
}
|
|
305
|
+
|
|
182
306
|
func footerLine(termW int, content string) string {
|
|
183
307
|
if termW <= 0 {
|
|
184
308
|
termW = defaultTermW
|
|
@@ -186,5 +310,5 @@ func footerLine(termW int, content string) string {
|
|
|
186
310
|
if lipgloss.Width(content) > termW {
|
|
187
311
|
content = xansi.Truncate(content, termW, "")
|
|
188
312
|
}
|
|
189
|
-
return
|
|
313
|
+
return styles.RootLine.Width(termW).Align(lipgloss.Center).Render(content)
|
|
190
314
|
}
|
|
@@ -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
|
|
|
@@ -74,7 +74,7 @@ func TestView_containsFooterHints(t *testing.T) {
|
|
|
74
74
|
if len(lines) != 3 {
|
|
75
75
|
t.Fatalf("renderFooter() returned %d lines, want 3", len(lines))
|
|
76
76
|
}
|
|
77
|
-
if strings.TrimSpace(lines[1]) != "" {
|
|
77
|
+
if strings.TrimSpace(plainTerminal(lines[1])) != "" {
|
|
78
78
|
t.Fatalf("renderFooter() spacer line = %q, want blank", lines[1])
|
|
79
79
|
}
|
|
80
80
|
for i, line := range lines {
|
|
@@ -133,6 +133,164 @@ func TestView_wideShowsEpicPanel(t *testing.T) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
func TestFormatNextActivity_nil(t *testing.T) {
|
|
137
|
+
if got := FormatNextActivity(nil); got != "" {
|
|
138
|
+
t.Errorf("FormatNextActivity(nil) = %q, want empty", got)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func TestFormatNextActivity_states(t *testing.T) {
|
|
143
|
+
cases := []struct {
|
|
144
|
+
state *data.RouterState
|
|
145
|
+
want string
|
|
146
|
+
}{
|
|
147
|
+
{&data.RouterState{State: "task-building", Task: "T010", Epic: "E06", Release: "v1"}, "Build v1 E06/T010"},
|
|
148
|
+
{&data.RouterState{State: "audit-pending", Epic: "E06"}, "Audit E06"},
|
|
149
|
+
{&data.RouterState{State: "epic-design", Epic: "E06"}, "Design E06"},
|
|
150
|
+
{&data.RouterState{State: "epic-task-breakdown", Epic: "E06"}, "Plan E06"},
|
|
151
|
+
{&data.RouterState{State: "pre-implementation", Release: "v1"}, "Planning v1"},
|
|
152
|
+
}
|
|
153
|
+
for _, c := range cases {
|
|
154
|
+
got := FormatNextActivity(c.state)
|
|
155
|
+
if got != c.want {
|
|
156
|
+
t.Errorf("FormatNextActivity(%q) = %q, want %q", c.state.State, got, c.want)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestFormatNextActivity_truncation(t *testing.T) {
|
|
162
|
+
state := &data.RouterState{State: "task-building", Task: "T001", Epic: "E01-very-long-epic-name", Release: "v1.1"}
|
|
163
|
+
got := FormatNextActivity(state)
|
|
164
|
+
if lipgloss.Width(got) > 20 {
|
|
165
|
+
t.Errorf("FormatNextActivity truncation: width %d > 20, got %q", lipgloss.Width(got), got)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func TestFormatNextActivity_taskBuildingKeepsMinorReleaseVisible(t *testing.T) {
|
|
170
|
+
state := &data.RouterState{State: "task-building", Task: "T001", Epic: "E03", Release: "v1.1"}
|
|
171
|
+
got := FormatNextActivity(state)
|
|
172
|
+
if got != "Build v1.1 E03/T001" {
|
|
173
|
+
t.Errorf("FormatNextActivity() = %q, want Build v1.1 E03/T001", got)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func TestView_headerShowsNextActivity(t *testing.T) {
|
|
178
|
+
m := NewModel(nil, "v1", "E03")
|
|
179
|
+
m.Width = 120
|
|
180
|
+
m.RouterState = &data.RouterState{State: "audit-pending", NextAction: "Audit E06"}
|
|
181
|
+
got := m.View()
|
|
182
|
+
if !strings.Contains(got, "AUDIT:") {
|
|
183
|
+
t.Error("View() missing Next Activity phase tag")
|
|
184
|
+
}
|
|
185
|
+
if !strings.Contains(got, "Audit E06") {
|
|
186
|
+
t.Error("View() missing activity text below header")
|
|
187
|
+
}
|
|
188
|
+
if strings.Contains(got, "Next Activity:") {
|
|
189
|
+
t.Error("View() should not render Next Activity inside the header")
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func TestView_nextActivityLineImmediatelyBelowHeader(t *testing.T) {
|
|
194
|
+
m := NewModel(nil, "v1", "E03")
|
|
195
|
+
m.Width = 120
|
|
196
|
+
m.RouterState = &data.RouterState{State: "task-building", NextAction: "Build T010 (E06) v1"}
|
|
197
|
+
got := m.View()
|
|
198
|
+
|
|
199
|
+
lines := strings.Split(got, "\n")
|
|
200
|
+
headerIndex := -1
|
|
201
|
+
activityIndex := -1
|
|
202
|
+
dividerIndex := -1
|
|
203
|
+
for i, line := range lines {
|
|
204
|
+
if strings.Contains(line, "S A V E P O I N T") {
|
|
205
|
+
headerIndex = i
|
|
206
|
+
}
|
|
207
|
+
if strings.Contains(line, "BUILD:") && strings.Contains(line, "Build T010 (E06) v1") {
|
|
208
|
+
activityIndex = i
|
|
209
|
+
}
|
|
210
|
+
if dividerIndex == -1 && strings.Contains(line, strings.Repeat("─", 120)) {
|
|
211
|
+
dividerIndex = i
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if headerIndex == -1 || activityIndex == -1 || dividerIndex == -1 {
|
|
215
|
+
t.Fatalf("View() missing expected header/activity/divider lines: header=%d activity=%d divider=%d", headerIndex, activityIndex, dividerIndex)
|
|
216
|
+
}
|
|
217
|
+
if !(headerIndex < activityIndex && activityIndex < dividerIndex) {
|
|
218
|
+
t.Fatalf("Next Activity line order invalid: header=%d activity=%d divider=%d", headerIndex, activityIndex, dividerIndex)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func TestView_headerNoActivityWhenNilState(t *testing.T) {
|
|
223
|
+
m := NewModel(nil, "v1", "E03")
|
|
224
|
+
m.Width = 120
|
|
225
|
+
m.RouterState = nil
|
|
226
|
+
got := m.View()
|
|
227
|
+
if strings.Contains(got, "Next Activity:") || strings.Contains(got, "BUILD:") || strings.Contains(got, "PLAN:") || strings.Contains(got, "AUDIT:") {
|
|
228
|
+
t.Error("View() should not show Next Activity line when RouterState is nil")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func TestView_headerNarrowWidth(t *testing.T) {
|
|
233
|
+
m := NewModel(nil, "v1", "E03")
|
|
234
|
+
m.Width = 40
|
|
235
|
+
m.RouterState = &data.RouterState{State: "audit-pending", NextAction: "Audit E06"}
|
|
236
|
+
got := m.View()
|
|
237
|
+
// Should not panic and header text should still be present
|
|
238
|
+
if !strings.Contains(got, "S A V E P O I N T") {
|
|
239
|
+
t.Error("View() at narrow width missing header text")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func TestRenderNextActivityLine_phaseMapping(t *testing.T) {
|
|
244
|
+
cases := []struct {
|
|
245
|
+
name string
|
|
246
|
+
state *data.RouterState
|
|
247
|
+
tag string
|
|
248
|
+
}{
|
|
249
|
+
{"build", &data.RouterState{State: "task-building", NextAction: "Build T010 (E06) v1"}, "BUILD:"},
|
|
250
|
+
{"audit", &data.RouterState{State: "audit-pending", NextAction: "Audit E03"}, "AUDIT:"},
|
|
251
|
+
{"pre implementation", &data.RouterState{State: "pre-implementation", NextAction: "Plan v1.1"}, "PLAN:"},
|
|
252
|
+
{"epic design", &data.RouterState{State: "epic-design", NextAction: "Design E03"}, "PLAN:"},
|
|
253
|
+
{"task breakdown", &data.RouterState{State: "epic-task-breakdown", NextAction: "Break down E03"}, "PLAN:"},
|
|
254
|
+
}
|
|
255
|
+
for _, tc := range cases {
|
|
256
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
257
|
+
got := renderNextActivityLine(tc.state, 80)
|
|
258
|
+
if !strings.Contains(got, tc.tag) {
|
|
259
|
+
t.Fatalf("renderNextActivityLine() missing phase tag %q in %q", tc.tag, got)
|
|
260
|
+
}
|
|
261
|
+
if !strings.Contains(got, tc.state.NextAction) {
|
|
262
|
+
t.Fatalf("renderNextActivityLine() missing next_action %q in %q", tc.state.NextAction, got)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func TestRenderNextActivityLine_hiddenStates(t *testing.T) {
|
|
269
|
+
cases := []*data.RouterState{
|
|
270
|
+
nil,
|
|
271
|
+
{State: "idle", NextAction: "Wait"},
|
|
272
|
+
{State: "task-building", NextAction: ""},
|
|
273
|
+
}
|
|
274
|
+
for _, state := range cases {
|
|
275
|
+
if got := renderNextActivityLine(state, 80); got != "" {
|
|
276
|
+
t.Fatalf("renderNextActivityLine(%v) = %q, want empty", state, got)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func TestRenderNextActivityLine_truncatesAtNarrowWidth(t *testing.T) {
|
|
282
|
+
got := renderNextActivityLine(&data.RouterState{
|
|
283
|
+
State: "pre-implementation",
|
|
284
|
+
NextAction: "Build T010 (E06) v1 with a very long follow-up activity",
|
|
285
|
+
}, 18)
|
|
286
|
+
if lipgloss.Width(got) > 18 {
|
|
287
|
+
t.Fatalf("renderNextActivityLine() width = %d, want <= 18; got %q", lipgloss.Width(got), got)
|
|
288
|
+
}
|
|
289
|
+
if !strings.Contains(got, "PLAN:") || !strings.Contains(got, "…") {
|
|
290
|
+
t.Fatalf("renderNextActivityLine() = %q, want PLAN tag and ellipsis", got)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
136
294
|
func TestView_narrowShowsSingleColumn(t *testing.T) {
|
|
137
295
|
m := NewModel(nil, "v1", "E03")
|
|
138
296
|
m.Width = 60
|
|
@@ -0,0 +1,100 @@
|
|
|
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 {
|
|
15
|
+
tasks []data.Task
|
|
16
|
+
releases []string
|
|
17
|
+
releaseEpics map[string][]string
|
|
18
|
+
epicStatuses map[string]string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
|
|
22
|
+
func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
23
|
+
return func() tea.Msg {
|
|
24
|
+
for {
|
|
25
|
+
select {
|
|
26
|
+
case event, ok := <-w.Events:
|
|
27
|
+
if !ok {
|
|
28
|
+
return nil
|
|
29
|
+
}
|
|
30
|
+
watchCreatedDir(w, event)
|
|
31
|
+
timer := time.NewTimer(100 * time.Millisecond)
|
|
32
|
+
drain:
|
|
33
|
+
for {
|
|
34
|
+
select {
|
|
35
|
+
case event, ok := <-w.Events:
|
|
36
|
+
if !ok {
|
|
37
|
+
timer.Stop()
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
watchCreatedDir(w, event)
|
|
41
|
+
case <-timer.C:
|
|
42
|
+
break drain
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return fileChangeMsg{}
|
|
46
|
+
case _, ok := <-w.Errors:
|
|
47
|
+
if !ok {
|
|
48
|
+
return nil
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func reloadTasks(root string) tea.Cmd {
|
|
56
|
+
return func() tea.Msg {
|
|
57
|
+
tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// newWatcher watches the releases directory by walking all subdirs (fsnotify v1.10 has no recursive opt).
|
|
66
|
+
func newWatcher(root string) (*fsnotify.Watcher, error) {
|
|
67
|
+
w, err := fsnotify.NewWatcher()
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
releasesPath := filepath.Join(root, "releases")
|
|
72
|
+
if err := addDirsRecursive(w, releasesPath); err != nil {
|
|
73
|
+
w.Close()
|
|
74
|
+
return nil, err
|
|
75
|
+
}
|
|
76
|
+
return w, nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func watchCreatedDir(w *fsnotify.Watcher, event fsnotify.Event) {
|
|
80
|
+
if !event.Has(fsnotify.Create) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
info, err := os.Stat(event.Name)
|
|
84
|
+
if err != nil || !info.IsDir() {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
_ = addDirsRecursive(w, event.Name)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func addDirsRecursive(w *fsnotify.Watcher, root string) error {
|
|
91
|
+
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
92
|
+
if err != nil {
|
|
93
|
+
return nil // skip unreadable dirs
|
|
94
|
+
}
|
|
95
|
+
if d.IsDir() {
|
|
96
|
+
return w.Add(path)
|
|
97
|
+
}
|
|
98
|
+
return nil
|
|
99
|
+
})
|
|
100
|
+
}
|