@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.
- package/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Toast as ToastPrimitive } from "@base-ui/react/toast";
|
|
2
|
+
import {
|
|
3
|
+
CircleCheckIcon,
|
|
4
|
+
InfoIcon,
|
|
5
|
+
Loader2Icon,
|
|
6
|
+
OctagonXIcon,
|
|
7
|
+
TriangleAlertIcon,
|
|
8
|
+
XIcon,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import type * as React from "react";
|
|
11
|
+
|
|
12
|
+
import { Button } from "#dev/components/ui/button.tsx";
|
|
13
|
+
import { cn } from "#dev/lib/utils.ts";
|
|
14
|
+
|
|
15
|
+
const toast = ToastPrimitive.createToastManager();
|
|
16
|
+
|
|
17
|
+
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
|
|
18
|
+
return <ToastPrimitive.Provider {...props} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
|
|
22
|
+
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
|
26
|
+
return (
|
|
27
|
+
<ToastPrimitive.Viewport
|
|
28
|
+
data-slot="toast-viewport"
|
|
29
|
+
className={cn(
|
|
30
|
+
"pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
|
39
|
+
return (
|
|
40
|
+
<ToastPrimitive.Root
|
|
41
|
+
data-slot="toast"
|
|
42
|
+
className={cn(
|
|
43
|
+
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
44
|
+
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
|
|
45
|
+
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
|
|
46
|
+
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
|
47
|
+
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
|
|
48
|
+
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
|
|
49
|
+
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
|
|
50
|
+
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
|
51
|
+
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
|
52
|
+
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
|
53
|
+
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
|
54
|
+
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
|
55
|
+
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
|
56
|
+
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
|
57
|
+
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
|
|
66
|
+
return (
|
|
67
|
+
<ToastPrimitive.Content
|
|
68
|
+
data-slot="toast-content"
|
|
69
|
+
className={cn(
|
|
70
|
+
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
|
|
79
|
+
return (
|
|
80
|
+
<ToastPrimitive.Title
|
|
81
|
+
data-slot="toast-title"
|
|
82
|
+
className={cn("text-sm font-medium", className)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ToastDescription({
|
|
89
|
+
className,
|
|
90
|
+
...props
|
|
91
|
+
}: ToastPrimitive.Description.Props) {
|
|
92
|
+
return (
|
|
93
|
+
<ToastPrimitive.Description
|
|
94
|
+
data-slot="toast-description"
|
|
95
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
96
|
+
{...props}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ToastAction({
|
|
102
|
+
className,
|
|
103
|
+
render = <Button variant="outline" size="sm" />,
|
|
104
|
+
...props
|
|
105
|
+
}: ToastPrimitive.Action.Props) {
|
|
106
|
+
return (
|
|
107
|
+
<ToastPrimitive.Action
|
|
108
|
+
data-slot="toast-action"
|
|
109
|
+
render={render}
|
|
110
|
+
className={cn("shrink-0", className)}
|
|
111
|
+
{...props}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ToastClose({
|
|
117
|
+
className,
|
|
118
|
+
children,
|
|
119
|
+
render = <Button variant="ghost" size="icon-sm" />,
|
|
120
|
+
...props
|
|
121
|
+
}: ToastPrimitive.Close.Props) {
|
|
122
|
+
return (
|
|
123
|
+
<ToastPrimitive.Close
|
|
124
|
+
data-slot="toast-close"
|
|
125
|
+
aria-label="Close toast"
|
|
126
|
+
render={render}
|
|
127
|
+
className={cn(
|
|
128
|
+
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
|
|
129
|
+
className,
|
|
130
|
+
)}
|
|
131
|
+
{...props}
|
|
132
|
+
>
|
|
133
|
+
{children ?? <XIcon aria-hidden="true" />}
|
|
134
|
+
</ToastPrimitive.Close>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ToastIcon({ type }: { type: string | undefined }) {
|
|
139
|
+
let icon: React.ReactNode = null;
|
|
140
|
+
|
|
141
|
+
if (type === "success") {
|
|
142
|
+
icon = <CircleCheckIcon aria-hidden="true" />;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (type === "info") {
|
|
146
|
+
icon = <InfoIcon aria-hidden="true" />;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (type === "warning") {
|
|
150
|
+
icon = <TriangleAlertIcon aria-hidden="true" />;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (type === "error") {
|
|
154
|
+
icon = <OctagonXIcon className="text-destructive" aria-hidden="true" />;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (type === "loading") {
|
|
158
|
+
icon = <Loader2Icon className="animate-spin" aria-hidden="true" />;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!icon) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<span
|
|
167
|
+
data-slot="toast-icon"
|
|
168
|
+
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
|
|
169
|
+
>
|
|
170
|
+
{icon}
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function ToastList() {
|
|
176
|
+
const { toasts } = ToastPrimitive.useToastManager();
|
|
177
|
+
|
|
178
|
+
return toasts.map((toastItem) => (
|
|
179
|
+
<Toast key={toastItem.id} toast={toastItem}>
|
|
180
|
+
<ToastContent>
|
|
181
|
+
<ToastIcon type={toastItem.type} />
|
|
182
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
183
|
+
<ToastTitle />
|
|
184
|
+
<ToastDescription />
|
|
185
|
+
</div>
|
|
186
|
+
<ToastAction />
|
|
187
|
+
<ToastClose />
|
|
188
|
+
</ToastContent>
|
|
189
|
+
</Toast>
|
|
190
|
+
));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function Toaster({
|
|
194
|
+
children,
|
|
195
|
+
toastManager = toast,
|
|
196
|
+
...props
|
|
197
|
+
}: ToastPrimitive.Provider.Props) {
|
|
198
|
+
return (
|
|
199
|
+
<ToastProvider toastManager={toastManager} {...props}>
|
|
200
|
+
{children}
|
|
201
|
+
<ToastPortal>
|
|
202
|
+
<ToastViewport>
|
|
203
|
+
<ToastList />
|
|
204
|
+
</ToastViewport>
|
|
205
|
+
</ToastPortal>
|
|
206
|
+
</ToastProvider>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const createToastManager = ToastPrimitive.createToastManager;
|
|
211
|
+
const useToastManager = ToastPrimitive.useToastManager;
|
|
212
|
+
|
|
213
|
+
export {
|
|
214
|
+
createToastManager,
|
|
215
|
+
Toast,
|
|
216
|
+
ToastAction,
|
|
217
|
+
ToastClose,
|
|
218
|
+
ToastContent,
|
|
219
|
+
ToastDescription,
|
|
220
|
+
Toaster,
|
|
221
|
+
ToastPortal,
|
|
222
|
+
ToastProvider,
|
|
223
|
+
ToastTitle,
|
|
224
|
+
ToastViewport,
|
|
225
|
+
toast,
|
|
226
|
+
useToastManager,
|
|
227
|
+
};
|
package/dev/feed.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Resource } from "webappwiz/disposable";
|
|
2
|
+
import { Dispatcher, type Eventful } from "webappwiz/events";
|
|
3
|
+
import type { Snapshot } from "../snapshot";
|
|
4
|
+
|
|
5
|
+
export type FeedEvents = { changed: undefined };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The page's copy of the repo, refetched whenever the server says something
|
|
9
|
+
* moved.
|
|
10
|
+
*
|
|
11
|
+
* One of these lasts as long as the page: `start` and `dispose` open and close
|
|
12
|
+
* the stream, and disposing leaves the object usable so it can be started
|
|
13
|
+
* again, which is why the component reads it through `useReactive` rather than
|
|
14
|
+
* rebuilding it, since that subscribes to the instance it saw on its first
|
|
15
|
+
* render.
|
|
16
|
+
*/
|
|
17
|
+
export class Feed implements Eventful<FeedEvents>, Resource {
|
|
18
|
+
private readonly dispatcher = new Dispatcher<FeedEvents>();
|
|
19
|
+
readonly events = this.dispatcher.events;
|
|
20
|
+
|
|
21
|
+
snapshot: Snapshot | null = null;
|
|
22
|
+
/** Set once the stream drops, so a stale page says so instead of lying. */
|
|
23
|
+
offline = false;
|
|
24
|
+
|
|
25
|
+
private stream: EventSource | null = null;
|
|
26
|
+
|
|
27
|
+
start(): void {
|
|
28
|
+
// React runs a mount effect twice in StrictMode, and a second stream would
|
|
29
|
+
// leak the first.
|
|
30
|
+
if (this.stream !== null) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const stream = new EventSource("/events");
|
|
34
|
+
this.stream = stream;
|
|
35
|
+
stream.onmessage = () => void this.load();
|
|
36
|
+
stream.onopen = () => this.set({ offline: false });
|
|
37
|
+
// EventSource reconnects on its own, so this is a notice rather than an
|
|
38
|
+
// error to recover from: `onopen` clears it when the server comes back.
|
|
39
|
+
stream.onerror = () => this.set({ offline: true });
|
|
40
|
+
void this.load();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
dispose(): void {
|
|
44
|
+
this.stream?.close();
|
|
45
|
+
this.stream = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async load(): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch("/api/snapshot");
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`snapshot: ${response.status}`);
|
|
53
|
+
}
|
|
54
|
+
this.set({
|
|
55
|
+
snapshot: (await response.json()) as Snapshot,
|
|
56
|
+
offline: false,
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
// The last good snapshot stays on screen: a page that empties itself
|
|
60
|
+
// because one poll failed is worse than one that admits it is stale.
|
|
61
|
+
this.set({ offline: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private set(next: Partial<Pick<Feed, "snapshot" | "offline">>): void {
|
|
66
|
+
Object.assign(this, next);
|
|
67
|
+
this.dispatcher.dispatch("changed");
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dev/files.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { FileIcon, PaperclipIcon, XIcon } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type ClipboardEvent,
|
|
4
|
+
type JSX,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Button } from "#dev/components/ui/button.tsx";
|
|
10
|
+
import { InputGroupButton } from "#dev/components/ui/input-group.tsx";
|
|
11
|
+
import { fileUrl } from "./api";
|
|
12
|
+
|
|
13
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Files on their way to being attached, and the ones already stored: what a
|
|
17
|
+
* reply box and a todo both need. `keep` starts as every stored path.
|
|
18
|
+
*/
|
|
19
|
+
export function useFiles(stored: string[] = []) {
|
|
20
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
21
|
+
const [keep, setKeep] = useState<string[]>(stored);
|
|
22
|
+
const add = (added: Iterable<File>) => {
|
|
23
|
+
const list = [...added];
|
|
24
|
+
if (list.length > 0) {
|
|
25
|
+
setFiles((files) => [...files, ...list]);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
// A screenshot pasted from the clipboard arrives as a file with no useful
|
|
29
|
+
// name, which is fine: the server keeps whatever it is given.
|
|
30
|
+
const paste = (event: ClipboardEvent<HTMLElement>) => {
|
|
31
|
+
const pasted = [...event.clipboardData.files];
|
|
32
|
+
if (pasted.length > 0) {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
add(pasted);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
files,
|
|
39
|
+
keep,
|
|
40
|
+
add,
|
|
41
|
+
paste,
|
|
42
|
+
drop: (index: number) =>
|
|
43
|
+
setFiles((files) => files.filter((_, at) => at !== index)),
|
|
44
|
+
unkeep: (path: string) =>
|
|
45
|
+
setKeep((keep) => keep.filter((kept) => kept !== path)),
|
|
46
|
+
/** Back to nothing attached, once what was attached has been sent. */
|
|
47
|
+
clear: () => {
|
|
48
|
+
setFiles([]);
|
|
49
|
+
setKeep([]);
|
|
50
|
+
},
|
|
51
|
+
/** Anything to send: a new file, or a stored one still kept. */
|
|
52
|
+
any: files.length > 0 || keep.length > 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type Files = ReturnType<typeof useFiles>;
|
|
57
|
+
|
|
58
|
+
/** The paperclip: picks any number of files of any kind. */
|
|
59
|
+
export function AttachButton({ files }: { files: Files }): JSX.Element {
|
|
60
|
+
const picker = useRef<HTMLInputElement>(null);
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<InputGroupButton
|
|
64
|
+
aria-label="attach files"
|
|
65
|
+
size="icon-xs"
|
|
66
|
+
variant="ghost"
|
|
67
|
+
onClick={() => picker.current?.click()}
|
|
68
|
+
>
|
|
69
|
+
<PaperclipIcon />
|
|
70
|
+
</InputGroupButton>
|
|
71
|
+
<input
|
|
72
|
+
ref={picker}
|
|
73
|
+
type="file"
|
|
74
|
+
multiple
|
|
75
|
+
hidden
|
|
76
|
+
onChange={(event) => {
|
|
77
|
+
files.add(event.target.files ?? []);
|
|
78
|
+
event.target.value = "";
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every file attached, stored or new, each with a way to drop it. */
|
|
86
|
+
export function FileList({ files }: { files: Files }): JSX.Element | null {
|
|
87
|
+
if (!files.any) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<div className="flex flex-wrap gap-2">
|
|
92
|
+
{files.keep.map((path) => (
|
|
93
|
+
<Chip
|
|
94
|
+
key={path}
|
|
95
|
+
name={stored(path)}
|
|
96
|
+
src={IMAGE.test(path) ? fileUrl(path) : undefined}
|
|
97
|
+
href={fileUrl(path)}
|
|
98
|
+
onRemove={() => files.unkeep(path)}
|
|
99
|
+
/>
|
|
100
|
+
))}
|
|
101
|
+
{files.files.map((file, index) => (
|
|
102
|
+
<New
|
|
103
|
+
key={`${file.name}-${file.size}-${file.lastModified}`}
|
|
104
|
+
file={file}
|
|
105
|
+
onRemove={() => files.drop(index)}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function New({
|
|
113
|
+
file,
|
|
114
|
+
onRemove,
|
|
115
|
+
}: {
|
|
116
|
+
file: File;
|
|
117
|
+
onRemove: () => void;
|
|
118
|
+
}): JSX.Element {
|
|
119
|
+
const image = file.type.startsWith("image/");
|
|
120
|
+
// Revoked when the chip goes, or every pasted screenshot stays in memory for
|
|
121
|
+
// as long as the page is open.
|
|
122
|
+
const [url, setUrl] = useState<string>();
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!image) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const created = URL.createObjectURL(file);
|
|
128
|
+
setUrl(created);
|
|
129
|
+
return () => URL.revokeObjectURL(created);
|
|
130
|
+
}, [file, image]);
|
|
131
|
+
return (
|
|
132
|
+
<Chip name={file.name} src={image ? url : undefined} onRemove={onRemove} />
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One file: a thumbnail for an image, its name otherwise. */
|
|
137
|
+
function Chip({
|
|
138
|
+
name,
|
|
139
|
+
src,
|
|
140
|
+
href,
|
|
141
|
+
onRemove,
|
|
142
|
+
}: {
|
|
143
|
+
name: string;
|
|
144
|
+
src?: string;
|
|
145
|
+
href?: string;
|
|
146
|
+
onRemove?: () => void;
|
|
147
|
+
}): JSX.Element {
|
|
148
|
+
const face =
|
|
149
|
+
src === undefined ? (
|
|
150
|
+
<div className="flex h-16 max-w-40 items-center gap-2 rounded-md border px-3 text-xs">
|
|
151
|
+
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
152
|
+
<span className="truncate">{name}</span>
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<img
|
|
156
|
+
src={src}
|
|
157
|
+
alt={name}
|
|
158
|
+
className="size-16 rounded-md border object-cover"
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
return (
|
|
162
|
+
<div className="relative">
|
|
163
|
+
{href === undefined ? (
|
|
164
|
+
face
|
|
165
|
+
) : (
|
|
166
|
+
<a href={href} target="_blank" rel="noreferrer">
|
|
167
|
+
{face}
|
|
168
|
+
</a>
|
|
169
|
+
)}
|
|
170
|
+
{onRemove && (
|
|
171
|
+
<Button
|
|
172
|
+
aria-label={`remove ${name}`}
|
|
173
|
+
variant="secondary"
|
|
174
|
+
size="icon-xs"
|
|
175
|
+
className="absolute -top-2 -right-2 rounded-full"
|
|
176
|
+
onClick={onRemove}
|
|
177
|
+
>
|
|
178
|
+
<XIcon />
|
|
179
|
+
</Button>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The id a stored name leads with: a uuid, or a test's counter. */
|
|
186
|
+
const STORED_ID = /^([0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}|\d+)-/i;
|
|
187
|
+
|
|
188
|
+
/** A stored file's name as given, without the id stored names lead with. */
|
|
189
|
+
export function stored(path: string): string {
|
|
190
|
+
return (path.split("/").at(-1) ?? path).replace(STORED_ID, "");
|
|
191
|
+
}
|
package/dev/index.html
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>arbor</title>
|
|
7
|
+
<!-- The emoji drawn as SVG inline, so there is no file for the server to
|
|
8
|
+
serve. The page rewrites it to a warning when the server goes away. -->
|
|
9
|
+
<link
|
|
10
|
+
rel="icon"
|
|
11
|
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🌲</text></svg>"
|
|
12
|
+
/>
|
|
13
|
+
<!-- Both are routes the server builds on request, not files next to this
|
|
14
|
+
one. See dev/assets.ts for why they are not Bun's HTML import. -->
|
|
15
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div id="root"></div>
|
|
19
|
+
<script type="module" src="/main.js"></script>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
package/dev/lib/utils.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/** Joins class names, letting a later Tailwind utility override an earlier one. */
|
|
5
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
6
|
+
return twMerge(clsx(inputs));
|
|
7
|
+
}
|
package/dev/main.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { App } from "./app";
|
|
4
|
+
|
|
5
|
+
const root = document.getElementById("root");
|
|
6
|
+
if (root === null) {
|
|
7
|
+
throw new Error("no #root to mount into: index.html and main.tsx disagree");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// shadcn's theme keys dark mode off a class, so the class follows the device,
|
|
11
|
+
// including when it switches at sunset with the page open.
|
|
12
|
+
const dark = matchMedia("(prefers-color-scheme: dark)");
|
|
13
|
+
const theme = () =>
|
|
14
|
+
document.documentElement.classList.toggle("dark", dark.matches);
|
|
15
|
+
theme();
|
|
16
|
+
dark.addEventListener("change", theme);
|
|
17
|
+
|
|
18
|
+
createRoot(root).render(
|
|
19
|
+
<StrictMode>
|
|
20
|
+
<App />
|
|
21
|
+
</StrictMode>,
|
|
22
|
+
);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import "../../../setup";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
// `screen` is deliberately unused: it binds to `document.body` when this module
|
|
5
|
+
// is imported, which happens before `./dom` registers one. The queries that
|
|
6
|
+
// `render` hands back bind on call instead.
|
|
7
|
+
import { Markdown } from "./markdown";
|
|
8
|
+
|
|
9
|
+
describe("Markdown", () => {
|
|
10
|
+
it("renders a heading at its own level", () => {
|
|
11
|
+
const page = render(<Markdown text={"# alpha\n\n## Goal\nland it\n"} />);
|
|
12
|
+
|
|
13
|
+
expect(page.getByRole("heading", { level: 1 }).textContent).toBe("alpha");
|
|
14
|
+
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Goal");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps lines that wrap in one paragraph", () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<Markdown text={"one or two lines:\nwhat done means.\n"} />,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(container.querySelectorAll("p")).toHaveLength(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("gives a checklist item a disabled checkbox, ticked when the box was", () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<Markdown text={"- [ ] the rest\n- [x] landed\n"} />,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const boxes = [...container.querySelectorAll("input")];
|
|
31
|
+
|
|
32
|
+
expect(boxes.map((box) => box.checked)).toEqual([false, true]);
|
|
33
|
+
expect(boxes.every((box) => box.disabled)).toBe(true);
|
|
34
|
+
expect(container.textContent).toContain("the rest");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders a plain bullet without a checkbox", () => {
|
|
38
|
+
const { container } = render(<Markdown text={"- a bullet\n"} />);
|
|
39
|
+
|
|
40
|
+
expect(container.querySelector("li")?.textContent).toBe("a bullet");
|
|
41
|
+
expect(container.querySelector("input")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("nests lists", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Markdown text={"- outer\n - inner\n- next\n"} />,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(container.querySelectorAll("ul ul li")).toHaveLength(1);
|
|
50
|
+
expect(container.querySelectorAll("ul > li")).toHaveLength(3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders a table", () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<Markdown text={"| a | b |\n| - | - |\n| 1 | 2 |\n"} />,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
[...container.querySelectorAll("td")].map((cell) => cell.textContent),
|
|
60
|
+
).toEqual(["1", "2"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders a fenced block as code, verbatim", () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<Markdown text={"```ts\nconst x = 1;\n```\n"} />,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(container.querySelector("pre code")?.textContent).toBe(
|
|
69
|
+
"const x = 1;",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders inline code, bold and italics inside a line", () => {
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<Markdown text={"run `arbor list` for **every** *task*\n"} />,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(container.querySelector("code")?.textContent).toBe("arbor list");
|
|
79
|
+
expect(container.querySelector("strong")?.textContent).toBe("every");
|
|
80
|
+
expect(container.querySelector("em")?.textContent).toBe("task");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("links http destinations in a new tab, so the page keeps its place", () => {
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<Markdown text={"open [the page](http://localhost:4269)"} />,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const link = container.querySelector("a");
|
|
89
|
+
|
|
90
|
+
expect(link?.textContent).toBe("the page");
|
|
91
|
+
expect(link?.getAttribute("href")).toBe("http://localhost:4269");
|
|
92
|
+
expect(link?.getAttribute("target")).toBe("_blank");
|
|
93
|
+
expect(link?.getAttribute("rel")).toContain("noopener");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("links a bare URL", () => {
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<Markdown text={"see https://example.com/a?b=1 for more"} />,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
|
102
|
+
"https://example.com/a?b=1",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("never makes a javascript: link clickable", () => {
|
|
107
|
+
const { container } = render(
|
|
108
|
+
<Markdown text={"[click](javascript:alert(1))"} />,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(container.querySelector("a[href]")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders markup in the source as text instead of elements", () => {
|
|
115
|
+
const { container } = render(
|
|
116
|
+
<Markdown text={"- [ ] drop <script>alert(1)</script>\n"} />,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(container.querySelector("script")).toBeNull();
|
|
120
|
+
expect(container.querySelector("li")?.textContent?.trim()).toBe(
|
|
121
|
+
"drop <script>alert(1)</script>",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("shows an image as its alt text, since the page cannot load a local path", () => {
|
|
126
|
+
const { container } = render(
|
|
127
|
+
<Markdown text={""} />,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(container.querySelector("img")).toBeNull();
|
|
131
|
+
expect(container.textContent).toBe("[the header]");
|
|
132
|
+
});
|
|
133
|
+
});
|