opencode-backlog 0.1.6 → 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/dist/tui.js CHANGED
@@ -11,12 +11,10 @@ import { watch } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { Plugin } from "@opencode-ai/plugin/tui";
13
13
  import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
14
- import { BACKLOG_FILE, EMPTY_BACKLOG, STATUSES, moveItem } from "./backlog.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 statusLabel(status) {
17
- if (status === "todo") return "Todo";
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,93 +76,75 @@ function BacklogView(props) {
83
76
  }
84
77
  }), null);
85
78
  _$insert(_el$, _$createComponent(For, {
86
- each: STATUSES,
87
- children: status => {
88
- const items = createMemo(() => backlog().items.filter(item => item.status === status));
89
- return _$createComponent(Show, {
90
- get when() {
91
- return items().length > 0;
92
- },
93
- get children() {
94
- var _el$8 = _$createElement("box"),
95
- _el$9 = _$createElement("text"),
96
- _el$0 = _$createElement("b"),
97
- _el$1 = _$createTextNode(` (`),
98
- _el$10 = _$createTextNode(`)`);
99
- _$insertNode(_el$8, _el$9);
100
- _$setProp(_el$8, "marginTop", 1);
101
- _$insertNode(_el$9, _el$0);
102
- _$insertNode(_el$9, _el$1);
103
- _$insertNode(_el$9, _el$10);
104
- _$insert(_el$0, () => statusLabel(status));
105
- _$insert(_el$9, () => items().length, _el$10);
106
- _$insert(_el$8, _$createComponent(For, {
107
- get each() {
108
- return items();
109
- },
110
- children: item => (() => {
111
- var _el$11 = _$createElement("box"),
112
- _el$12 = _$createElement("text"),
113
- _el$13 = _$createElement("text");
114
- _$insertNode(_el$11, _el$12);
115
- _$insertNode(_el$11, _el$13);
116
- _$setProp(_el$11, "flexDirection", "row");
117
- _$setProp(_el$11, "gap", 1);
118
- _$setProp(_el$11, "minWidth", 0);
119
- _$setProp(_el$11, "onMouseUp", () => void showTaskDetails(props.context, item));
120
- _$setProp(_el$12, "flexShrink", 0);
121
- _$insert(_el$12, status === "done" ? "✓" : status === "doing" ? "●" : "○");
122
- _$setProp(_el$13, "wrapMode", "none");
123
- _$setProp(_el$13, "truncate", true);
124
- _$setProp(_el$13, "flexGrow", 1);
125
- _$setProp(_el$13, "minWidth", 0);
126
- _$insert(_el$13, () => item.title);
127
- _$effect(_p$ => {
128
- var _v$ = color(status),
129
- _v$2 = theme.text.default;
130
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$12, "fg", _v$, _p$.e));
131
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$13, "fg", _v$2, _p$.t));
132
- return _p$;
133
- }, {
134
- e: undefined,
135
- t: undefined
136
- });
137
- return _el$11;
138
- })()
139
- }), null);
140
- _$effect(_$p => _$setProp(_el$9, "fg", color(status), _$p));
141
- return _el$8;
142
- }
143
- });
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
+ })();
144
119
  }
145
120
  }), null);
146
121
  _$effect(_$p => _$setProp(_el$2, "fg", theme.text.default, _$p));
147
122
  return _el$;
148
123
  })();
149
124
  }
150
- function taskDetails(item) {
151
- return [`Status: ${statusLabel(item.status)}`, `ID: ${item.id}`, "", item.notes ?? "No notes"].join("\n");
125
+ function taskDetails(item, categories) {
126
+ return [`Category: ${categoryTitle(categories, item.status)}`, `ID: ${item.id}`, "", item.notes ?? "No notes"].join("\n");
152
127
  }
153
128
  function TaskAction(props) {
154
129
  return (() => {
155
- var _el$14 = _$createElement("text"),
156
- _el$15 = _$createElement("b"),
157
- _el$16 = _$createTextNode(` `);
158
- _$insertNode(_el$14, _el$15);
159
- _$insertNode(_el$14, _el$16);
160
- _$insert(_el$15, () => props.shortcut);
161
- _$insert(_el$14, () => props.label, null);
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);
162
137
  _$effect(_p$ => {
163
- var _v$3 = props.danger ? props.context.theme.text.feedback.error.default : props.context.theme.text.subdued,
164
- _v$4 = props.run;
165
- _v$3 !== _p$.e && (_p$.e = _$setProp(_el$14, "fg", _v$3, _p$.e));
166
- _v$4 !== _p$.t && (_p$.t = _$setProp(_el$14, "onMouseUp", _v$4, _p$.t));
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));
167
142
  return _p$;
168
143
  }, {
169
144
  e: undefined,
170
145
  t: undefined
171
146
  });
172
- return _el$14;
147
+ return _el$13;
173
148
  })();
174
149
  }
175
150
  function TaskDetailsDialog(props) {
@@ -207,37 +182,37 @@ function TaskDetailsDialog(props) {
207
182
  }]
208
183
  }));
209
184
  return (() => {
210
- var _el$17 = _$createElement("box"),
211
- _el$18 = _$createElement("box"),
212
- _el$19 = _$createElement("text"),
213
- _el$20 = _$createElement("b"),
214
- _el$21 = _$createElement("text"),
215
- _el$23 = _$createElement("text"),
216
- _el$24 = _$createElement("box");
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);
217
198
  _$insertNode(_el$17, _el$18);
218
- _$insertNode(_el$17, _el$23);
219
- _$insertNode(_el$17, _el$24);
220
- _$setProp(_el$17, "paddingLeft", 2);
221
- _$setProp(_el$17, "paddingRight", 2);
222
- _$setProp(_el$17, "gap", 1);
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);
223
203
  _$insertNode(_el$18, _el$19);
224
- _$insertNode(_el$18, _el$21);
225
- _$setProp(_el$18, "flexDirection", "row");
226
- _$setProp(_el$18, "justifyContent", "space-between");
227
- _$setProp(_el$18, "gap", 2);
228
- _$insertNode(_el$19, _el$20);
229
- _$setProp(_el$19, "wrapMode", "word");
230
- _$setProp(_el$19, "flexGrow", 1);
231
- _$insert(_el$20, () => props.item.title);
232
- _$insertNode(_el$21, _$createTextNode(`esc`));
233
- _$setProp(_el$21, "onMouseUp", () => props.context.ui.dialog.clear());
234
- _$setProp(_el$23, "wrapMode", "word");
235
- _$insert(_el$23, () => taskDetails(props.item));
236
- _$setProp(_el$24, "flexDirection", "row");
237
- _$setProp(_el$24, "justifyContent", "flex-end");
238
- _$setProp(_el$24, "gap", 2);
239
- _$setProp(_el$24, "paddingBottom", 1);
240
- _$insert(_el$24, _$createComponent(TaskAction, {
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, {
241
216
  get context() {
242
217
  return props.context;
243
218
  },
@@ -245,7 +220,7 @@ function TaskDetailsDialog(props) {
245
220
  label: "status",
246
221
  run: changeStatus
247
222
  }), null);
248
- _$insert(_el$24, _$createComponent(TaskAction, {
223
+ _$insert(_el$23, _$createComponent(TaskAction, {
249
224
  get context() {
250
225
  return props.context;
251
226
  },
@@ -253,7 +228,7 @@ function TaskDetailsDialog(props) {
253
228
  label: "edit",
254
229
  run: edit
255
230
  }), null);
256
- _$insert(_el$24, _$createComponent(TaskAction, {
231
+ _$insert(_el$23, _$createComponent(TaskAction, {
257
232
  get context() {
258
233
  return props.context;
259
234
  },
@@ -263,25 +238,26 @@ function TaskDetailsDialog(props) {
263
238
  run: remove
264
239
  }), null);
265
240
  _$effect(_p$ => {
266
- var _v$5 = props.context.theme.text.default,
267
- _v$6 = props.context.theme.text.subdued,
268
- _v$7 = props.context.theme.text.subdued;
269
- _v$5 !== _p$.e && (_p$.e = _$setProp(_el$19, "fg", _v$5, _p$.e));
270
- _v$6 !== _p$.t && (_p$.t = _$setProp(_el$21, "fg", _v$6, _p$.t));
271
- _v$7 !== _p$.a && (_p$.a = _$setProp(_el$23, "fg", _v$7, _p$.a));
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));
272
247
  return _p$;
273
248
  }, {
274
249
  e: undefined,
275
250
  t: undefined,
276
251
  a: undefined
277
252
  });
278
- return _el$17;
253
+ return _el$16;
279
254
  })();
280
255
  }
281
- function showTaskDetails(context, item) {
256
+ function showTaskDetails(context, item, categories) {
282
257
  context.ui.dialog.show(() => _$createComponent(TaskDetailsDialog, {
283
258
  context: context,
284
- item: item
259
+ item: item,
260
+ categories: categories
285
261
  }));
286
262
  }
287
263
  async function browseBacklog(context) {
@@ -297,6 +273,9 @@ function backlogLocation(context) {
297
273
  };
298
274
  }
299
275
  async function addBacklogItem(context) {
276
+ const {
277
+ path
278
+ } = backlogLocation(context);
300
279
  const title = await context.ui.dialog.prompt({
301
280
  title: "New backlog task",
302
281
  placeholder: "Task title"
@@ -308,48 +287,52 @@ async function addBacklogItem(context) {
308
287
  placeholder: "Leave empty for no notes"
309
288
  });
310
289
  if (notes === undefined) return;
311
- const item = {
312
- id: randomUUID(),
313
- title: title.trim(),
314
- ...(notes.trim() ? {
315
- notes: notes.trim()
316
- } : {}),
317
- status: "todo"
318
- };
319
- const {
320
- path
321
- } = backlogLocation(context);
322
- await updateBacklog(path, current => ({
323
- version: 1,
324
- items: moveItem([...current.items, item], item.id, "todo", 0)
325
- }));
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");
326
308
  context.ui.toast.show({
327
309
  message: `Added "${item.title}".`,
328
310
  variant: "success"
329
311
  });
330
312
  }
331
313
  async function changeTaskStatus(context, item) {
314
+ const {
315
+ path
316
+ } = backlogLocation(context);
317
+ const backlog = await readBacklog(path);
332
318
  const status = await context.ui.dialog.select({
333
- title: `Change status: ${item.title}`,
319
+ title: `Change category: ${item.title}`,
334
320
  current: item.status,
335
- options: STATUSES.map(candidate => ({
336
- title: statusLabel(candidate),
337
- value: candidate,
338
- ...(candidate === item.status ? {
339
- description: "Current status"
321
+ options: backlog.categories.map(category => ({
322
+ title: category.title,
323
+ value: category.id,
324
+ ...(category.id === item.status ? {
325
+ description: "Current category"
340
326
  } : {})
341
327
  }))
342
328
  });
343
329
  if (!status || status === item.status) return;
344
- const {
345
- path
346
- } = backlogLocation(context);
347
330
  await updateBacklog(path, current => ({
348
- version: 1,
349
- items: moveItem(current.items, item.id, status, undefined)
331
+ ...current,
332
+ items: moveItem(current.items, item.id, status, undefined, current.categories)
350
333
  }));
351
334
  context.ui.toast.show({
352
- message: `Moved "${item.title}" to ${statusLabel(status)}.`,
335
+ message: `Moved "${item.title}" to ${categoryTitle(backlog.categories, status)}.`,
353
336
  variant: "success"
354
337
  });
355
338
  }
@@ -371,7 +354,7 @@ async function editBacklogItem(context, item) {
371
354
  path
372
355
  } = backlogLocation(context);
373
356
  await updateBacklog(path, current => ({
374
- version: 1,
357
+ ...current,
375
358
  items: current.items.map(candidate => {
376
359
  if (candidate.id !== item.id) return candidate;
377
360
  return {
@@ -403,7 +386,7 @@ async function removeBacklogItem(context, item) {
403
386
  path
404
387
  } = backlogLocation(context);
405
388
  await updateBacklog(path, current => ({
406
- version: 1,
389
+ ...current,
407
390
  items: current.items.filter(candidate => candidate.id !== item.id)
408
391
  }));
409
392
  context.ui.toast.show({
@@ -411,6 +394,199 @@ async function removeBacklogItem(context, item) {
411
394
  variant: "success"
412
395
  });
413
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}".`,
587
+ variant: "success"
588
+ });
589
+ }
414
590
  async function moveBacklogItem(context) {
415
591
  const {
416
592
  path
@@ -432,7 +608,7 @@ async function moveBacklogItem(context) {
432
608
  ...(item.notes === undefined ? {} : {
433
609
  description: item.notes
434
610
  }),
435
- category: statusLabel(item.status)
611
+ category: categoryTitle(backlog.categories, item.status)
436
612
  }))
437
613
  });
438
614
  if (!id) return;
@@ -462,7 +638,7 @@ async function browseBacklogWithState(context, setOpen, action = () => "details"
462
638
  ...(item.notes === undefined ? {} : {
463
639
  description: item.notes
464
640
  }),
465
- category: statusLabel(item.status)
641
+ category: categoryTitle(backlog.categories, item.status)
466
642
  }))
467
643
  }).finally(() => setOpen(false));
468
644
  if (!id) return;
@@ -472,7 +648,7 @@ async function browseBacklogWithState(context, setOpen, action = () => "details"
472
648
  if (selectedAction === "status") return changeTaskStatus(context, item);
473
649
  if (selectedAction === "edit") return editBacklogItem(context, item);
474
650
  if (selectedAction === "delete") return removeBacklogItem(context, item);
475
- showTaskDetails(context, item);
651
+ showTaskDetails(context, item, backlog.categories);
476
652
  }
477
653
  function Commands(props) {
478
654
  const [browseOpen, setBrowseOpen] = createSignal(false);
@@ -492,7 +668,7 @@ function Commands(props) {
492
668
  commands: [{
493
669
  id: "backlog.browse",
494
670
  title: "Browse backlog",
495
- description: "View backlog tasks and change their status",
671
+ description: "View backlog tasks and change their category",
496
672
  group: "Backlog",
497
673
  palette: true,
498
674
  slash: {
@@ -506,7 +682,7 @@ function Commands(props) {
506
682
  }, {
507
683
  id: "backlog.add",
508
684
  title: "Add backlog task",
509
- description: "Create a Todo task at the top of the backlog",
685
+ description: "Create a task at the top of the first category",
510
686
  group: "Backlog",
511
687
  palette: true,
512
688
  slash: {
@@ -517,7 +693,7 @@ function Commands(props) {
517
693
  }, {
518
694
  id: "backlog.move",
519
695
  title: "Move backlog task",
520
- description: "Change a backlog task state",
696
+ description: "Change a backlog task category",
521
697
  group: "Backlog",
522
698
  palette: true,
523
699
  slash: {
@@ -525,6 +701,26 @@ function Commands(props) {
525
701
  aliases: ["task-move"]
526
702
  },
527
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))
528
724
  }]
529
725
  }));
530
726
  props.context.keymap.layer(() => ({
@@ -539,9 +735,17 @@ function Commands(props) {
539
735
  setBrowseOpen(false);
540
736
  return run(() => addBacklogItem(props.context));
541
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
+ }
542
746
  }, {
543
747
  bind: "c",
544
- title: "Change selected task status",
748
+ title: "Change selected task category",
545
749
  group: "Backlog",
546
750
  run() {
547
751
  setBrowseAction("status");