opencode-backlog 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -11,12 +11,17 @@ 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, CATEGORY_COLORS, EMPTY_BACKLOG, moveItem, moveCategory, purgeCategory, removeCategory, renameCategory, setCategoryColor } 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;
18
+ }
19
+ function categoryColorName(category) {
20
+ if (category.color) return category.color;
21
+ if (category.id === "todo") return "subdued";
22
+ if (category.id === "doing") return "warning";
23
+ if (category.id === "done") return "success";
24
+ return "info";
20
25
  }
21
26
  function readSnapshot(directory) {
22
27
  try {
@@ -48,11 +53,6 @@ function BacklogView(props) {
48
53
  clearTimeout(refresh);
49
54
  watcher.close();
50
55
  });
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
56
  return (() => {
57
57
  var _el$ = _$createElement("box"),
58
58
  _el$2 = _$createElement("text"),
@@ -83,93 +83,81 @@ function BacklogView(props) {
83
83
  }
84
84
  }), null);
85
85
  _$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
- });
86
+ get each() {
87
+ return backlog().categories;
88
+ },
89
+ children: category => {
90
+ const items = createMemo(() => backlog().items.filter(item => item.status === category.id));
91
+ const color = () => {
92
+ const name = categoryColorName(category);
93
+ if (name === "default") return theme.text.default;
94
+ if (name === "subdued") return theme.text.subdued;
95
+ return theme.text.feedback[name].default;
96
+ };
97
+ return (() => {
98
+ var _el$8 = _$createElement("box"),
99
+ _el$9 = _$createElement("text"),
100
+ _el$0 = _$createElement("b"),
101
+ _el$1 = _$createTextNode(` (`),
102
+ _el$10 = _$createTextNode(`)`);
103
+ _$insertNode(_el$8, _el$9);
104
+ _$setProp(_el$8, "marginTop", 1);
105
+ _$insertNode(_el$9, _el$0);
106
+ _$insertNode(_el$9, _el$1);
107
+ _$insertNode(_el$9, _el$10);
108
+ _$insert(_el$0, () => category.title);
109
+ _$insert(_el$9, () => items().length, _el$10);
110
+ _$insert(_el$8, _$createComponent(For, {
111
+ get each() {
112
+ return items();
113
+ },
114
+ children: item => (() => {
115
+ var _el$11 = _$createElement("box"),
116
+ _el$12 = _$createElement("text");
117
+ _$insertNode(_el$11, _el$12);
118
+ _$setProp(_el$11, "minWidth", 0);
119
+ _$setProp(_el$11, "onMouseUp", () => void showTaskDetails(props.context, item, backlog().categories));
120
+ _$setProp(_el$12, "wrapMode", "none");
121
+ _$setProp(_el$12, "truncate", true);
122
+ _$setProp(_el$12, "flexGrow", 1);
123
+ _$setProp(_el$12, "minWidth", 0);
124
+ _$insert(_el$12, () => item.title);
125
+ _$effect(_$p => _$setProp(_el$12, "fg", theme.text.default, _$p));
126
+ return _el$11;
127
+ })()
128
+ }), null);
129
+ _$effect(_$p => _$setProp(_el$9, "fg", color(), _$p));
130
+ return _el$8;
131
+ })();
144
132
  }
145
133
  }), null);
146
134
  _$effect(_$p => _$setProp(_el$2, "fg", theme.text.default, _$p));
147
135
  return _el$;
148
136
  })();
149
137
  }
150
- function taskDetails(item) {
151
- return [`Status: ${statusLabel(item.status)}`, `ID: ${item.id}`, "", item.notes ?? "No notes"].join("\n");
138
+ function taskDetails(item, categories) {
139
+ return [`Category: ${categoryTitle(categories, item.status)}`, `ID: ${item.id}`, "", item.notes ?? "No notes"].join("\n");
152
140
  }
153
141
  function TaskAction(props) {
154
142
  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);
143
+ var _el$13 = _$createElement("text"),
144
+ _el$14 = _$createElement("b"),
145
+ _el$15 = _$createTextNode(` `);
146
+ _$insertNode(_el$13, _el$14);
147
+ _$insertNode(_el$13, _el$15);
148
+ _$insert(_el$14, () => props.shortcut);
149
+ _$insert(_el$13, () => props.label, null);
162
150
  _$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));
151
+ var _v$ = props.danger ? props.context.theme.text.feedback.error.default : props.context.theme.text.subdued,
152
+ _v$2 = props.run;
153
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$13, "fg", _v$, _p$.e));
154
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$13, "onMouseUp", _v$2, _p$.t));
167
155
  return _p$;
168
156
  }, {
169
157
  e: undefined,
170
158
  t: undefined
171
159
  });
172
- return _el$14;
160
+ return _el$13;
173
161
  })();
174
162
  }
175
163
  function TaskDetailsDialog(props) {
@@ -207,37 +195,37 @@ function TaskDetailsDialog(props) {
207
195
  }]
208
196
  }));
209
197
  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");
198
+ var _el$16 = _$createElement("box"),
199
+ _el$17 = _$createElement("box"),
200
+ _el$18 = _$createElement("text"),
201
+ _el$19 = _$createElement("b"),
202
+ _el$20 = _$createElement("text"),
203
+ _el$22 = _$createElement("text"),
204
+ _el$23 = _$createElement("box");
205
+ _$insertNode(_el$16, _el$17);
206
+ _$insertNode(_el$16, _el$22);
207
+ _$insertNode(_el$16, _el$23);
208
+ _$setProp(_el$16, "paddingLeft", 2);
209
+ _$setProp(_el$16, "paddingRight", 2);
210
+ _$setProp(_el$16, "gap", 1);
217
211
  _$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);
212
+ _$insertNode(_el$17, _el$20);
213
+ _$setProp(_el$17, "flexDirection", "row");
214
+ _$setProp(_el$17, "justifyContent", "space-between");
215
+ _$setProp(_el$17, "gap", 2);
223
216
  _$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, {
217
+ _$setProp(_el$18, "wrapMode", "word");
218
+ _$setProp(_el$18, "flexGrow", 1);
219
+ _$insert(_el$19, () => props.item.title);
220
+ _$insertNode(_el$20, _$createTextNode(`esc`));
221
+ _$setProp(_el$20, "onMouseUp", () => props.context.ui.dialog.clear());
222
+ _$setProp(_el$22, "wrapMode", "word");
223
+ _$insert(_el$22, () => taskDetails(props.item, props.categories));
224
+ _$setProp(_el$23, "flexDirection", "row");
225
+ _$setProp(_el$23, "justifyContent", "flex-end");
226
+ _$setProp(_el$23, "gap", 2);
227
+ _$setProp(_el$23, "paddingBottom", 1);
228
+ _$insert(_el$23, _$createComponent(TaskAction, {
241
229
  get context() {
242
230
  return props.context;
243
231
  },
@@ -245,7 +233,7 @@ function TaskDetailsDialog(props) {
245
233
  label: "status",
246
234
  run: changeStatus
247
235
  }), null);
248
- _$insert(_el$24, _$createComponent(TaskAction, {
236
+ _$insert(_el$23, _$createComponent(TaskAction, {
249
237
  get context() {
250
238
  return props.context;
251
239
  },
@@ -253,7 +241,7 @@ function TaskDetailsDialog(props) {
253
241
  label: "edit",
254
242
  run: edit
255
243
  }), null);
256
- _$insert(_el$24, _$createComponent(TaskAction, {
244
+ _$insert(_el$23, _$createComponent(TaskAction, {
257
245
  get context() {
258
246
  return props.context;
259
247
  },
@@ -263,25 +251,26 @@ function TaskDetailsDialog(props) {
263
251
  run: remove
264
252
  }), null);
265
253
  _$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));
254
+ var _v$3 = props.context.theme.text.default,
255
+ _v$4 = props.context.theme.text.subdued,
256
+ _v$5 = props.context.theme.text.subdued;
257
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$18, "fg", _v$3, _p$.e));
258
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$4, _p$.t));
259
+ _v$5 !== _p$.a && (_p$.a = _$setProp(_el$22, "fg", _v$5, _p$.a));
272
260
  return _p$;
273
261
  }, {
274
262
  e: undefined,
275
263
  t: undefined,
276
264
  a: undefined
277
265
  });
278
- return _el$17;
266
+ return _el$16;
279
267
  })();
280
268
  }
281
- function showTaskDetails(context, item) {
269
+ function showTaskDetails(context, item, categories) {
282
270
  context.ui.dialog.show(() => _$createComponent(TaskDetailsDialog, {
283
271
  context: context,
284
- item: item
272
+ item: item,
273
+ categories: categories
285
274
  }));
286
275
  }
287
276
  async function browseBacklog(context) {
@@ -297,6 +286,9 @@ function backlogLocation(context) {
297
286
  };
298
287
  }
299
288
  async function addBacklogItem(context) {
289
+ const {
290
+ path
291
+ } = backlogLocation(context);
300
292
  const title = await context.ui.dialog.prompt({
301
293
  title: "New backlog task",
302
294
  placeholder: "Task title"
@@ -308,48 +300,52 @@ async function addBacklogItem(context) {
308
300
  placeholder: "Leave empty for no notes"
309
301
  });
310
302
  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
- }));
303
+ let item;
304
+ await updateBacklog(path, current => {
305
+ const firstCategory = current.categories[0];
306
+ if (!firstCategory) throw new Error("Add a backlog category before adding a task");
307
+ item = {
308
+ id: randomUUID(),
309
+ title: title.trim(),
310
+ ...(notes.trim() ? {
311
+ notes: notes.trim()
312
+ } : {}),
313
+ status: firstCategory.id
314
+ };
315
+ return {
316
+ ...current,
317
+ items: moveItem([...current.items, item], item.id, firstCategory.id, 0, current.categories)
318
+ };
319
+ });
320
+ if (!item) throw new Error("Backlog update did not add a task");
326
321
  context.ui.toast.show({
327
322
  message: `Added "${item.title}".`,
328
323
  variant: "success"
329
324
  });
330
325
  }
331
326
  async function changeTaskStatus(context, item) {
327
+ const {
328
+ path
329
+ } = backlogLocation(context);
330
+ const backlog = await readBacklog(path);
332
331
  const status = await context.ui.dialog.select({
333
- title: `Change status: ${item.title}`,
332
+ title: `Change category: ${item.title}`,
334
333
  current: item.status,
335
- options: STATUSES.map(candidate => ({
336
- title: statusLabel(candidate),
337
- value: candidate,
338
- ...(candidate === item.status ? {
339
- description: "Current status"
334
+ options: backlog.categories.map(category => ({
335
+ title: category.title,
336
+ value: category.id,
337
+ ...(category.id === item.status ? {
338
+ description: "Current category"
340
339
  } : {})
341
340
  }))
342
341
  });
343
342
  if (!status || status === item.status) return;
344
- const {
345
- path
346
- } = backlogLocation(context);
347
343
  await updateBacklog(path, current => ({
348
- version: 1,
349
- items: moveItem(current.items, item.id, status, undefined)
344
+ ...current,
345
+ items: moveItem(current.items, item.id, status, undefined, current.categories)
350
346
  }));
351
347
  context.ui.toast.show({
352
- message: `Moved "${item.title}" to ${statusLabel(status)}.`,
348
+ message: `Moved "${item.title}" to ${categoryTitle(backlog.categories, status)}.`,
353
349
  variant: "success"
354
350
  });
355
351
  }
@@ -371,7 +367,7 @@ async function editBacklogItem(context, item) {
371
367
  path
372
368
  } = backlogLocation(context);
373
369
  await updateBacklog(path, current => ({
374
- version: 1,
370
+ ...current,
375
371
  items: current.items.map(candidate => {
376
372
  if (candidate.id !== item.id) return candidate;
377
373
  return {
@@ -403,7 +399,7 @@ async function removeBacklogItem(context, item) {
403
399
  path
404
400
  } = backlogLocation(context);
405
401
  await updateBacklog(path, current => ({
406
- version: 1,
402
+ ...current,
407
403
  items: current.items.filter(candidate => candidate.id !== item.id)
408
404
  }));
409
405
  context.ui.toast.show({
@@ -411,6 +407,234 @@ async function removeBacklogItem(context, item) {
411
407
  variant: "success"
412
408
  });
413
409
  }
410
+ async function purgeConfirmedCategory(path, status, confirmedIDs) {
411
+ await updateBacklog(path, current => {
412
+ const currentIDs = current.items.filter(item => item.status === status).map(item => item.id);
413
+ const changed = currentIDs.length !== confirmedIDs.length || currentIDs.some(id => !confirmedIDs.includes(id));
414
+ if (changed) throw new Error("The category changed. Review its tasks and confirm the purge again.");
415
+ return purgeCategory(current, status);
416
+ });
417
+ }
418
+ async function purgeBacklogCategory(context) {
419
+ const {
420
+ path
421
+ } = backlogLocation(context);
422
+ const backlog = await readBacklog(path);
423
+ const status = await context.ui.dialog.select({
424
+ title: "Purge backlog category",
425
+ placeholder: "Select a category",
426
+ options: backlog.categories.map(category => {
427
+ const count = backlog.items.filter(item => item.status === category.id).length;
428
+ return {
429
+ title: category.title,
430
+ value: category.id,
431
+ description: `${count} ${count === 1 ? "task" : "tasks"}`
432
+ };
433
+ })
434
+ });
435
+ if (!status) return;
436
+ const category = backlog.categories.find(candidate => candidate.id === status);
437
+ if (!category) return;
438
+ const taskIDs = backlog.items.filter(item => item.status === status).map(item => item.id);
439
+ const count = taskIDs.length;
440
+ const confirmed = await context.ui.dialog.confirm({
441
+ title: `Purge ${category.title}`,
442
+ message: `Permanently delete ${count} ${count === 1 ? "task" : "tasks"} from this category?`,
443
+ label: {
444
+ confirm: "Purge",
445
+ cancel: "Cancel"
446
+ }
447
+ });
448
+ if (!confirmed) return;
449
+ await purgeConfirmedCategory(path, status, taskIDs);
450
+ context.ui.toast.show({
451
+ message: `Purged ${count} ${count === 1 ? "task" : "tasks"} from ${category.title}.`,
452
+ variant: "success"
453
+ });
454
+ }
455
+ async function addBacklogCategory(context) {
456
+ const title = await context.ui.dialog.prompt({
457
+ title: "New backlog category",
458
+ placeholder: "Category title"
459
+ });
460
+ if (!title?.trim()) return;
461
+ const suggestedID = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
462
+ const id = await context.ui.dialog.prompt({
463
+ title: title.trim(),
464
+ description: "Stable category ID",
465
+ placeholder: "Category ID",
466
+ value: suggestedID
467
+ });
468
+ if (!id?.trim()) return;
469
+ const color = await context.ui.dialog.select({
470
+ title: title.trim(),
471
+ placeholder: "Category color",
472
+ current: "info",
473
+ options: CATEGORY_COLORS.map(value => ({
474
+ title: value,
475
+ value
476
+ }))
477
+ });
478
+ if (!color) return;
479
+ const category = {
480
+ id: id.trim(),
481
+ title: title.trim(),
482
+ color
483
+ };
484
+ const {
485
+ path
486
+ } = backlogLocation(context);
487
+ await updateBacklog(path, current => addCategory(current, category));
488
+ context.ui.toast.show({
489
+ message: `Added category "${category.title}".`,
490
+ variant: "success"
491
+ });
492
+ }
493
+ async function renameBacklogCategory(context, category) {
494
+ const title = await context.ui.dialog.prompt({
495
+ title: "Rename backlog category",
496
+ placeholder: "Category title",
497
+ value: category.title
498
+ });
499
+ if (!title?.trim() || title.trim() === category.title) return;
500
+ const {
501
+ path
502
+ } = backlogLocation(context);
503
+ await updateBacklog(path, current => renameCategory(current, category.id, title.trim()));
504
+ context.ui.toast.show({
505
+ message: `Renamed category to "${title.trim()}".`,
506
+ variant: "success"
507
+ });
508
+ }
509
+ async function colorBacklogCategory(context, category) {
510
+ const color = await context.ui.dialog.select({
511
+ title: `Color: ${category.title}`,
512
+ placeholder: "Category color",
513
+ current: categoryColorName(category),
514
+ options: CATEGORY_COLORS.map(value => ({
515
+ title: value,
516
+ value
517
+ }))
518
+ });
519
+ if (!color || color === categoryColorName(category)) return;
520
+ const {
521
+ path
522
+ } = backlogLocation(context);
523
+ await updateBacklog(path, current => setCategoryColor(current, category.id, color));
524
+ context.ui.toast.show({
525
+ message: `Updated color for "${category.title}".`,
526
+ variant: "success"
527
+ });
528
+ }
529
+ async function moveBacklogCategory(context, category) {
530
+ const {
531
+ path
532
+ } = backlogLocation(context);
533
+ const backlog = await readBacklog(path);
534
+ const position = await context.ui.dialog.select({
535
+ title: `Move ${category.title}`,
536
+ placeholder: "Select a position",
537
+ options: backlog.categories.map((candidate, index) => ({
538
+ title: `${index + 1}. ${candidate.title}`,
539
+ value: index,
540
+ ...(candidate.id === category.id ? {
541
+ description: "Current position"
542
+ } : {})
543
+ }))
544
+ });
545
+ if (position === undefined || backlog.categories[position]?.id === category.id) return;
546
+ await updateBacklog(path, current => moveCategory(current, category.id, position));
547
+ context.ui.toast.show({
548
+ message: `Moved category "${category.title}".`,
549
+ variant: "success"
550
+ });
551
+ }
552
+ async function deleteBacklogCategory(context, category) {
553
+ const confirmed = await context.ui.dialog.confirm({
554
+ title: "Delete backlog category",
555
+ message: category.title,
556
+ label: {
557
+ confirm: "Delete",
558
+ cancel: "Cancel"
559
+ }
560
+ });
561
+ if (!confirmed) return;
562
+ const {
563
+ path
564
+ } = backlogLocation(context);
565
+ await updateBacklog(path, current => removeCategory(current, category.id));
566
+ context.ui.toast.show({
567
+ message: `Deleted category "${category.title}".`,
568
+ variant: "success"
569
+ });
570
+ }
571
+ async function manageBacklogCategories(context) {
572
+ const {
573
+ path
574
+ } = backlogLocation(context);
575
+ const backlog = await readBacklog(path);
576
+ let addSelection = `__add:${randomUUID()}`;
577
+ while (backlog.categories.some(category => category.id === addSelection)) {
578
+ addSelection = `__add:${randomUUID()}`;
579
+ }
580
+ const selection = await context.ui.dialog.select({
581
+ title: "Backlog categories",
582
+ placeholder: "Add or select a category",
583
+ options: [{
584
+ title: "Add category",
585
+ value: addSelection
586
+ }, ...backlog.categories.map(category => ({
587
+ title: category.title,
588
+ value: category.id,
589
+ description: `${category.id} - ${backlog.items.filter(item => item.status === category.id).length} tasks`
590
+ }))]
591
+ });
592
+ if (!selection) return;
593
+ if (selection === addSelection) return addBacklogCategory(context);
594
+ const category = backlog.categories.find(candidate => candidate.id === selection);
595
+ if (!category) return;
596
+ const taskIDs = backlog.items.filter(item => item.status === category.id).map(item => item.id);
597
+ const count = taskIDs.length;
598
+ const action = await context.ui.dialog.select({
599
+ title: category.title,
600
+ placeholder: "Select an action",
601
+ options: [{
602
+ title: "Rename",
603
+ value: "rename"
604
+ }, {
605
+ title: "Change color",
606
+ value: "color"
607
+ }, {
608
+ title: "Move",
609
+ value: "move"
610
+ }, ...(count === 0 ? [{
611
+ title: "Delete empty category",
612
+ value: "delete"
613
+ }] : [{
614
+ title: `Purge category and ${count} ${count === 1 ? "task" : "tasks"}`,
615
+ value: "purge"
616
+ }])]
617
+ });
618
+ if (action === "rename") return renameBacklogCategory(context, category);
619
+ if (action === "color") return colorBacklogCategory(context, category);
620
+ if (action === "move") return moveBacklogCategory(context, category);
621
+ if (action === "delete") return deleteBacklogCategory(context, category);
622
+ if (action !== "purge") return;
623
+ const confirmed = await context.ui.dialog.confirm({
624
+ title: `Purge ${category.title}`,
625
+ message: `Permanently delete ${count} ${count === 1 ? "task" : "tasks"} from this category?`,
626
+ label: {
627
+ confirm: "Purge",
628
+ cancel: "Cancel"
629
+ }
630
+ });
631
+ if (!confirmed) return;
632
+ await purgeConfirmedCategory(path, category.id, taskIDs);
633
+ context.ui.toast.show({
634
+ message: `Purged category "${category.title}".`,
635
+ variant: "success"
636
+ });
637
+ }
414
638
  async function moveBacklogItem(context) {
415
639
  const {
416
640
  path
@@ -432,7 +656,7 @@ async function moveBacklogItem(context) {
432
656
  ...(item.notes === undefined ? {} : {
433
657
  description: item.notes
434
658
  }),
435
- category: statusLabel(item.status)
659
+ category: categoryTitle(backlog.categories, item.status)
436
660
  }))
437
661
  });
438
662
  if (!id) return;
@@ -462,7 +686,7 @@ async function browseBacklogWithState(context, setOpen, action = () => "details"
462
686
  ...(item.notes === undefined ? {} : {
463
687
  description: item.notes
464
688
  }),
465
- category: statusLabel(item.status)
689
+ category: categoryTitle(backlog.categories, item.status)
466
690
  }))
467
691
  }).finally(() => setOpen(false));
468
692
  if (!id) return;
@@ -472,7 +696,7 @@ async function browseBacklogWithState(context, setOpen, action = () => "details"
472
696
  if (selectedAction === "status") return changeTaskStatus(context, item);
473
697
  if (selectedAction === "edit") return editBacklogItem(context, item);
474
698
  if (selectedAction === "delete") return removeBacklogItem(context, item);
475
- showTaskDetails(context, item);
699
+ showTaskDetails(context, item, backlog.categories);
476
700
  }
477
701
  function Commands(props) {
478
702
  const [browseOpen, setBrowseOpen] = createSignal(false);
@@ -492,7 +716,7 @@ function Commands(props) {
492
716
  commands: [{
493
717
  id: "backlog.browse",
494
718
  title: "Browse backlog",
495
- description: "View backlog tasks and change their status",
719
+ description: "View backlog tasks and change their category",
496
720
  group: "Backlog",
497
721
  palette: true,
498
722
  slash: {
@@ -506,7 +730,7 @@ function Commands(props) {
506
730
  }, {
507
731
  id: "backlog.add",
508
732
  title: "Add backlog task",
509
- description: "Create a Todo task at the top of the backlog",
733
+ description: "Create a task at the top of the first category",
510
734
  group: "Backlog",
511
735
  palette: true,
512
736
  slash: {
@@ -517,7 +741,7 @@ function Commands(props) {
517
741
  }, {
518
742
  id: "backlog.move",
519
743
  title: "Move backlog task",
520
- description: "Change a backlog task state",
744
+ description: "Change a backlog task category",
521
745
  group: "Backlog",
522
746
  palette: true,
523
747
  slash: {
@@ -525,6 +749,26 @@ function Commands(props) {
525
749
  aliases: ["task-move"]
526
750
  },
527
751
  run: () => run(() => moveBacklogItem(props.context))
752
+ }, {
753
+ id: "backlog.purge",
754
+ title: "Purge backlog category",
755
+ description: "Permanently delete every task in a category",
756
+ group: "Backlog",
757
+ palette: true,
758
+ slash: {
759
+ name: "backlog-purge"
760
+ },
761
+ run: () => run(() => purgeBacklogCategory(props.context))
762
+ }, {
763
+ id: "backlog.categories",
764
+ title: "Manage backlog categories",
765
+ description: "Add, rename, move, purge, or delete a category",
766
+ group: "Backlog",
767
+ palette: true,
768
+ slash: {
769
+ name: "backlog-categories"
770
+ },
771
+ run: () => run(() => manageBacklogCategories(props.context))
528
772
  }]
529
773
  }));
530
774
  props.context.keymap.layer(() => ({
@@ -539,9 +783,17 @@ function Commands(props) {
539
783
  setBrowseOpen(false);
540
784
  return run(() => addBacklogItem(props.context));
541
785
  }
786
+ }, {
787
+ bind: "p",
788
+ title: "Purge backlog category",
789
+ group: "Backlog",
790
+ run() {
791
+ setBrowseOpen(false);
792
+ return run(() => purgeBacklogCategory(props.context));
793
+ }
542
794
  }, {
543
795
  bind: "c",
544
- title: "Change selected task status",
796
+ title: "Change selected task category",
545
797
  group: "Backlog",
546
798
  run() {
547
799
  setBrowseAction("status");