opencode-backlog 0.1.5 → 0.2.0
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/README.md +48 -14
- package/dist/backlog.d.ts +16 -6
- package/dist/backlog.js +124 -24
- package/dist/backlog.js.map +1 -1
- package/dist/index.js +141 -30
- package/dist/index.js.map +1 -1
- package/dist/input.d.ts +2 -2
- package/dist/input.js +3 -3
- package/dist/input.js.map +1 -1
- package/dist/tui.js +544 -112
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -10,13 +10,11 @@ import { randomUUID } from "node:crypto";
|
|
|
10
10
|
import { watch } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { Plugin } from "@opencode-ai/plugin/tui";
|
|
13
|
-
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
14
|
-
import { BACKLOG_FILE, EMPTY_BACKLOG,
|
|
13
|
+
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
14
|
+
import { addCategory, BACKLOG_FILE, EMPTY_BACKLOG, moveItem, moveCategory, purgeCategory, removeCategory, renameCategory } from "./backlog.js";
|
|
15
15
|
import { readBacklog, readBacklogSync, updateBacklog } from "./store.js";
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
if (status === "doing") return "Doing";
|
|
19
|
-
return "Done";
|
|
16
|
+
function categoryTitle(categories, status) {
|
|
17
|
+
return categories.find(category => category.id === status)?.title ?? status;
|
|
20
18
|
}
|
|
21
19
|
function readSnapshot(directory) {
|
|
22
20
|
try {
|
|
@@ -48,11 +46,6 @@ function BacklogView(props) {
|
|
|
48
46
|
clearTimeout(refresh);
|
|
49
47
|
watcher.close();
|
|
50
48
|
});
|
|
51
|
-
const color = status => {
|
|
52
|
-
if (status === "doing") return theme.text.feedback.warning.default;
|
|
53
|
-
if (status === "done") return theme.text.feedback.success.default;
|
|
54
|
-
return theme.text.subdued;
|
|
55
|
-
};
|
|
56
49
|
return (() => {
|
|
57
50
|
var _el$ = _$createElement("box"),
|
|
58
51
|
_el$2 = _$createElement("text"),
|
|
@@ -83,71 +76,189 @@ function BacklogView(props) {
|
|
|
83
76
|
}
|
|
84
77
|
}), null);
|
|
85
78
|
_$insert(_el$, _$createComponent(For, {
|
|
86
|
-
each
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
_$effect(_p$ => {
|
|
127
|
-
var _v$ = color(status),
|
|
128
|
-
_v$2 = theme.text.default;
|
|
129
|
-
_v$ !== _p$.e && (_p$.e = _$setProp(_el$12, "fg", _v$, _p$.e));
|
|
130
|
-
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$13, "fg", _v$2, _p$.t));
|
|
131
|
-
return _p$;
|
|
132
|
-
}, {
|
|
133
|
-
e: undefined,
|
|
134
|
-
t: undefined
|
|
135
|
-
});
|
|
136
|
-
return _el$11;
|
|
137
|
-
})()
|
|
138
|
-
}), null);
|
|
139
|
-
_$effect(_$p => _$setProp(_el$9, "fg", color(status), _$p));
|
|
140
|
-
return _el$8;
|
|
141
|
-
}
|
|
142
|
-
});
|
|
79
|
+
get each() {
|
|
80
|
+
return backlog().categories;
|
|
81
|
+
},
|
|
82
|
+
children: category => {
|
|
83
|
+
const items = createMemo(() => backlog().items.filter(item => item.status === category.id));
|
|
84
|
+
return (() => {
|
|
85
|
+
var _el$8 = _$createElement("box"),
|
|
86
|
+
_el$9 = _$createElement("text"),
|
|
87
|
+
_el$0 = _$createElement("b"),
|
|
88
|
+
_el$1 = _$createTextNode(` (`),
|
|
89
|
+
_el$10 = _$createTextNode(`)`);
|
|
90
|
+
_$insertNode(_el$8, _el$9);
|
|
91
|
+
_$setProp(_el$8, "marginTop", 1);
|
|
92
|
+
_$insertNode(_el$9, _el$0);
|
|
93
|
+
_$insertNode(_el$9, _el$1);
|
|
94
|
+
_$insertNode(_el$9, _el$10);
|
|
95
|
+
_$insert(_el$0, () => category.title);
|
|
96
|
+
_$insert(_el$9, () => items().length, _el$10);
|
|
97
|
+
_$insert(_el$8, _$createComponent(For, {
|
|
98
|
+
get each() {
|
|
99
|
+
return items();
|
|
100
|
+
},
|
|
101
|
+
children: item => (() => {
|
|
102
|
+
var _el$11 = _$createElement("box"),
|
|
103
|
+
_el$12 = _$createElement("text");
|
|
104
|
+
_$insertNode(_el$11, _el$12);
|
|
105
|
+
_$setProp(_el$11, "minWidth", 0);
|
|
106
|
+
_$setProp(_el$11, "onMouseUp", () => void showTaskDetails(props.context, item, backlog().categories));
|
|
107
|
+
_$setProp(_el$12, "wrapMode", "none");
|
|
108
|
+
_$setProp(_el$12, "truncate", true);
|
|
109
|
+
_$setProp(_el$12, "flexGrow", 1);
|
|
110
|
+
_$setProp(_el$12, "minWidth", 0);
|
|
111
|
+
_$insert(_el$12, () => item.title);
|
|
112
|
+
_$effect(_$p => _$setProp(_el$12, "fg", theme.text.default, _$p));
|
|
113
|
+
return _el$11;
|
|
114
|
+
})()
|
|
115
|
+
}), null);
|
|
116
|
+
_$effect(_$p => _$setProp(_el$9, "fg", theme.text.subdued, _$p));
|
|
117
|
+
return _el$8;
|
|
118
|
+
})();
|
|
143
119
|
}
|
|
144
120
|
}), null);
|
|
145
121
|
_$effect(_$p => _$setProp(_el$2, "fg", theme.text.default, _$p));
|
|
146
122
|
return _el$;
|
|
147
123
|
})();
|
|
148
124
|
}
|
|
149
|
-
function taskDetails(item) {
|
|
150
|
-
return [`
|
|
125
|
+
function taskDetails(item, categories) {
|
|
126
|
+
return [`Category: ${categoryTitle(categories, item.status)}`, `ID: ${item.id}`, "", item.notes ?? "No notes"].join("\n");
|
|
127
|
+
}
|
|
128
|
+
function TaskAction(props) {
|
|
129
|
+
return (() => {
|
|
130
|
+
var _el$13 = _$createElement("text"),
|
|
131
|
+
_el$14 = _$createElement("b"),
|
|
132
|
+
_el$15 = _$createTextNode(` `);
|
|
133
|
+
_$insertNode(_el$13, _el$14);
|
|
134
|
+
_$insertNode(_el$13, _el$15);
|
|
135
|
+
_$insert(_el$14, () => props.shortcut);
|
|
136
|
+
_$insert(_el$13, () => props.label, null);
|
|
137
|
+
_$effect(_p$ => {
|
|
138
|
+
var _v$ = props.danger ? props.context.theme.text.feedback.error.default : props.context.theme.text.subdued,
|
|
139
|
+
_v$2 = props.run;
|
|
140
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$13, "fg", _v$, _p$.e));
|
|
141
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$13, "onMouseUp", _v$2, _p$.t));
|
|
142
|
+
return _p$;
|
|
143
|
+
}, {
|
|
144
|
+
e: undefined,
|
|
145
|
+
t: undefined
|
|
146
|
+
});
|
|
147
|
+
return _el$13;
|
|
148
|
+
})();
|
|
149
|
+
}
|
|
150
|
+
function TaskDetailsDialog(props) {
|
|
151
|
+
const run = operation => {
|
|
152
|
+
props.context.ui.dialog.clear();
|
|
153
|
+
void operation().catch(cause => props.context.ui.toast.show({
|
|
154
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
155
|
+
variant: "error"
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
158
|
+
const changeStatus = () => run(() => changeTaskStatus(props.context, props.item));
|
|
159
|
+
const edit = () => run(() => editBacklogItem(props.context, props.item));
|
|
160
|
+
const remove = () => run(() => removeBacklogItem(props.context, props.item));
|
|
161
|
+
onMount(() => props.context.ui.dialog.set({
|
|
162
|
+
size: "medium"
|
|
163
|
+
}));
|
|
164
|
+
props.context.keymap.layer(() => ({
|
|
165
|
+
mode: "modal",
|
|
166
|
+
priority: 100,
|
|
167
|
+
commands: [{
|
|
168
|
+
bind: "c",
|
|
169
|
+
title: "Change backlog task status",
|
|
170
|
+
group: "Backlog",
|
|
171
|
+
run: changeStatus
|
|
172
|
+
}, {
|
|
173
|
+
bind: "e",
|
|
174
|
+
title: "Edit backlog task",
|
|
175
|
+
group: "Backlog",
|
|
176
|
+
run: edit
|
|
177
|
+
}, {
|
|
178
|
+
bind: "d",
|
|
179
|
+
title: "Delete backlog task",
|
|
180
|
+
group: "Backlog",
|
|
181
|
+
run: remove
|
|
182
|
+
}]
|
|
183
|
+
}));
|
|
184
|
+
return (() => {
|
|
185
|
+
var _el$16 = _$createElement("box"),
|
|
186
|
+
_el$17 = _$createElement("box"),
|
|
187
|
+
_el$18 = _$createElement("text"),
|
|
188
|
+
_el$19 = _$createElement("b"),
|
|
189
|
+
_el$20 = _$createElement("text"),
|
|
190
|
+
_el$22 = _$createElement("text"),
|
|
191
|
+
_el$23 = _$createElement("box");
|
|
192
|
+
_$insertNode(_el$16, _el$17);
|
|
193
|
+
_$insertNode(_el$16, _el$22);
|
|
194
|
+
_$insertNode(_el$16, _el$23);
|
|
195
|
+
_$setProp(_el$16, "paddingLeft", 2);
|
|
196
|
+
_$setProp(_el$16, "paddingRight", 2);
|
|
197
|
+
_$setProp(_el$16, "gap", 1);
|
|
198
|
+
_$insertNode(_el$17, _el$18);
|
|
199
|
+
_$insertNode(_el$17, _el$20);
|
|
200
|
+
_$setProp(_el$17, "flexDirection", "row");
|
|
201
|
+
_$setProp(_el$17, "justifyContent", "space-between");
|
|
202
|
+
_$setProp(_el$17, "gap", 2);
|
|
203
|
+
_$insertNode(_el$18, _el$19);
|
|
204
|
+
_$setProp(_el$18, "wrapMode", "word");
|
|
205
|
+
_$setProp(_el$18, "flexGrow", 1);
|
|
206
|
+
_$insert(_el$19, () => props.item.title);
|
|
207
|
+
_$insertNode(_el$20, _$createTextNode(`esc`));
|
|
208
|
+
_$setProp(_el$20, "onMouseUp", () => props.context.ui.dialog.clear());
|
|
209
|
+
_$setProp(_el$22, "wrapMode", "word");
|
|
210
|
+
_$insert(_el$22, () => taskDetails(props.item, props.categories));
|
|
211
|
+
_$setProp(_el$23, "flexDirection", "row");
|
|
212
|
+
_$setProp(_el$23, "justifyContent", "flex-end");
|
|
213
|
+
_$setProp(_el$23, "gap", 2);
|
|
214
|
+
_$setProp(_el$23, "paddingBottom", 1);
|
|
215
|
+
_$insert(_el$23, _$createComponent(TaskAction, {
|
|
216
|
+
get context() {
|
|
217
|
+
return props.context;
|
|
218
|
+
},
|
|
219
|
+
shortcut: "c",
|
|
220
|
+
label: "status",
|
|
221
|
+
run: changeStatus
|
|
222
|
+
}), null);
|
|
223
|
+
_$insert(_el$23, _$createComponent(TaskAction, {
|
|
224
|
+
get context() {
|
|
225
|
+
return props.context;
|
|
226
|
+
},
|
|
227
|
+
shortcut: "e",
|
|
228
|
+
label: "edit",
|
|
229
|
+
run: edit
|
|
230
|
+
}), null);
|
|
231
|
+
_$insert(_el$23, _$createComponent(TaskAction, {
|
|
232
|
+
get context() {
|
|
233
|
+
return props.context;
|
|
234
|
+
},
|
|
235
|
+
shortcut: "d",
|
|
236
|
+
label: "delete",
|
|
237
|
+
danger: true,
|
|
238
|
+
run: remove
|
|
239
|
+
}), null);
|
|
240
|
+
_$effect(_p$ => {
|
|
241
|
+
var _v$3 = props.context.theme.text.default,
|
|
242
|
+
_v$4 = props.context.theme.text.subdued,
|
|
243
|
+
_v$5 = props.context.theme.text.subdued;
|
|
244
|
+
_v$3 !== _p$.e && (_p$.e = _$setProp(_el$18, "fg", _v$3, _p$.e));
|
|
245
|
+
_v$4 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$4, _p$.t));
|
|
246
|
+
_v$5 !== _p$.a && (_p$.a = _$setProp(_el$22, "fg", _v$5, _p$.a));
|
|
247
|
+
return _p$;
|
|
248
|
+
}, {
|
|
249
|
+
e: undefined,
|
|
250
|
+
t: undefined,
|
|
251
|
+
a: undefined
|
|
252
|
+
});
|
|
253
|
+
return _el$16;
|
|
254
|
+
})();
|
|
255
|
+
}
|
|
256
|
+
function showTaskDetails(context, item, categories) {
|
|
257
|
+
context.ui.dialog.show(() => _$createComponent(TaskDetailsDialog, {
|
|
258
|
+
context: context,
|
|
259
|
+
item: item,
|
|
260
|
+
categories: categories
|
|
261
|
+
}));
|
|
151
262
|
}
|
|
152
263
|
async function browseBacklog(context) {
|
|
153
264
|
return browseBacklogWithState(context, () => {});
|
|
@@ -162,6 +273,9 @@ function backlogLocation(context) {
|
|
|
162
273
|
};
|
|
163
274
|
}
|
|
164
275
|
async function addBacklogItem(context) {
|
|
276
|
+
const {
|
|
277
|
+
path
|
|
278
|
+
} = backlogLocation(context);
|
|
165
279
|
const title = await context.ui.dialog.prompt({
|
|
166
280
|
title: "New backlog task",
|
|
167
281
|
placeholder: "Task title"
|
|
@@ -173,23 +287,303 @@ async function addBacklogItem(context) {
|
|
|
173
287
|
placeholder: "Leave empty for no notes"
|
|
174
288
|
});
|
|
175
289
|
if (notes === undefined) return;
|
|
176
|
-
|
|
177
|
-
|
|
290
|
+
let item;
|
|
291
|
+
await updateBacklog(path, current => {
|
|
292
|
+
const firstCategory = current.categories[0];
|
|
293
|
+
if (!firstCategory) throw new Error("Add a backlog category before adding a task");
|
|
294
|
+
item = {
|
|
295
|
+
id: randomUUID(),
|
|
296
|
+
title: title.trim(),
|
|
297
|
+
...(notes.trim() ? {
|
|
298
|
+
notes: notes.trim()
|
|
299
|
+
} : {}),
|
|
300
|
+
status: firstCategory.id
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
...current,
|
|
304
|
+
items: moveItem([...current.items, item], item.id, firstCategory.id, 0, current.categories)
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
if (!item) throw new Error("Backlog update did not add a task");
|
|
308
|
+
context.ui.toast.show({
|
|
309
|
+
message: `Added "${item.title}".`,
|
|
310
|
+
variant: "success"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async function changeTaskStatus(context, item) {
|
|
314
|
+
const {
|
|
315
|
+
path
|
|
316
|
+
} = backlogLocation(context);
|
|
317
|
+
const backlog = await readBacklog(path);
|
|
318
|
+
const status = await context.ui.dialog.select({
|
|
319
|
+
title: `Change category: ${item.title}`,
|
|
320
|
+
current: item.status,
|
|
321
|
+
options: backlog.categories.map(category => ({
|
|
322
|
+
title: category.title,
|
|
323
|
+
value: category.id,
|
|
324
|
+
...(category.id === item.status ? {
|
|
325
|
+
description: "Current category"
|
|
326
|
+
} : {})
|
|
327
|
+
}))
|
|
328
|
+
});
|
|
329
|
+
if (!status || status === item.status) return;
|
|
330
|
+
await updateBacklog(path, current => ({
|
|
331
|
+
...current,
|
|
332
|
+
items: moveItem(current.items, item.id, status, undefined, current.categories)
|
|
333
|
+
}));
|
|
334
|
+
context.ui.toast.show({
|
|
335
|
+
message: `Moved "${item.title}" to ${categoryTitle(backlog.categories, status)}.`,
|
|
336
|
+
variant: "success"
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
async function editBacklogItem(context, item) {
|
|
340
|
+
const title = await context.ui.dialog.prompt({
|
|
341
|
+
title: "Edit backlog task",
|
|
342
|
+
placeholder: "Task title",
|
|
343
|
+
value: item.title
|
|
344
|
+
});
|
|
345
|
+
if (!title?.trim()) return;
|
|
346
|
+
const notes = await context.ui.dialog.prompt({
|
|
178
347
|
title: title.trim(),
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
348
|
+
description: "Optional notes",
|
|
349
|
+
placeholder: "Leave empty for no notes",
|
|
350
|
+
value: item.notes ?? ""
|
|
351
|
+
});
|
|
352
|
+
if (notes === undefined) return;
|
|
184
353
|
const {
|
|
185
354
|
path
|
|
186
355
|
} = backlogLocation(context);
|
|
187
356
|
await updateBacklog(path, current => ({
|
|
188
|
-
|
|
189
|
-
items:
|
|
357
|
+
...current,
|
|
358
|
+
items: current.items.map(candidate => {
|
|
359
|
+
if (candidate.id !== item.id) return candidate;
|
|
360
|
+
return {
|
|
361
|
+
id: candidate.id,
|
|
362
|
+
title: title.trim(),
|
|
363
|
+
status: candidate.status,
|
|
364
|
+
...(notes.trim() ? {
|
|
365
|
+
notes: notes.trim()
|
|
366
|
+
} : {})
|
|
367
|
+
};
|
|
368
|
+
})
|
|
190
369
|
}));
|
|
191
370
|
context.ui.toast.show({
|
|
192
|
-
message: `
|
|
371
|
+
message: `Updated "${title.trim()}".`,
|
|
372
|
+
variant: "success"
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async function removeBacklogItem(context, item) {
|
|
376
|
+
const confirmed = await context.ui.dialog.confirm({
|
|
377
|
+
title: "Delete backlog task",
|
|
378
|
+
message: item.title,
|
|
379
|
+
label: {
|
|
380
|
+
confirm: "Delete",
|
|
381
|
+
cancel: "Cancel"
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
if (!confirmed) return;
|
|
385
|
+
const {
|
|
386
|
+
path
|
|
387
|
+
} = backlogLocation(context);
|
|
388
|
+
await updateBacklog(path, current => ({
|
|
389
|
+
...current,
|
|
390
|
+
items: current.items.filter(candidate => candidate.id !== item.id)
|
|
391
|
+
}));
|
|
392
|
+
context.ui.toast.show({
|
|
393
|
+
message: `Deleted "${item.title}".`,
|
|
394
|
+
variant: "success"
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function purgeConfirmedCategory(path, status, confirmedIDs) {
|
|
398
|
+
await updateBacklog(path, current => {
|
|
399
|
+
const currentIDs = current.items.filter(item => item.status === status).map(item => item.id);
|
|
400
|
+
const changed = currentIDs.length !== confirmedIDs.length || currentIDs.some(id => !confirmedIDs.includes(id));
|
|
401
|
+
if (changed) throw new Error("The category changed. Review its tasks and confirm the purge again.");
|
|
402
|
+
return purgeCategory(current, status);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
async function purgeBacklogCategory(context) {
|
|
406
|
+
const {
|
|
407
|
+
path
|
|
408
|
+
} = backlogLocation(context);
|
|
409
|
+
const backlog = await readBacklog(path);
|
|
410
|
+
const status = await context.ui.dialog.select({
|
|
411
|
+
title: "Purge backlog category",
|
|
412
|
+
placeholder: "Select a category",
|
|
413
|
+
options: backlog.categories.map(category => {
|
|
414
|
+
const count = backlog.items.filter(item => item.status === category.id).length;
|
|
415
|
+
return {
|
|
416
|
+
title: category.title,
|
|
417
|
+
value: category.id,
|
|
418
|
+
description: `${count} ${count === 1 ? "task" : "tasks"}`
|
|
419
|
+
};
|
|
420
|
+
})
|
|
421
|
+
});
|
|
422
|
+
if (!status) return;
|
|
423
|
+
const category = backlog.categories.find(candidate => candidate.id === status);
|
|
424
|
+
if (!category) return;
|
|
425
|
+
const taskIDs = backlog.items.filter(item => item.status === status).map(item => item.id);
|
|
426
|
+
const count = taskIDs.length;
|
|
427
|
+
const confirmed = await context.ui.dialog.confirm({
|
|
428
|
+
title: `Purge ${category.title}`,
|
|
429
|
+
message: `Permanently delete ${count} ${count === 1 ? "task" : "tasks"} from this category?`,
|
|
430
|
+
label: {
|
|
431
|
+
confirm: "Purge",
|
|
432
|
+
cancel: "Cancel"
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
if (!confirmed) return;
|
|
436
|
+
await purgeConfirmedCategory(path, status, taskIDs);
|
|
437
|
+
context.ui.toast.show({
|
|
438
|
+
message: `Purged ${count} ${count === 1 ? "task" : "tasks"} from ${category.title}.`,
|
|
439
|
+
variant: "success"
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async function addBacklogCategory(context) {
|
|
443
|
+
const title = await context.ui.dialog.prompt({
|
|
444
|
+
title: "New backlog category",
|
|
445
|
+
placeholder: "Category title"
|
|
446
|
+
});
|
|
447
|
+
if (!title?.trim()) return;
|
|
448
|
+
const suggestedID = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
449
|
+
const id = await context.ui.dialog.prompt({
|
|
450
|
+
title: title.trim(),
|
|
451
|
+
description: "Stable category ID",
|
|
452
|
+
placeholder: "Category ID",
|
|
453
|
+
value: suggestedID
|
|
454
|
+
});
|
|
455
|
+
if (!id?.trim()) return;
|
|
456
|
+
const category = {
|
|
457
|
+
id: id.trim(),
|
|
458
|
+
title: title.trim()
|
|
459
|
+
};
|
|
460
|
+
const {
|
|
461
|
+
path
|
|
462
|
+
} = backlogLocation(context);
|
|
463
|
+
await updateBacklog(path, current => addCategory(current, category));
|
|
464
|
+
context.ui.toast.show({
|
|
465
|
+
message: `Added category "${category.title}".`,
|
|
466
|
+
variant: "success"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async function renameBacklogCategory(context, category) {
|
|
470
|
+
const title = await context.ui.dialog.prompt({
|
|
471
|
+
title: "Rename backlog category",
|
|
472
|
+
placeholder: "Category title",
|
|
473
|
+
value: category.title
|
|
474
|
+
});
|
|
475
|
+
if (!title?.trim() || title.trim() === category.title) return;
|
|
476
|
+
const {
|
|
477
|
+
path
|
|
478
|
+
} = backlogLocation(context);
|
|
479
|
+
await updateBacklog(path, current => renameCategory(current, category.id, title.trim()));
|
|
480
|
+
context.ui.toast.show({
|
|
481
|
+
message: `Renamed category to "${title.trim()}".`,
|
|
482
|
+
variant: "success"
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
async function moveBacklogCategory(context, category) {
|
|
486
|
+
const {
|
|
487
|
+
path
|
|
488
|
+
} = backlogLocation(context);
|
|
489
|
+
const backlog = await readBacklog(path);
|
|
490
|
+
const position = await context.ui.dialog.select({
|
|
491
|
+
title: `Move ${category.title}`,
|
|
492
|
+
placeholder: "Select a position",
|
|
493
|
+
options: backlog.categories.map((candidate, index) => ({
|
|
494
|
+
title: `${index + 1}. ${candidate.title}`,
|
|
495
|
+
value: index,
|
|
496
|
+
...(candidate.id === category.id ? {
|
|
497
|
+
description: "Current position"
|
|
498
|
+
} : {})
|
|
499
|
+
}))
|
|
500
|
+
});
|
|
501
|
+
if (position === undefined || backlog.categories[position]?.id === category.id) return;
|
|
502
|
+
await updateBacklog(path, current => moveCategory(current, category.id, position));
|
|
503
|
+
context.ui.toast.show({
|
|
504
|
+
message: `Moved category "${category.title}".`,
|
|
505
|
+
variant: "success"
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
async function deleteBacklogCategory(context, category) {
|
|
509
|
+
const confirmed = await context.ui.dialog.confirm({
|
|
510
|
+
title: "Delete backlog category",
|
|
511
|
+
message: category.title,
|
|
512
|
+
label: {
|
|
513
|
+
confirm: "Delete",
|
|
514
|
+
cancel: "Cancel"
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
if (!confirmed) return;
|
|
518
|
+
const {
|
|
519
|
+
path
|
|
520
|
+
} = backlogLocation(context);
|
|
521
|
+
await updateBacklog(path, current => removeCategory(current, category.id));
|
|
522
|
+
context.ui.toast.show({
|
|
523
|
+
message: `Deleted category "${category.title}".`,
|
|
524
|
+
variant: "success"
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
async function manageBacklogCategories(context) {
|
|
528
|
+
const {
|
|
529
|
+
path
|
|
530
|
+
} = backlogLocation(context);
|
|
531
|
+
const backlog = await readBacklog(path);
|
|
532
|
+
let addSelection = `__add:${randomUUID()}`;
|
|
533
|
+
while (backlog.categories.some(category => category.id === addSelection)) {
|
|
534
|
+
addSelection = `__add:${randomUUID()}`;
|
|
535
|
+
}
|
|
536
|
+
const selection = await context.ui.dialog.select({
|
|
537
|
+
title: "Backlog categories",
|
|
538
|
+
placeholder: "Add or select a category",
|
|
539
|
+
options: [{
|
|
540
|
+
title: "Add category",
|
|
541
|
+
value: addSelection
|
|
542
|
+
}, ...backlog.categories.map(category => ({
|
|
543
|
+
title: category.title,
|
|
544
|
+
value: category.id,
|
|
545
|
+
description: `${category.id} - ${backlog.items.filter(item => item.status === category.id).length} tasks`
|
|
546
|
+
}))]
|
|
547
|
+
});
|
|
548
|
+
if (!selection) return;
|
|
549
|
+
if (selection === addSelection) return addBacklogCategory(context);
|
|
550
|
+
const category = backlog.categories.find(candidate => candidate.id === selection);
|
|
551
|
+
if (!category) return;
|
|
552
|
+
const taskIDs = backlog.items.filter(item => item.status === category.id).map(item => item.id);
|
|
553
|
+
const count = taskIDs.length;
|
|
554
|
+
const action = await context.ui.dialog.select({
|
|
555
|
+
title: category.title,
|
|
556
|
+
placeholder: "Select an action",
|
|
557
|
+
options: [{
|
|
558
|
+
title: "Rename",
|
|
559
|
+
value: "rename"
|
|
560
|
+
}, {
|
|
561
|
+
title: "Move",
|
|
562
|
+
value: "move"
|
|
563
|
+
}, ...(count === 0 ? [{
|
|
564
|
+
title: "Delete empty category",
|
|
565
|
+
value: "delete"
|
|
566
|
+
}] : [{
|
|
567
|
+
title: `Purge category and ${count} ${count === 1 ? "task" : "tasks"}`,
|
|
568
|
+
value: "purge"
|
|
569
|
+
}])]
|
|
570
|
+
});
|
|
571
|
+
if (action === "rename") return renameBacklogCategory(context, category);
|
|
572
|
+
if (action === "move") return moveBacklogCategory(context, category);
|
|
573
|
+
if (action === "delete") return deleteBacklogCategory(context, category);
|
|
574
|
+
if (action !== "purge") return;
|
|
575
|
+
const confirmed = await context.ui.dialog.confirm({
|
|
576
|
+
title: `Purge ${category.title}`,
|
|
577
|
+
message: `Permanently delete ${count} ${count === 1 ? "task" : "tasks"} from this category?`,
|
|
578
|
+
label: {
|
|
579
|
+
confirm: "Purge",
|
|
580
|
+
cancel: "Cancel"
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
if (!confirmed) return;
|
|
584
|
+
await purgeConfirmedCategory(path, category.id, taskIDs);
|
|
585
|
+
context.ui.toast.show({
|
|
586
|
+
message: `Purged category "${category.title}".`,
|
|
193
587
|
variant: "success"
|
|
194
588
|
});
|
|
195
589
|
}
|
|
@@ -214,34 +608,15 @@ async function moveBacklogItem(context) {
|
|
|
214
608
|
...(item.notes === undefined ? {} : {
|
|
215
609
|
description: item.notes
|
|
216
610
|
}),
|
|
217
|
-
category:
|
|
611
|
+
category: categoryTitle(backlog.categories, item.status)
|
|
218
612
|
}))
|
|
219
613
|
});
|
|
220
614
|
if (!id) return;
|
|
221
615
|
const item = backlog.items.find(candidate => candidate.id === id);
|
|
222
616
|
if (!item) return;
|
|
223
|
-
|
|
224
|
-
title: `Change status: ${item.title}`,
|
|
225
|
-
current: item.status,
|
|
226
|
-
options: STATUSES.map(candidate => ({
|
|
227
|
-
title: statusLabel(candidate),
|
|
228
|
-
value: candidate,
|
|
229
|
-
...(candidate === item.status ? {
|
|
230
|
-
description: "Current status"
|
|
231
|
-
} : {})
|
|
232
|
-
}))
|
|
233
|
-
});
|
|
234
|
-
if (!status || status === item.status) return;
|
|
235
|
-
await updateBacklog(path, current => ({
|
|
236
|
-
version: 1,
|
|
237
|
-
items: moveItem(current.items, item.id, status, undefined)
|
|
238
|
-
}));
|
|
239
|
-
context.ui.toast.show({
|
|
240
|
-
message: `Moved "${item.title}" to ${statusLabel(status)}.`,
|
|
241
|
-
variant: "success"
|
|
242
|
-
});
|
|
617
|
+
await changeTaskStatus(context, item);
|
|
243
618
|
}
|
|
244
|
-
async function browseBacklogWithState(context, setOpen) {
|
|
619
|
+
async function browseBacklogWithState(context, setOpen, action = () => "details") {
|
|
245
620
|
const {
|
|
246
621
|
path
|
|
247
622
|
} = backlogLocation(context);
|
|
@@ -255,7 +630,7 @@ async function browseBacklogWithState(context, setOpen) {
|
|
|
255
630
|
}
|
|
256
631
|
setOpen(true);
|
|
257
632
|
const id = await context.ui.dialog.select({
|
|
258
|
-
title: "Backlog
|
|
633
|
+
title: "Backlog",
|
|
259
634
|
placeholder: "Select a task",
|
|
260
635
|
options: backlog.items.map(item => ({
|
|
261
636
|
title: item.title,
|
|
@@ -263,19 +638,21 @@ async function browseBacklogWithState(context, setOpen) {
|
|
|
263
638
|
...(item.notes === undefined ? {} : {
|
|
264
639
|
description: item.notes
|
|
265
640
|
}),
|
|
266
|
-
category:
|
|
641
|
+
category: categoryTitle(backlog.categories, item.status)
|
|
267
642
|
}))
|
|
268
643
|
}).finally(() => setOpen(false));
|
|
269
644
|
if (!id) return;
|
|
270
645
|
const item = backlog.items.find(candidate => candidate.id === id);
|
|
271
646
|
if (!item) return;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
647
|
+
const selectedAction = action();
|
|
648
|
+
if (selectedAction === "status") return changeTaskStatus(context, item);
|
|
649
|
+
if (selectedAction === "edit") return editBacklogItem(context, item);
|
|
650
|
+
if (selectedAction === "delete") return removeBacklogItem(context, item);
|
|
651
|
+
showTaskDetails(context, item, backlog.categories);
|
|
276
652
|
}
|
|
277
653
|
function Commands(props) {
|
|
278
654
|
const [browseOpen, setBrowseOpen] = createSignal(false);
|
|
655
|
+
const [browseAction, setBrowseAction] = createSignal("details");
|
|
279
656
|
const run = async operation => {
|
|
280
657
|
try {
|
|
281
658
|
await operation();
|
|
@@ -291,18 +668,21 @@ function Commands(props) {
|
|
|
291
668
|
commands: [{
|
|
292
669
|
id: "backlog.browse",
|
|
293
670
|
title: "Browse backlog",
|
|
294
|
-
description: "View backlog tasks and change their
|
|
671
|
+
description: "View backlog tasks and change their category",
|
|
295
672
|
group: "Backlog",
|
|
296
673
|
palette: true,
|
|
297
674
|
slash: {
|
|
298
675
|
name: "backlog",
|
|
299
676
|
aliases: ["tasks"]
|
|
300
677
|
},
|
|
301
|
-
run: () =>
|
|
678
|
+
run: () => {
|
|
679
|
+
setBrowseAction("details");
|
|
680
|
+
return run(() => browseBacklogWithState(props.context, setBrowseOpen, browseAction));
|
|
681
|
+
}
|
|
302
682
|
}, {
|
|
303
683
|
id: "backlog.add",
|
|
304
684
|
title: "Add backlog task",
|
|
305
|
-
description: "Create a
|
|
685
|
+
description: "Create a task at the top of the first category",
|
|
306
686
|
group: "Backlog",
|
|
307
687
|
palette: true,
|
|
308
688
|
slash: {
|
|
@@ -313,7 +693,7 @@ function Commands(props) {
|
|
|
313
693
|
}, {
|
|
314
694
|
id: "backlog.move",
|
|
315
695
|
title: "Move backlog task",
|
|
316
|
-
description: "Change a backlog task
|
|
696
|
+
description: "Change a backlog task category",
|
|
317
697
|
group: "Backlog",
|
|
318
698
|
palette: true,
|
|
319
699
|
slash: {
|
|
@@ -321,6 +701,26 @@ function Commands(props) {
|
|
|
321
701
|
aliases: ["task-move"]
|
|
322
702
|
},
|
|
323
703
|
run: () => run(() => moveBacklogItem(props.context))
|
|
704
|
+
}, {
|
|
705
|
+
id: "backlog.purge",
|
|
706
|
+
title: "Purge backlog category",
|
|
707
|
+
description: "Permanently delete every task in a category",
|
|
708
|
+
group: "Backlog",
|
|
709
|
+
palette: true,
|
|
710
|
+
slash: {
|
|
711
|
+
name: "backlog-purge"
|
|
712
|
+
},
|
|
713
|
+
run: () => run(() => purgeBacklogCategory(props.context))
|
|
714
|
+
}, {
|
|
715
|
+
id: "backlog.categories",
|
|
716
|
+
title: "Manage backlog categories",
|
|
717
|
+
description: "Add, rename, move, purge, or delete a category",
|
|
718
|
+
group: "Backlog",
|
|
719
|
+
palette: true,
|
|
720
|
+
slash: {
|
|
721
|
+
name: "backlog-categories"
|
|
722
|
+
},
|
|
723
|
+
run: () => run(() => manageBacklogCategories(props.context))
|
|
324
724
|
}]
|
|
325
725
|
}));
|
|
326
726
|
props.context.keymap.layer(() => ({
|
|
@@ -335,6 +735,38 @@ function Commands(props) {
|
|
|
335
735
|
setBrowseOpen(false);
|
|
336
736
|
return run(() => addBacklogItem(props.context));
|
|
337
737
|
}
|
|
738
|
+
}, {
|
|
739
|
+
bind: "p",
|
|
740
|
+
title: "Purge backlog category",
|
|
741
|
+
group: "Backlog",
|
|
742
|
+
run() {
|
|
743
|
+
setBrowseOpen(false);
|
|
744
|
+
return run(() => purgeBacklogCategory(props.context));
|
|
745
|
+
}
|
|
746
|
+
}, {
|
|
747
|
+
bind: "c",
|
|
748
|
+
title: "Change selected task category",
|
|
749
|
+
group: "Backlog",
|
|
750
|
+
run() {
|
|
751
|
+
setBrowseAction("status");
|
|
752
|
+
props.context.keymap.dispatch("dialog.select.submit");
|
|
753
|
+
}
|
|
754
|
+
}, {
|
|
755
|
+
bind: "d",
|
|
756
|
+
title: "Delete selected task",
|
|
757
|
+
group: "Backlog",
|
|
758
|
+
run() {
|
|
759
|
+
setBrowseAction("delete");
|
|
760
|
+
props.context.keymap.dispatch("dialog.select.submit");
|
|
761
|
+
}
|
|
762
|
+
}, {
|
|
763
|
+
bind: "e",
|
|
764
|
+
title: "Edit selected task",
|
|
765
|
+
group: "Backlog",
|
|
766
|
+
run() {
|
|
767
|
+
setBrowseAction("edit");
|
|
768
|
+
props.context.keymap.dispatch("dialog.select.submit");
|
|
769
|
+
}
|
|
338
770
|
}]
|
|
339
771
|
}));
|
|
340
772
|
return null;
|