savepoint 1.0.1 → 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 +4 -1
- package/.savepoint/Design.md +22 -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/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/proposals.md +2 -2
- 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/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/{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-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/AGENTS.md +56 -113
- package/Makefile +19 -3
- package/README.md +6 -5
- 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/internal/board/board.go +43 -27
- package/internal/board/board_test.go +71 -0
- package/internal/board/card.go +34 -3
- package/internal/board/card_test.go +105 -12
- package/internal/board/column.go +40 -5
- package/internal/board/column_test.go +60 -13
- package/internal/board/detail.go +107 -25
- package/internal/board/detail_test.go +117 -26
- 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 +141 -24
- package/internal/board/render_policy_test.go +77 -0
- package/internal/board/status.go +23 -0
- package/internal/board/update.go +276 -8
- package/internal/board/update_test.go +166 -0
- package/internal/board/view.go +131 -17
- package/internal/board/view_test.go +159 -1
- package/internal/board/watch.go +24 -6
- package/internal/buildtool/main.go +219 -0
- package/internal/data/parser.go +8 -0
- package/internal/data/parser_test.go +35 -0
- package/internal/data/task.go +10 -0
- package/internal/styles/palette.go +3 -3
- package/internal/styles/styles.go +39 -12
- package/main.go +9 -0
- package/package.json +1 -1
- 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.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
- 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-atari-noir-layout → v1/E06-atari-noir-layout}/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,9 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
4
7
|
"testing"
|
|
5
8
|
|
|
6
9
|
tea "github.com/charmbracelet/bubbletea"
|
|
@@ -90,6 +93,54 @@ func TestUpdate_downMovesTaskFocus(t *testing.T) {
|
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
func TestUpdate_downAutoScrollsFocusedTaskIntoViewport(t *testing.T) {
|
|
97
|
+
tasks := []data.Task{
|
|
98
|
+
{ID: "T1", Column: data.ColumnPlanned},
|
|
99
|
+
{ID: "T2", Column: data.ColumnPlanned},
|
|
100
|
+
{ID: "T3", Column: data.ColumnPlanned},
|
|
101
|
+
{ID: "T4", Column: data.ColumnPlanned},
|
|
102
|
+
{ID: "T5", Column: data.ColumnPlanned},
|
|
103
|
+
}
|
|
104
|
+
m := NewModel(tasks, "v1", "E03")
|
|
105
|
+
m.Width = 100
|
|
106
|
+
m.Height = 24
|
|
107
|
+
m.FocusedTask = 3
|
|
108
|
+
|
|
109
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
110
|
+
updated := requireModel(t, got)
|
|
111
|
+
|
|
112
|
+
if updated.FocusedTask != 4 {
|
|
113
|
+
t.Errorf("FocusedTask = %d, want 4", updated.FocusedTask)
|
|
114
|
+
}
|
|
115
|
+
if updated.ColumnOffsets[data.ColumnPlanned] != 1 {
|
|
116
|
+
t.Errorf("ColumnOffsets[planned] = %d, want 1", updated.ColumnOffsets[data.ColumnPlanned])
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func TestUpdate_pageDownScrollsFocusedColumnByPage(t *testing.T) {
|
|
121
|
+
tasks := []data.Task{
|
|
122
|
+
{ID: "T1", Column: data.ColumnPlanned},
|
|
123
|
+
{ID: "T2", Column: data.ColumnPlanned},
|
|
124
|
+
{ID: "T3", Column: data.ColumnPlanned},
|
|
125
|
+
{ID: "T4", Column: data.ColumnPlanned},
|
|
126
|
+
{ID: "T5", Column: data.ColumnPlanned},
|
|
127
|
+
{ID: "T6", Column: data.ColumnPlanned},
|
|
128
|
+
}
|
|
129
|
+
m := NewModel(tasks, "v1", "E03")
|
|
130
|
+
m.Width = 100
|
|
131
|
+
m.Height = 24
|
|
132
|
+
|
|
133
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
|
|
134
|
+
updated := requireModel(t, got)
|
|
135
|
+
|
|
136
|
+
if updated.ColumnOffsets[data.ColumnPlanned] != 2 {
|
|
137
|
+
t.Errorf("ColumnOffsets[planned] = %d, want 2", updated.ColumnOffsets[data.ColumnPlanned])
|
|
138
|
+
}
|
|
139
|
+
if updated.FocusedTask != 2 {
|
|
140
|
+
t.Errorf("FocusedTask = %d, want 2", updated.FocusedTask)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
93
144
|
func TestUpdate_upMovesTaskFocus(t *testing.T) {
|
|
94
145
|
tasks := []data.Task{
|
|
95
146
|
{ID: "T1", Column: data.ColumnPlanned},
|
|
@@ -126,3 +177,118 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
|
|
|
126
177
|
t.Errorf("Width changed unexpectedly: %d", updated.Width)
|
|
127
178
|
}
|
|
128
179
|
}
|
|
180
|
+
|
|
181
|
+
func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
|
|
182
|
+
root := writeRouterFixture(t)
|
|
183
|
+
tasks := []data.Task{
|
|
184
|
+
{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
|
|
185
|
+
{ID: "E05-tasking-permissions/T005-update-help-overlay", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
|
|
186
|
+
}
|
|
187
|
+
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
188
|
+
m.Root = root
|
|
189
|
+
|
|
190
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
|
|
191
|
+
updated := requireModel(t, got)
|
|
192
|
+
|
|
193
|
+
if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
|
|
194
|
+
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
195
|
+
}
|
|
196
|
+
state := readRouterFixture(t, root)
|
|
197
|
+
if state.State != "task-building" {
|
|
198
|
+
t.Errorf("router state = %q, want task-building", state.State)
|
|
199
|
+
}
|
|
200
|
+
if state.Release != "v1.1" {
|
|
201
|
+
t.Errorf("router release = %q, want v1.1", state.Release)
|
|
202
|
+
}
|
|
203
|
+
if state.Epic != "E05-tasking-permissions" {
|
|
204
|
+
t.Errorf("router epic = %q, want E05-tasking-permissions", state.Epic)
|
|
205
|
+
}
|
|
206
|
+
if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
|
|
207
|
+
t.Errorf("router task = %q, want focused task", state.Task)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
|
|
212
|
+
root := writeRouterFixture(t)
|
|
213
|
+
tasks := []data.Task{
|
|
214
|
+
{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
|
|
215
|
+
{ID: "E05-tasking-permissions/T003-update-design-md", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone},
|
|
216
|
+
}
|
|
217
|
+
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
218
|
+
m.Root = root
|
|
219
|
+
|
|
220
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
|
|
221
|
+
updated := requireModel(t, got)
|
|
222
|
+
|
|
223
|
+
if updated.StatusMessage != "Audit pending for E05-tasking-permissions" {
|
|
224
|
+
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
225
|
+
}
|
|
226
|
+
state := readRouterFixture(t, root)
|
|
227
|
+
if state.State != "audit-pending" {
|
|
228
|
+
t.Errorf("router state = %q, want audit-pending", state.State)
|
|
229
|
+
}
|
|
230
|
+
if state.Task != "" {
|
|
231
|
+
t.Errorf("router task = %q, want empty", state.Task)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
|
|
236
|
+
root := writeRouterFixture(t)
|
|
237
|
+
tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
|
|
238
|
+
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
239
|
+
m.Root = root
|
|
240
|
+
m.Overlay = OverlayHelp
|
|
241
|
+
|
|
242
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
|
|
243
|
+
updated := requireModel(t, got)
|
|
244
|
+
|
|
245
|
+
if updated.StatusMessage != "" {
|
|
246
|
+
t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
|
|
247
|
+
}
|
|
248
|
+
state := readRouterFixture(t, root)
|
|
249
|
+
if state.Task != "T001" {
|
|
250
|
+
t.Errorf("router task = %q, want unchanged T001", state.Task)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
|
|
255
|
+
root := writeRouterFixture(t)
|
|
256
|
+
tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
|
|
257
|
+
m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
|
|
258
|
+
m.Root = root
|
|
259
|
+
m.FocusedColumn = data.ColumnDone
|
|
260
|
+
|
|
261
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
|
|
262
|
+
updated := requireModel(t, got)
|
|
263
|
+
|
|
264
|
+
if updated.StatusMessage != "Router not updated: focused task is done" {
|
|
265
|
+
t.Fatalf("StatusMessage = %q", updated.StatusMessage)
|
|
266
|
+
}
|
|
267
|
+
state := readRouterFixture(t, root)
|
|
268
|
+
if state.Task != "T001" {
|
|
269
|
+
t.Errorf("router task = %q, want unchanged T001", state.Task)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func writeRouterFixture(t *testing.T) string {
|
|
274
|
+
t.Helper()
|
|
275
|
+
root := t.TempDir()
|
|
276
|
+
content := "# Agent State Machine\n\n## Current state\n\n```yaml\nstate: task-building\nrelease: v1.1\nepic: E05\ntask: T001\nnext_action: Build T001.\n```\n"
|
|
277
|
+
if err := os.WriteFile(filepath.Join(root, "router.md"), []byte(content), 0644); err != nil {
|
|
278
|
+
t.Fatal(err)
|
|
279
|
+
}
|
|
280
|
+
return root
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func readRouterFixture(t *testing.T, root string) *data.RouterState {
|
|
284
|
+
t.Helper()
|
|
285
|
+
content, err := os.ReadFile(filepath.Join(root, "router.md"))
|
|
286
|
+
if err != nil {
|
|
287
|
+
t.Fatal(err)
|
|
288
|
+
}
|
|
289
|
+
state, err := data.NewRouterReader().ReadState(string(content))
|
|
290
|
+
if err != nil {
|
|
291
|
+
t.Fatal(err)
|
|
292
|
+
}
|
|
293
|
+
return state
|
|
294
|
+
}
|
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"
|
|
@@ -19,21 +20,25 @@ func (m Model) View() string {
|
|
|
19
20
|
w = defaultTermW
|
|
20
21
|
}
|
|
21
22
|
m.Width = w
|
|
23
|
+
h := m.Height
|
|
24
|
+
if h == 0 {
|
|
25
|
+
h = defaultTermH
|
|
26
|
+
}
|
|
27
|
+
m.Height = h
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
header := styles.HeaderFrame.Width(w).Render(icon + " " + text)
|
|
29
|
+
header := m.renderHeader(w)
|
|
30
|
+
nextActivity := m.renderNextActivityLine(w)
|
|
31
|
+
layout := CalculateLayoutWithChrome(w, h, extraHeaderLines(nextActivity))
|
|
27
32
|
topDivider := dividerLine(w)
|
|
28
33
|
board := m.renderBoard(layout)
|
|
29
34
|
bottomDivider := dividerLine(w)
|
|
30
35
|
footer := m.renderFooter(w)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if h == 0 {
|
|
35
|
-
h = defaultTermH
|
|
36
|
+
sections := []string{header}
|
|
37
|
+
if nextActivity != "" {
|
|
38
|
+
sections = append(sections, nextActivity)
|
|
36
39
|
}
|
|
40
|
+
sections = append(sections, topDivider, board, bottomDivider, footer)
|
|
41
|
+
base := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
37
42
|
|
|
38
43
|
if m.Overlay == OverlayEpic {
|
|
39
44
|
overlay := RenderEpicDropdown(m.Epics, m.EpicCursor, min(40, w))
|
|
@@ -56,13 +61,111 @@ func (m Model) View() string {
|
|
|
56
61
|
return base
|
|
57
62
|
}
|
|
58
63
|
ow := overlayWidth(w)
|
|
59
|
-
detail := RenderDetail(task, ow, m.
|
|
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)
|
|
60
75
|
return overlayOnBase(dimLines(base), detail, w, h)
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
return base
|
|
64
79
|
}
|
|
65
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
|
+
|
|
66
169
|
func (m Model) focusedTask() (data.Task, bool) {
|
|
67
170
|
tasks := m.Tasks[m.FocusedColumn]
|
|
68
171
|
if len(tasks) == 0 || m.FocusedTask >= len(tasks) {
|
|
@@ -150,23 +253,34 @@ func (m Model) renderBoard(layout Layout) string {
|
|
|
150
253
|
|
|
151
254
|
func (m Model) renderColumns(layout Layout) string {
|
|
152
255
|
if layout.ColCount == 1 {
|
|
153
|
-
return m.renderColumn(m.FocusedColumn, layout.ColWidths[0])
|
|
256
|
+
return m.renderColumn(m.FocusedColumn, layout.ColWidths[0], layout.ContentHeight)
|
|
154
257
|
}
|
|
155
258
|
allCols := []data.ColumnType{data.ColumnPlanned, data.ColumnInProgress, data.ColumnDone}
|
|
156
259
|
rendered := make([]string, len(allCols))
|
|
157
260
|
for i, col := range allCols {
|
|
158
|
-
rendered[i] = m.renderColumn(col, layout.ColWidths[i])
|
|
261
|
+
rendered[i] = m.renderColumn(col, layout.ColWidths[i], layout.ContentHeight)
|
|
159
262
|
}
|
|
160
263
|
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
|
161
264
|
}
|
|
162
265
|
|
|
163
266
|
func (m Model) renderEpicPanel(w int) string {
|
|
164
|
-
return RenderEpicSidebar(m.Epics, m.SelectedEpic, w)
|
|
267
|
+
return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
|
|
268
|
+
}
|
|
269
|
+
|
|
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)
|
|
165
273
|
}
|
|
166
274
|
|
|
167
|
-
func (
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
170
284
|
}
|
|
171
285
|
|
|
172
286
|
func (m Model) renderFooter(termW int) string {
|
|
@@ -196,5 +310,5 @@ func footerLine(termW int, content string) string {
|
|
|
196
310
|
if lipgloss.Width(content) > termW {
|
|
197
311
|
content = xansi.Truncate(content, termW, "")
|
|
198
312
|
}
|
|
199
|
-
return
|
|
313
|
+
return styles.RootLine.Width(termW).Align(lipgloss.Center).Render(content)
|
|
200
314
|
}
|
|
@@ -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
|
package/internal/board/watch.go
CHANGED
|
@@ -11,26 +11,33 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
type fileChangeMsg struct{}
|
|
14
|
-
type reloadMsg struct{
|
|
14
|
+
type reloadMsg struct {
|
|
15
|
+
tasks []data.Task
|
|
16
|
+
releases []string
|
|
17
|
+
releaseEpics map[string][]string
|
|
18
|
+
epicStatuses map[string]string
|
|
19
|
+
}
|
|
15
20
|
|
|
16
21
|
// watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
|
|
17
22
|
func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
18
23
|
return func() tea.Msg {
|
|
19
24
|
for {
|
|
20
25
|
select {
|
|
21
|
-
case
|
|
26
|
+
case event, ok := <-w.Events:
|
|
22
27
|
if !ok {
|
|
23
28
|
return nil
|
|
24
29
|
}
|
|
30
|
+
watchCreatedDir(w, event)
|
|
25
31
|
timer := time.NewTimer(100 * time.Millisecond)
|
|
26
32
|
drain:
|
|
27
33
|
for {
|
|
28
34
|
select {
|
|
29
|
-
case
|
|
35
|
+
case event, ok := <-w.Events:
|
|
30
36
|
if !ok {
|
|
31
37
|
timer.Stop()
|
|
32
38
|
return nil
|
|
33
39
|
}
|
|
40
|
+
watchCreatedDir(w, event)
|
|
34
41
|
case <-timer.C:
|
|
35
42
|
break drain
|
|
36
43
|
}
|
|
@@ -47,11 +54,11 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
|
47
54
|
|
|
48
55
|
func reloadTasks(root string) tea.Cmd {
|
|
49
56
|
return func() tea.Msg {
|
|
50
|
-
tasks, err :=
|
|
57
|
+
tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
|
|
51
58
|
if err != nil {
|
|
52
59
|
return nil
|
|
53
60
|
}
|
|
54
|
-
return reloadMsg{tasks: tasks}
|
|
61
|
+
return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses}
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
@@ -61,7 +68,7 @@ func newWatcher(root string) (*fsnotify.Watcher, error) {
|
|
|
61
68
|
if err != nil {
|
|
62
69
|
return nil, err
|
|
63
70
|
}
|
|
64
|
-
releasesPath := filepath.Join(root, "
|
|
71
|
+
releasesPath := filepath.Join(root, "releases")
|
|
65
72
|
if err := addDirsRecursive(w, releasesPath); err != nil {
|
|
66
73
|
w.Close()
|
|
67
74
|
return nil, err
|
|
@@ -69,6 +76,17 @@ func newWatcher(root string) (*fsnotify.Watcher, error) {
|
|
|
69
76
|
return w, nil
|
|
70
77
|
}
|
|
71
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
|
+
|
|
72
90
|
func addDirsRecursive(w *fsnotify.Watcher, root string) error {
|
|
73
91
|
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
74
92
|
if err != nil {
|