@webappwiz/arbor 0.0.28 → 0.0.30

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.
Files changed (119) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +34 -12
  3. package/add.test.ts +125 -0
  4. package/add.ts +192 -0
  5. package/age.ts +11 -0
  6. package/arbor.test.ts +145 -0
  7. package/arbor.ts +429 -0
  8. package/assets.d.ts +9 -0
  9. package/attachments.ts +87 -0
  10. package/build.ts +98 -0
  11. package/claim.test.ts +89 -0
  12. package/claim.ts +73 -0
  13. package/components.json +21 -0
  14. package/config.ts +58 -0
  15. package/dev/api.ts +120 -0
  16. package/dev/app.test.tsx +645 -0
  17. package/dev/app.tsx +80 -0
  18. package/dev/assets.ts +16 -0
  19. package/dev/build/main.txt +68 -0
  20. package/dev/build/shell.txt +21 -0
  21. package/dev/build/styles.txt +2263 -0
  22. package/dev/components/ui/badge.tsx +51 -0
  23. package/dev/components/ui/button.tsx +57 -0
  24. package/dev/components/ui/dialog.tsx +154 -0
  25. package/dev/components/ui/empty.tsx +100 -0
  26. package/dev/components/ui/input-group.tsx +155 -0
  27. package/dev/components/ui/input.tsx +19 -0
  28. package/dev/components/ui/progress.tsx +80 -0
  29. package/dev/components/ui/textarea.tsx +17 -0
  30. package/dev/components/ui/toast.tsx +227 -0
  31. package/dev/feed.ts +69 -0
  32. package/dev/files.tsx +191 -0
  33. package/dev/index.html +21 -0
  34. package/dev/lib/utils.ts +7 -0
  35. package/dev/main.tsx +22 -0
  36. package/dev/markdown.test.tsx +133 -0
  37. package/dev/markdown.tsx +124 -0
  38. package/dev/mentions.tsx +319 -0
  39. package/dev/shadcn.css +649 -0
  40. package/dev/styles.css +107 -0
  41. package/dev/tags.tsx +44 -0
  42. package/dev/tasks.tsx +151 -0
  43. package/dev/todos.tsx +759 -0
  44. package/dev.test.ts +439 -0
  45. package/dev.ts +386 -0
  46. package/e2e.test.ts +256 -0
  47. package/escalate.test.ts +76 -0
  48. package/escalate.ts +112 -0
  49. package/exit.test.ts +71 -0
  50. package/exit.ts +74 -0
  51. package/git.ts +294 -0
  52. package/index.ts +9 -0
  53. package/journal.ts +89 -0
  54. package/list.test.ts +82 -0
  55. package/list.ts +137 -0
  56. package/load-config.test.ts +121 -0
  57. package/load-config.ts +62 -0
  58. package/log.test.ts +65 -0
  59. package/log.ts +40 -0
  60. package/merge.test.ts +332 -0
  61. package/merge.ts +283 -0
  62. package/package.json +32 -15
  63. package/path.test.ts +35 -0
  64. package/path.ts +38 -0
  65. package/plan.test.ts +318 -0
  66. package/plan.ts +302 -0
  67. package/progress.test.ts +54 -0
  68. package/progress.ts +31 -0
  69. package/remove.test.ts +68 -0
  70. package/remove.ts +88 -0
  71. package/repository.ts +83 -0
  72. package/retry.test.ts +43 -0
  73. package/retry.ts +48 -0
  74. package/shell.ts +44 -0
  75. package/show.test.ts +91 -0
  76. package/show.ts +151 -0
  77. package/snapshot.test.ts +33 -0
  78. package/snapshot.ts +84 -0
  79. package/table.ts +20 -0
  80. package/testing.ts +229 -0
  81. package/todo.test.ts +490 -0
  82. package/todo.ts +834 -0
  83. package/wait.test.ts +61 -0
  84. package/wait.ts +77 -0
  85. package/worktree-service.test.ts +158 -0
  86. package/worktree-service.ts +193 -0
  87. package/worktree.ts +239 -0
  88. package/add.d.ts +0 -23
  89. package/age.d.ts +0 -2
  90. package/arbor.d.ts +0 -11
  91. package/attachments.d.ts +0 -36
  92. package/claim.d.ts +0 -11
  93. package/config.d.ts +0 -55
  94. package/config.js +0 -7
  95. package/dev/assets.d.ts +0 -11
  96. package/dev.d.ts +0 -47
  97. package/escalate.d.ts +0 -28
  98. package/exit.d.ts +0 -42
  99. package/git.d.ts +0 -77
  100. package/index.d.ts +0 -2
  101. package/index.js +0 -4669
  102. package/journal.d.ts +0 -29
  103. package/list.d.ts +0 -18
  104. package/load-config.d.ts +0 -15
  105. package/log.d.ts +0 -14
  106. package/merge.d.ts +0 -24
  107. package/path.d.ts +0 -13
  108. package/plan.d.ts +0 -83
  109. package/remove.d.ts +0 -19
  110. package/repository.d.ts +0 -25
  111. package/retry.d.ts +0 -17
  112. package/shell.d.ts +0 -17
  113. package/show.d.ts +0 -53
  114. package/snapshot.d.ts +0 -31
  115. package/table.d.ts +0 -5
  116. package/todo.d.ts +0 -230
  117. package/wait.d.ts +0 -22
  118. package/worktree-service.d.ts +0 -51
  119. package/worktree.d.ts +0 -95
package/dev/todos.tsx ADDED
@@ -0,0 +1,759 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ DragOverlay,
6
+ KeyboardSensor,
7
+ MouseSensor,
8
+ TouchSensor,
9
+ useSensor,
10
+ useSensors,
11
+ } from "@dnd-kit/core";
12
+ import {
13
+ restrictToParentElement,
14
+ restrictToVerticalAxis,
15
+ } from "@dnd-kit/modifiers";
16
+ import {
17
+ arrayMove,
18
+ SortableContext,
19
+ sortableKeyboardCoordinates,
20
+ useSortable,
21
+ verticalListSortingStrategy,
22
+ } from "@dnd-kit/sortable";
23
+ import { CSS } from "@dnd-kit/utilities";
24
+ import {
25
+ ClockIcon,
26
+ GitBranchIcon,
27
+ GripVerticalIcon,
28
+ LinkIcon,
29
+ ListTodoIcon,
30
+ type LucideIcon,
31
+ PaperclipIcon,
32
+ TagIcon,
33
+ Trash2Icon,
34
+ } from "lucide-react";
35
+ import {
36
+ createContext,
37
+ type FormEvent,
38
+ type HTMLAttributes,
39
+ type JSX,
40
+ type KeyboardEventHandler,
41
+ type MouseEventHandler,
42
+ type ReactNode,
43
+ type TouchEventHandler,
44
+ useContext,
45
+ useEffect,
46
+ useId,
47
+ useState,
48
+ } from "react";
49
+ import { Button } from "#dev/components/ui/button.tsx";
50
+ import {
51
+ Dialog,
52
+ DialogContent,
53
+ DialogDescription,
54
+ DialogFooter,
55
+ DialogHeader,
56
+ DialogTitle,
57
+ } from "#dev/components/ui/dialog.tsx";
58
+ import {
59
+ Empty,
60
+ EmptyDescription,
61
+ EmptyHeader,
62
+ EmptyMedia,
63
+ EmptyTitle,
64
+ } from "#dev/components/ui/empty.tsx";
65
+ import {
66
+ InputGroup,
67
+ InputGroupAddon,
68
+ InputGroupButton,
69
+ InputGroupInput,
70
+ InputGroupTextarea,
71
+ } from "#dev/components/ui/input-group.tsx";
72
+ import { toast } from "#dev/components/ui/toast.tsx";
73
+ import { cn } from "#dev/lib/utils.ts";
74
+ import { age } from "../age";
75
+ import type { Snapshot } from "../snapshot";
76
+ import type { TodoState } from "../todo";
77
+ import { addTodo, moveTodo, removeTodo, updateTodo } from "./api";
78
+ import { AttachButton, FileList, useFiles } from "./files";
79
+ import { Markdown } from "./markdown";
80
+ import { MentionAnchor, useMentions } from "./mentions";
81
+ import { TagFilter } from "./tags";
82
+ import { Task } from "./tasks";
83
+
84
+ /**
85
+ * Work deferred for later, top of the list first, and a line to add to it.
86
+ * Picking one up takes an agent (`arbor add <task> --todo <id>`), and `merge`
87
+ * recommends the highest open one, so the list is the priority: drag a card
88
+ * to reorder it, or tap one to reword or remove it.
89
+ */
90
+ export function Todos({ snapshot }: { snapshot: Snapshot }): JSX.Element {
91
+ const { todoStalenessMs, tasks } = snapshot;
92
+ const [picked, setPicked] = useState<string | null>(null);
93
+ const tags = [...new Set(snapshot.todos.flatMap((todo) => todo.tags))].sort();
94
+ // A tag whose last todo went has nothing left to show, and lets go.
95
+ const tag = picked !== null && tags.includes(picked) ? picked : null;
96
+ const todos = snapshot.todos.filter(
97
+ (todo) => tag === null || todo.tags.includes(tag),
98
+ );
99
+ const [opened, setOpened] = useState<number | null>(null);
100
+ const current = todos.find((todo) => todo.id === opened);
101
+ const [openedTask, setOpenedTask] = useState<string | null>(null);
102
+ const task = tasks.find((found) => found.task === openedTask);
103
+ // Only a task the page knows of opens: one merged a moment ago has no
104
+ // details left to show.
105
+ const openTask = (name: string) =>
106
+ tasks.some((found) => found.task === name) ? setOpenedTask(name) : null;
107
+ return (
108
+ <OpenTask value={openTask}>
109
+ <FilterTag value={setPicked}>
110
+ <div className="flex flex-col gap-6">
111
+ <Add />
112
+ {tags.length > 0 && (
113
+ <TagFilter tags={tags} selected={tag} onSelect={setPicked} />
114
+ )}
115
+ {todos.length === 0 ? (
116
+ <Empty>
117
+ <EmptyHeader>
118
+ <EmptyMedia variant="icon">
119
+ <ListTodoIcon />
120
+ </EmptyMedia>
121
+ <EmptyTitle>Nothing deferred</EmptyTitle>
122
+ <EmptyDescription>
123
+ Agents add what comes up outside their task here.
124
+ </EmptyDescription>
125
+ </EmptyHeader>
126
+ </Empty>
127
+ ) : (
128
+ <Board
129
+ todos={todos}
130
+ staleness={todoStalenessMs}
131
+ onOpen={setOpened}
132
+ />
133
+ )}
134
+ <Dialog
135
+ open={current !== undefined}
136
+ onOpenChange={(open) => {
137
+ if (!open) {
138
+ setOpened(null);
139
+ }
140
+ }}
141
+ >
142
+ {current && <Edit todo={current} onDone={() => setOpened(null)} />}
143
+ </Dialog>
144
+ <Dialog
145
+ open={task !== undefined}
146
+ onOpenChange={(open) => {
147
+ if (!open) {
148
+ setOpenedTask(null);
149
+ }
150
+ }}
151
+ >
152
+ {task && <Task task={task} />}
153
+ </Dialog>
154
+ </div>
155
+ </FilterTag>
156
+ </OpenTask>
157
+ );
158
+ }
159
+
160
+ /** Shows only one tag's todos, from a tag on any card. */
161
+ const FilterTag = createContext<(tag: string) => void>(() => {});
162
+
163
+ /** Opens the task a card names, from wherever the card is drawn. */
164
+ const OpenTask = createContext<(task: string) => void>(() => {});
165
+
166
+ /**
167
+ * The todos as cards in a column, each dragged to where it belongs. A mouse
168
+ * drags once it moves a few pixels and a finger after a short press, so a
169
+ * tap still opens the card and a swipe still scrolls; a keyboard picks one
170
+ * up by its grip with Space and moves it with the arrows.
171
+ */
172
+ function Board({
173
+ todos,
174
+ staleness,
175
+ onOpen,
176
+ }: {
177
+ todos: TodoState[];
178
+ staleness: number;
179
+ onOpen: (id: number) => void;
180
+ }): JSX.Element {
181
+ // The order just dropped, shown until the server's catches up, so a card
182
+ // lands where it was let go instead of jumping back for a poll.
183
+ const [dropped, setDropped] = useState<number[] | null>(null);
184
+ const [dragging, setDragging] = useState<number | null>(null);
185
+ // biome-ignore lint/correctness/useExhaustiveDependencies: any new snapshot is the server's word
186
+ useEffect(() => setDropped(null), [todos]);
187
+ const sensors = useSensors(
188
+ useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
189
+ useSensor(TouchSensor, {
190
+ activationConstraint: { delay: 200, tolerance: 6 },
191
+ }),
192
+ useSensor(KeyboardSensor, {
193
+ coordinateGetter: sortableKeyboardCoordinates,
194
+ }),
195
+ );
196
+ const ids = dropped ?? todos.map((todo) => todo.id);
197
+ const shown = ids.flatMap((id) => todos.filter((todo) => todo.id === id));
198
+ const lifted = todos.find((todo) => todo.id === dragging);
199
+
200
+ const drop = ({ active, over }: DragEndEvent) => {
201
+ setDragging(null);
202
+ if (over === null || active.id === over.id) {
203
+ return;
204
+ }
205
+ const to = ids.indexOf(Number(over.id));
206
+ setDropped(arrayMove(ids, ids.indexOf(Number(active.id)), to));
207
+ // The place of the card it landed on, which is not its index when the
208
+ // list shows only one tag's todos.
209
+ const position =
210
+ todos.find((todo) => todo.id === Number(over.id))?.position ?? to + 1;
211
+ moveTodo(Number(active.id), position).catch((error: unknown) => {
212
+ setDropped(null);
213
+ toast.add({
214
+ title: "Not moved",
215
+ description: error instanceof Error ? error.message : String(error),
216
+ type: "error",
217
+ });
218
+ });
219
+ };
220
+
221
+ return (
222
+ <DndContext
223
+ sensors={sensors}
224
+ collisionDetection={closestCenter}
225
+ modifiers={[restrictToVerticalAxis, restrictToParentElement]}
226
+ onDragStart={({ active }) => setDragging(Number(active.id))}
227
+ onDragCancel={() => setDragging(null)}
228
+ onDragEnd={drop}
229
+ >
230
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
231
+ <ul aria-label="todos" className="flex flex-col gap-2">
232
+ {shown.map((todo) => (
233
+ <Sortable
234
+ key={todo.id}
235
+ todo={todo}
236
+ staleness={staleness}
237
+ onOpen={() => onOpen(todo.id)}
238
+ />
239
+ ))}
240
+ </ul>
241
+ </SortableContext>
242
+ {/* Lifted off the column, tilted, the way a card in hand looks. */}
243
+ <DragOverlay>
244
+ {lifted && (
245
+ <Card
246
+ todo={lifted}
247
+ staleness={staleness}
248
+ className="rotate-2 cursor-grabbing shadow-lg"
249
+ />
250
+ )}
251
+ </DragOverlay>
252
+ </DndContext>
253
+ );
254
+ }
255
+
256
+ /** One card in the column, holding its place while another is dragged over it. */
257
+ function Sortable({
258
+ todo,
259
+ staleness,
260
+ onOpen,
261
+ }: {
262
+ todo: TodoState;
263
+ staleness: number;
264
+ onOpen: () => void;
265
+ }): JSX.Element {
266
+ const {
267
+ attributes,
268
+ listeners,
269
+ setNodeRef,
270
+ setActivatorNodeRef,
271
+ transform,
272
+ transition,
273
+ isDragging,
274
+ } = useSortable({ id: todo.id });
275
+ return (
276
+ <li
277
+ ref={setNodeRef}
278
+ style={{ transform: CSS.Translate.toString(transform), transition }}
279
+ >
280
+ <Card
281
+ todo={todo}
282
+ staleness={staleness}
283
+ onOpen={onOpen}
284
+ // The card answers a mouse and a finger; its grip, a keyboard.
285
+ pointer={{
286
+ onMouseDown: listeners?.onMouseDown as MouseEventHandler,
287
+ onTouchStart: listeners?.onTouchStart as TouchEventHandler,
288
+ }}
289
+ grip={{
290
+ ...attributes,
291
+ onKeyDown: listeners?.onKeyDown as KeyboardEventHandler,
292
+ ref: setActivatorNodeRef,
293
+ }}
294
+ // The slot it leaves while it is in hand, where it will land.
295
+ className={cn(
296
+ isDragging &&
297
+ "border-dashed bg-muted shadow-none *:invisible hover:shadow-none",
298
+ )}
299
+ />
300
+ </li>
301
+ );
302
+ }
303
+
304
+ function Card({
305
+ todo,
306
+ staleness,
307
+ onOpen,
308
+ pointer,
309
+ grip,
310
+ className,
311
+ }: {
312
+ todo: TodoState;
313
+ staleness: number;
314
+ onOpen?: () => void;
315
+ pointer?: HTMLAttributes<HTMLElement>;
316
+ grip?: HTMLAttributes<HTMLButtonElement> & {
317
+ ref?: (element: HTMLElement | null) => void;
318
+ };
319
+ className?: string;
320
+ }): JSX.Element {
321
+ const stale =
322
+ todo.takenBy === null &&
323
+ Date.now() - Date.parse(todo.createdAt) > staleness;
324
+ return (
325
+ <div
326
+ {...pointer}
327
+ className={cn(
328
+ // A pointer, since a tap opens it; a drag from anywhere still moves
329
+ // it, and the grip says so with its own cursor.
330
+ "group relative flex cursor-pointer touch-manipulation items-stretch rounded-lg border bg-card text-card-foreground shadow-xs transition-[background-color,border-color,box-shadow] select-none hover:border-foreground/25 hover:bg-muted/70 hover:shadow-sm",
331
+ // Taken: a task is at work on it, which should read from across
332
+ // the room.
333
+ todo.takenBy && "border-l-4 border-l-success",
334
+ className,
335
+ )}
336
+ >
337
+ <div
338
+ className={cn(
339
+ "flex min-w-0 flex-1 flex-col gap-1.5 py-2.5 pl-3",
340
+ stale && "opacity-60",
341
+ )}
342
+ >
343
+ {/* Stretched over the whole card, so a tap anywhere opens it; the
344
+ buttons on top of it keep their own. */}
345
+ <button
346
+ type="button"
347
+ onClick={onOpen}
348
+ className="cursor-pointer text-left font-semibold text-sm outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
349
+ >
350
+ {todo.subject}
351
+ </button>
352
+ {todo.text !== "" && (
353
+ <Markdown
354
+ text={todo.text}
355
+ preview
356
+ className="text-muted-foreground text-xs"
357
+ />
358
+ )}
359
+ {/* Badges, the way a Trello card has them: only what there is. */}
360
+ <span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
361
+ <CopyLink id={todo.id} />
362
+ {todo.files.length > 0 && (
363
+ <Badge Icon={PaperclipIcon} label="files">
364
+ {todo.files.length}
365
+ </Badge>
366
+ )}
367
+ {todo.takenBy && <TakenBy task={todo.takenBy} />}
368
+ {todo.tags.map((tag) => (
369
+ <TagButton key={tag} tag={tag} />
370
+ ))}
371
+ {stale && (
372
+ <Badge Icon={ClockIcon} label="stale">
373
+ stale, {age(todo.createdAt)}
374
+ </Badge>
375
+ )}
376
+ </span>
377
+ </div>
378
+ <button
379
+ type="button"
380
+ {...grip}
381
+ aria-label={`move ${todo.subject}`}
382
+ className="relative flex w-8 shrink-0 cursor-grab items-center active:cursor-grabbing justify-center rounded-r-lg text-muted-foreground opacity-40 outline-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
383
+ >
384
+ <GripVerticalIcon className="size-4" />
385
+ </button>
386
+ </div>
387
+ );
388
+ }
389
+
390
+ /**
391
+ * The task that took the todo, green so it reads from across the room. A tap
392
+ * opens the task; like the copy link, it sits above the card's own button and
393
+ * never starts a drag.
394
+ */
395
+ function TakenBy({ task }: { task: string }): JSX.Element {
396
+ const openTask = useContext(OpenTask);
397
+ return (
398
+ <button
399
+ type="button"
400
+ onClick={() => openTask(task)}
401
+ onMouseDown={(event) => event.stopPropagation()}
402
+ onTouchStart={(event) => event.stopPropagation()}
403
+ className="relative inline-flex min-w-0 cursor-pointer items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 font-medium text-success outline-none hover:bg-success/25 focus-visible:ring-2 focus-visible:ring-ring"
404
+ >
405
+ <GitBranchIcon
406
+ aria-label="taken by"
407
+ role="img"
408
+ className="size-3.5 shrink-0"
409
+ />
410
+ <span className="truncate">Taken by {task}</span>
411
+ </button>
412
+ );
413
+ }
414
+
415
+ /** One of a card's tags, which shows only that tag's todos when tapped. */
416
+ function TagButton({ tag }: { tag: string }): JSX.Element {
417
+ const filter = useContext(FilterTag);
418
+ return (
419
+ <button
420
+ type="button"
421
+ aria-label={`show only ${tag}`}
422
+ onClick={() => filter(tag)}
423
+ onMouseDown={(event) => event.stopPropagation()}
424
+ onTouchStart={(event) => event.stopPropagation()}
425
+ className="relative inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
426
+ >
427
+ <TagIcon aria-hidden className="size-3" />
428
+ {tag}
429
+ </button>
430
+ );
431
+ }
432
+
433
+ /** What a chat with an agent takes to mean this todo. */
434
+ function todoReference(id: number): string {
435
+ return `[ARBOR TODO #${id}]`;
436
+ }
437
+
438
+ /**
439
+ * The card's id, which copies `todoReference` for pasting into a chat. Above
440
+ * the card's own button, and never the start of a drag.
441
+ */
442
+ function CopyLink({ id }: { id: number }): JSX.Element {
443
+ const copy = async () => {
444
+ try {
445
+ await navigator.clipboard.writeText(todoReference(id));
446
+ toast.add({ title: `Copied ${todoReference(id)}` });
447
+ } catch (error) {
448
+ toast.add({
449
+ title: "Not copied",
450
+ description: error instanceof Error ? error.message : String(error),
451
+ type: "error",
452
+ });
453
+ }
454
+ };
455
+ return (
456
+ <button
457
+ type="button"
458
+ aria-label={`copy a link to todo ${id}`}
459
+ onClick={() => void copy()}
460
+ onMouseDown={(event) => event.stopPropagation()}
461
+ onTouchStart={(event) => event.stopPropagation()}
462
+ className="relative -mx-1 inline-flex cursor-pointer items-center gap-1 rounded px-1 tabular-nums outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
463
+ >
464
+ <LinkIcon className="size-3.5" />#{id}
465
+ </button>
466
+ );
467
+ }
468
+
469
+ /** One fact about a card, after an icon that says what kind. */
470
+ function Badge({
471
+ Icon,
472
+ label,
473
+ children,
474
+ }: {
475
+ Icon: LucideIcon;
476
+ label: string;
477
+ children: ReactNode;
478
+ }): JSX.Element {
479
+ return (
480
+ <span className="inline-flex items-center gap-1" title={label}>
481
+ <Icon aria-label={label} role="img" className="size-3.5" />
482
+ {children}
483
+ </span>
484
+ );
485
+ }
486
+
487
+ function Add(): JSX.Element {
488
+ const [subject, setSubject] = useState("");
489
+ const [text, setText] = useState("");
490
+ const files = useFiles();
491
+ const mentions = useMentions<HTMLInputElement>({
492
+ task: "",
493
+ text: subject,
494
+ setText: setSubject,
495
+ });
496
+ const detail = useMentions<HTMLTextAreaElement>({ task: "", text, setText });
497
+ const [sending, setSending] = useState(false);
498
+
499
+ const submit = async (event: FormEvent) => {
500
+ event.preventDefault();
501
+ if (subject.trim() === "" || sending) {
502
+ return;
503
+ }
504
+ setSending(true);
505
+ try {
506
+ await addTodo({ subject, text }, files.files);
507
+ setSubject("");
508
+ setText("");
509
+ files.clear();
510
+ } catch (error) {
511
+ toast.add({
512
+ title: "Not added",
513
+ description: error instanceof Error ? error.message : String(error),
514
+ type: "error",
515
+ });
516
+ } finally {
517
+ setSending(false);
518
+ }
519
+ };
520
+
521
+ return (
522
+ <form onSubmit={submit} className="flex flex-col gap-2">
523
+ <MentionAnchor mentions={mentions}>
524
+ <InputGroup>
525
+ <InputGroupInput
526
+ ref={mentions.ref}
527
+ aria-label="new todo"
528
+ placeholder="Something to do later, @ for a file"
529
+ value={subject}
530
+ onChange={mentions.onChange}
531
+ onSelect={mentions.onSelect}
532
+ onClick={mentions.onClick}
533
+ onKeyDown={mentions.onKeyDown}
534
+ onPaste={files.paste}
535
+ />
536
+ <InputGroupAddon align="inline-end">
537
+ <AttachButton files={files} />
538
+ <InputGroupButton
539
+ type="submit"
540
+ variant="secondary"
541
+ disabled={subject.trim() === "" || sending}
542
+ >
543
+ Add
544
+ </InputGroupButton>
545
+ </InputGroupAddon>
546
+ </InputGroup>
547
+ </MentionAnchor>
548
+ {/* Only once there is a line to add to, so the box stays one line. */}
549
+ {(subject !== "" || text !== "") && (
550
+ <MentionAnchor mentions={detail}>
551
+ <InputGroup>
552
+ <InputGroupTextarea
553
+ ref={detail.ref}
554
+ aria-label="new todo detail"
555
+ placeholder="More to say in markdown, if any"
556
+ value={text}
557
+ onChange={detail.onChange}
558
+ onSelect={detail.onSelect}
559
+ onClick={detail.onClick}
560
+ onKeyDown={detail.onKeyDown}
561
+ onPaste={files.paste}
562
+ rows={2}
563
+ />
564
+ </InputGroup>
565
+ </MentionAnchor>
566
+ )}
567
+ <FileList files={files} />
568
+ </form>
569
+ );
570
+ }
571
+
572
+ /** One todo opened: its words and files to change, and a way to drop it. */
573
+ function Edit({
574
+ todo,
575
+ onDone,
576
+ }: {
577
+ todo: TodoState;
578
+ onDone: () => void;
579
+ }): JSX.Element {
580
+ const [subject, setSubject] = useState(todo.subject);
581
+ const [text, setText] = useState(todo.text);
582
+ const [tags, setTags] = useState(todo.tags.join(", "));
583
+ const tagsHint = useId();
584
+ const tagged = tags
585
+ .split(",")
586
+ .map((tag) => tag.trim())
587
+ .filter(Boolean);
588
+ // Read first when there is something to read, the way a Trello card opens.
589
+ const [writing, setWriting] = useState(todo.text === "");
590
+ const files = useFiles(todo.files);
591
+ const mentions = useMentions<HTMLInputElement>({
592
+ task: "",
593
+ text: subject,
594
+ setText: setSubject,
595
+ });
596
+ const detail = useMentions<HTMLTextAreaElement>({ task: "", text, setText });
597
+ const [busy, setBusy] = useState(false);
598
+ // Removing cannot be taken back, so it asks twice.
599
+ const [confirming, setConfirming] = useState(false);
600
+
601
+ const run = async (write: () => Promise<void>, failed: string) => {
602
+ setBusy(true);
603
+ try {
604
+ await write();
605
+ onDone();
606
+ } catch (error) {
607
+ toast.add({
608
+ title: failed,
609
+ description: error instanceof Error ? error.message : String(error),
610
+ type: "error",
611
+ });
612
+ setBusy(false);
613
+ }
614
+ };
615
+
616
+ const changed =
617
+ subject.trim() !== "" &&
618
+ (subject.trim() !== todo.subject ||
619
+ text.trim() !== todo.text ||
620
+ tagged.join(",") !== todo.tags.join(",") ||
621
+ files.files.length > 0 ||
622
+ files.keep.length !== todo.files.length);
623
+ return (
624
+ <DialogContent className="max-h-[85dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-lg">
625
+ <DialogHeader>
626
+ <DialogTitle className="flex items-center gap-2">
627
+ Todo {todo.id}
628
+ <span className="font-normal text-muted-foreground text-xs">
629
+ <CopyLink id={todo.id} />
630
+ </span>
631
+ </DialogTitle>
632
+ <DialogDescription>
633
+ {todo.takenBy
634
+ ? `Taken by ${todo.takenBy}`
635
+ : `Number ${todo.position} on the list`}
636
+ </DialogDescription>
637
+ </DialogHeader>
638
+ <div className="flex flex-col gap-2">
639
+ <MentionAnchor mentions={mentions}>
640
+ <InputGroup>
641
+ <InputGroupInput
642
+ ref={mentions.ref}
643
+ aria-label="subject"
644
+ value={subject}
645
+ onChange={mentions.onChange}
646
+ onSelect={mentions.onSelect}
647
+ onClick={mentions.onClick}
648
+ onKeyDown={mentions.onKeyDown}
649
+ onPaste={files.paste}
650
+ />
651
+ </InputGroup>
652
+ </MentionAnchor>
653
+ <div className="flex items-center justify-between">
654
+ <div className="flex gap-1">
655
+ <Button
656
+ size="xs"
657
+ variant={writing ? "secondary" : "ghost"}
658
+ aria-pressed={writing}
659
+ onClick={() => setWriting(true)}
660
+ >
661
+ Write
662
+ </Button>
663
+ <Button
664
+ size="xs"
665
+ variant={writing ? "ghost" : "secondary"}
666
+ aria-pressed={!writing}
667
+ onClick={() => setWriting(false)}
668
+ >
669
+ Preview
670
+ </Button>
671
+ </div>
672
+ <span className="text-muted-foreground text-xs">
673
+ Markdown supported
674
+ </span>
675
+ </div>
676
+ {writing ? (
677
+ <MentionAnchor mentions={detail}>
678
+ <InputGroup>
679
+ <InputGroupTextarea
680
+ ref={detail.ref}
681
+ aria-label="detail"
682
+ placeholder="More to say in markdown, if any"
683
+ value={text}
684
+ onChange={detail.onChange}
685
+ onSelect={detail.onSelect}
686
+ onClick={detail.onClick}
687
+ onKeyDown={detail.onKeyDown}
688
+ onPaste={files.paste}
689
+ rows={6}
690
+ />
691
+ <InputGroupAddon align="block-end">
692
+ <AttachButton files={files} />
693
+ </InputGroupAddon>
694
+ </InputGroup>
695
+ </MentionAnchor>
696
+ ) : (
697
+ <div className="min-h-20 rounded-md border px-3 py-2 text-sm">
698
+ {text.trim() === "" ? (
699
+ <span className="text-muted-foreground">Nothing to preview</span>
700
+ ) : (
701
+ <Markdown text={text} />
702
+ )}
703
+ </div>
704
+ )}
705
+ <FileList files={files} />
706
+ <div className="flex flex-col gap-1">
707
+ <InputGroup>
708
+ <InputGroupAddon>
709
+ <TagIcon />
710
+ </InputGroupAddon>
711
+ <InputGroupInput
712
+ aria-label="tags"
713
+ aria-describedby={tagsHint}
714
+ placeholder="uploads, merge"
715
+ value={tags}
716
+ onChange={(event) => setTags(event.target.value)}
717
+ />
718
+ </InputGroup>
719
+ {/* Always shown, since the placeholder goes once a tag is typed. */}
720
+ <p id={tagsHint} className="text-muted-foreground text-xs">
721
+ Separate tags with commas.
722
+ </p>
723
+ </div>
724
+ </div>
725
+ <DialogFooter className="flex-row justify-between sm:justify-between">
726
+ <Button
727
+ variant={confirming ? "destructive" : "ghost"}
728
+ disabled={busy}
729
+ onClick={() =>
730
+ confirming
731
+ ? void run(() => removeTodo(todo.id), "Not removed")
732
+ : setConfirming(true)
733
+ }
734
+ >
735
+ <Trash2Icon data-icon="inline-start" />
736
+ {confirming ? "Remove for good" : "Remove"}
737
+ </Button>
738
+ <Button
739
+ disabled={!changed || busy}
740
+ onClick={() =>
741
+ void run(
742
+ () =>
743
+ updateTodo(todo.id, {
744
+ subject,
745
+ text,
746
+ tags: tagged,
747
+ files: files.files,
748
+ keep: files.keep,
749
+ }),
750
+ "Not saved",
751
+ )
752
+ }
753
+ >
754
+ Save
755
+ </Button>
756
+ </DialogFooter>
757
+ </DialogContent>
758
+ );
759
+ }