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/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, STATUSES, moveItem } from "./backlog.js";
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 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,71 +76,189 @@ 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$12, "flexShrink", 0);
120
- _$insert(_el$12, status === "done" ? "✓" : status === "doing" ? "●" : "○");
121
- _$setProp(_el$13, "wrapMode", "none");
122
- _$setProp(_el$13, "truncate", true);
123
- _$setProp(_el$13, "flexGrow", 1);
124
- _$setProp(_el$13, "minWidth", 0);
125
- _$insert(_el$13, () => item.title);
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 [`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");
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
- const item = {
177
- id: randomUUID(),
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
- ...(notes.trim() ? {
180
- notes: notes.trim()
181
- } : {}),
182
- status: "todo"
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
- version: 1,
189
- items: moveItem([...current.items, item], item.id, "todo", 0)
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: `Added "${item.title}".`,
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: statusLabel(item.status)
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
- const status = await context.ui.dialog.select({
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 (n: new task)",
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: statusLabel(item.status)
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
- await context.ui.dialog.alert({
273
- title: item.title,
274
- message: taskDetails(item)
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 status",
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: () => run(() => browseBacklogWithState(props.context, setBrowseOpen))
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 Todo task at the top of the backlog",
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 state",
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;