@karimsa/mdxserve 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +233 -2
- package/client/App.tsx +7 -0
- package/client/CodeBlock.tsx +395 -0
- package/client/CrossFade.tsx +72 -0
- package/client/DocContext.ts +14 -0
- package/client/DocView.tsx +107 -0
- package/client/ErrorBox.tsx +23 -0
- package/client/Heading.tsx +31 -0
- package/client/HomeEmptyState.tsx +101 -0
- package/client/HomeView.tsx +78 -0
- package/client/ListingView.tsx +663 -0
- package/client/MdSection.tsx +234 -0
- package/client/MdSectionEditor.tsx +233 -0
- package/client/Mermaid.tsx +435 -0
- package/client/RenderErrorBoundary.tsx +40 -0
- package/client/Table.tsx +14 -0
- package/client/TaskCheckbox.tsx +38 -0
- package/client/api.ts +138 -0
- package/client/app.css +372 -0
- package/client/builtins/Badge.tsx +109 -0
- package/client/builtins/Button.tsx +111 -0
- package/client/builtins/Callout.tsx +97 -0
- package/client/builtins/Card.tsx +111 -0
- package/client/builtins/Chart.tsx +875 -0
- package/client/builtins/Diff.tsx +722 -0
- package/client/builtins/Dropdown.tsx +417 -0
- package/client/builtins/FileTree.tsx +87 -0
- package/client/builtins/Kbd.tsx +18 -0
- package/client/builtins/Screenshot.tsx +209 -0
- package/client/builtins/Sparkline.tsx +63 -0
- package/client/builtins/Tabs.tsx +169 -0
- package/client/builtins/Tooltip.tsx +52 -0
- package/client/builtins/chart-data.ts +133 -0
- package/client/builtins/index.ts +167 -0
- package/client/design/base/editor.css +151 -0
- package/client/design/base/prose.css +143 -0
- package/client/design/base/reset.css +79 -0
- package/client/design/tokens/colors.css +188 -0
- package/client/design/tokens/elevation.css +42 -0
- package/client/design/tokens/fonts.css +6 -0
- package/client/design/tokens/motion.css +76 -0
- package/client/design/tokens/spacing.css +34 -0
- package/client/design/tokens/typography.css +56 -0
- package/client/doc-module-cache.ts +17 -0
- package/client/editor-link.ts +27 -0
- package/client/entry.tsx +51 -0
- package/client/export-doc.ts +80 -0
- package/client/export-save.ts +96 -0
- package/client/favicon.svg +1 -0
- package/client/file-system-access.d.ts +29 -0
- package/client/format.ts +17 -0
- package/client/hooks.ts +34 -0
- package/client/lucide-icons.d.ts +9 -0
- package/client/mdx-components-base.ts +32 -0
- package/client/mdx-components.ts +18 -0
- package/client/mermaid-chart.ts +109 -0
- package/client/mermaid-direction.ts +73 -0
- package/client/motion.ts +104 -0
- package/client/platform.ts +16 -0
- package/client/route-path.ts +15 -0
- package/client/router.ts +452 -0
- package/client/shell/AppShell.tsx +401 -0
- package/client/shell/Footer.tsx +33 -0
- package/client/shell/NotFoundView.tsx +22 -0
- package/client/shell/Sidebar.tsx +169 -0
- package/client/shell/StandaloneShell.tsx +65 -0
- package/client/shell/TocRail.tsx +53 -0
- package/client/shell/TopBar.tsx +117 -0
- package/client/shell/use-doc-width.ts +61 -0
- package/client/shell/useToc.ts +77 -0
- package/client/ssr-entry.tsx +22 -0
- package/client/standalone-entry.tsx +51 -0
- package/client/state.ts +90 -0
- package/client/theme.ts +65 -0
- package/client/ui/Breadcrumb.tsx +49 -0
- package/client/ui/ConfirmDeleteDialog.tsx +113 -0
- package/client/ui/ExpandModal.tsx +342 -0
- package/client/ui/Icon.tsx +114 -0
- package/client/ui/IconButton.tsx +63 -0
- package/client/ui/Kbd.tsx +17 -0
- package/client/ui/PageNav.tsx +77 -0
- package/client/ui/ResizeHandle.tsx +201 -0
- package/client/ui/SearchDialog.tsx +187 -0
- package/client/ui/Tag.tsx +44 -0
- package/client/ui/Toast.tsx +189 -0
- package/client/ui/TocList.tsx +71 -0
- package/client/ui/icon-set.ts +102 -0
- package/client/ui/toast-count.ts +28 -0
- package/dist/cli.js +5091 -0
- package/dist/registry.json +703 -0
- package/dist/render-worker.js +145 -0
- package/package.json +113 -5
- package/skills/mdxserve/SKILL.md +178 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
2
|
+
import { useAtom, useSetAtom } from "jotai";
|
|
3
|
+
import { useMutation } from "@tanstack/react-query";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
|
5
|
+
import { queryClient, trpc } from "./api";
|
|
6
|
+
import { formatModified, formatSize } from "./format";
|
|
7
|
+
import { stagger, TRANSITIONS, VARIANTS } from "./motion";
|
|
8
|
+
import { Icon } from "./ui/Icon";
|
|
9
|
+
import { ResizeHandle } from "./ui/ResizeHandle";
|
|
10
|
+
import { ConfirmDeleteDialog } from "./ui/ConfirmDeleteDialog";
|
|
11
|
+
import { pushToast } from "./ui/Toast";
|
|
12
|
+
import type { ListingEntry } from "./router";
|
|
13
|
+
import Button from "./builtins/Button";
|
|
14
|
+
import Dropdown from "./builtins/Dropdown";
|
|
15
|
+
import {
|
|
16
|
+
LISTING_MAX_WIDTH,
|
|
17
|
+
LISTING_MIN_WIDTH,
|
|
18
|
+
listingSortAtom,
|
|
19
|
+
listingWidthAtom,
|
|
20
|
+
type SortKey,
|
|
21
|
+
} from "./state";
|
|
22
|
+
|
|
23
|
+
function sortEntries(entries: ListingEntry[], sort: SortKey): ListingEntry[] {
|
|
24
|
+
const byName = (first: ListingEntry, second: ListingEntry) =>
|
|
25
|
+
first.name.localeCompare(second.name, undefined, { sensitivity: "base" });
|
|
26
|
+
return [...entries].sort((first, second) => {
|
|
27
|
+
if (first.isDir !== second.isDir) return first.isDir ? -1 : 1;
|
|
28
|
+
if (sort === "modified") {
|
|
29
|
+
const diff = (second.mtime ?? 0) - (first.mtime ?? 0);
|
|
30
|
+
if (diff !== 0) return diff;
|
|
31
|
+
}
|
|
32
|
+
return byName(first, second);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rowVariants = {
|
|
37
|
+
initial: { opacity: 0, y: 4 },
|
|
38
|
+
enter: { opacity: 1, y: 0 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function Row({
|
|
42
|
+
href,
|
|
43
|
+
icon,
|
|
44
|
+
label,
|
|
45
|
+
labelHtml,
|
|
46
|
+
sublabel,
|
|
47
|
+
muted,
|
|
48
|
+
size,
|
|
49
|
+
mtime,
|
|
50
|
+
selectable = false,
|
|
51
|
+
checked = false,
|
|
52
|
+
selectionActive = false,
|
|
53
|
+
onToggle,
|
|
54
|
+
dense = false,
|
|
55
|
+
active = false,
|
|
56
|
+
}: {
|
|
57
|
+
href: string | null;
|
|
58
|
+
icon: "folder" | "file";
|
|
59
|
+
label: string;
|
|
60
|
+
/** Server-rendered inline HTML for the label; wins over `label` when set. */
|
|
61
|
+
labelHtml?: string;
|
|
62
|
+
/** Secondary line under the label (e.g. the filename when a title is shown). */
|
|
63
|
+
sublabel?: string;
|
|
64
|
+
muted: boolean;
|
|
65
|
+
size?: number;
|
|
66
|
+
mtime?: number;
|
|
67
|
+
/** Whether this row may be checked for bulk delete (files only; never dirs or ".."). */
|
|
68
|
+
selectable?: boolean;
|
|
69
|
+
checked?: boolean;
|
|
70
|
+
/** Whether any row in the listing is currently selected (keeps unchecked boxes visible). */
|
|
71
|
+
selectionActive?: boolean;
|
|
72
|
+
onToggle?: () => void;
|
|
73
|
+
/** Sidebar mode: tighter padding and smaller type, no checkbox gutter. */
|
|
74
|
+
dense?: boolean;
|
|
75
|
+
/** Sidebar mode: this row is the doc currently being viewed. */
|
|
76
|
+
active?: boolean;
|
|
77
|
+
}) {
|
|
78
|
+
const inner = (
|
|
79
|
+
<>
|
|
80
|
+
<Icon
|
|
81
|
+
name={icon === "folder" ? "folder" : "file-text"}
|
|
82
|
+
size={dense ? "sm" : "md"}
|
|
83
|
+
className="shrink-0 text-text-subtle"
|
|
84
|
+
/>
|
|
85
|
+
<span className="flex min-w-0 flex-col">
|
|
86
|
+
{labelHtml ? (
|
|
87
|
+
<span
|
|
88
|
+
className={
|
|
89
|
+
"listing-title truncate font-sans font-medium leading-normal " +
|
|
90
|
+
(dense ? "text-[length:var(--size-sm)]" : "text-[length:var(--size-md)]") +
|
|
91
|
+
(active ? " font-semibold text-text-accent" : "")
|
|
92
|
+
}
|
|
93
|
+
dangerouslySetInnerHTML={{ __html: labelHtml }}
|
|
94
|
+
/>
|
|
95
|
+
) : (
|
|
96
|
+
<span
|
|
97
|
+
className={
|
|
98
|
+
"truncate font-sans font-medium leading-normal " +
|
|
99
|
+
(dense ? "text-[length:var(--size-sm)]" : "text-[length:var(--size-md)]") +
|
|
100
|
+
(active ? " font-semibold text-text-accent" : "")
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
{label}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
{sublabel ? (
|
|
107
|
+
<span
|
|
108
|
+
className={
|
|
109
|
+
"truncate font-mono font-normal leading-normal text-text-subtle " +
|
|
110
|
+
(dense ? "text-[length:var(--size-2xs)]" : "text-[length:var(--size-xs)]")
|
|
111
|
+
}
|
|
112
|
+
>
|
|
113
|
+
{sublabel}
|
|
114
|
+
</span>
|
|
115
|
+
) : null}
|
|
116
|
+
</span>
|
|
117
|
+
{typeof mtime === "number" || typeof size === "number" ? (
|
|
118
|
+
<span className="ml-auto flex shrink-0 items-center gap-4 pl-4 font-mono font-normal leading-[1.62] text-[length:var(--size-xs)] text-text-subtle tabular-nums">
|
|
119
|
+
{typeof mtime === "number" ? (
|
|
120
|
+
<span title={new Date(mtime).toLocaleString()}>{formatModified(mtime)}</span>
|
|
121
|
+
) : null}
|
|
122
|
+
{typeof size === "number" ? (
|
|
123
|
+
<span className="w-16 text-right">{formatSize(size)}</span>
|
|
124
|
+
) : null}
|
|
125
|
+
</span>
|
|
126
|
+
) : null}
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Dense rows have no checkbox gutter, so give the link its own left inset
|
|
131
|
+
// or the icon sits flush against the active pill's edge.
|
|
132
|
+
const rowPadding = dense ? "py-1.5 pl-2" : "py-2";
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<motion.div
|
|
136
|
+
variants={rowVariants}
|
|
137
|
+
className={
|
|
138
|
+
"group flex items-center rounded-md " +
|
|
139
|
+
(active ? "relative " : "") +
|
|
140
|
+
(checked ? "bg-surface-active" : active ? "" : "hover:bg-surface-hover")
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
{active ? (
|
|
144
|
+
<motion.span
|
|
145
|
+
layoutId="sidebar-active"
|
|
146
|
+
transition={TRANSITIONS.glide}
|
|
147
|
+
className="absolute inset-0 z-0 rounded-md bg-surface-accent-soft"
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
/>
|
|
150
|
+
) : null}
|
|
151
|
+
{dense ? null : (
|
|
152
|
+
<span data-print-hide className="flex w-7 shrink-0 justify-center">
|
|
153
|
+
{selectable ? (
|
|
154
|
+
<input
|
|
155
|
+
type="checkbox"
|
|
156
|
+
checked={checked}
|
|
157
|
+
aria-label={`Select ${label}`}
|
|
158
|
+
onChange={onToggle}
|
|
159
|
+
className={
|
|
160
|
+
"size-3.5 cursor-pointer accent-[var(--teal-550)] " +
|
|
161
|
+
(checked || selectionActive
|
|
162
|
+
? "opacity-100"
|
|
163
|
+
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100")
|
|
164
|
+
}
|
|
165
|
+
/>
|
|
166
|
+
) : null}
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
{muted || !href ? (
|
|
170
|
+
<div
|
|
171
|
+
className={`flex min-w-0 flex-1 cursor-default items-center gap-2 ${rowPadding} pr-3 text-text-subtle`}
|
|
172
|
+
>
|
|
173
|
+
{inner}
|
|
174
|
+
</div>
|
|
175
|
+
) : (
|
|
176
|
+
// Plain <a>: the router's global click delegation (client/router.ts)
|
|
177
|
+
// intercepts this for client-side navigation; no per-row handler needed.
|
|
178
|
+
// The checkbox above is a sibling, not a descendant, so it never triggers it.
|
|
179
|
+
<a
|
|
180
|
+
href={href}
|
|
181
|
+
aria-current={active ? "page" : undefined}
|
|
182
|
+
className={
|
|
183
|
+
`flex min-w-0 flex-1 items-center gap-2 ${rowPadding} pr-3` +
|
|
184
|
+
(active ? " relative z-10" : "")
|
|
185
|
+
}
|
|
186
|
+
>
|
|
187
|
+
{inner}
|
|
188
|
+
</a>
|
|
189
|
+
)}
|
|
190
|
+
</motion.div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const DAY_PRESETS = [7, 14, 30, 90];
|
|
195
|
+
const DAY_MS = 86_400_000;
|
|
196
|
+
|
|
197
|
+
const MENU_ITEM_BASE =
|
|
198
|
+
"flex w-full items-center justify-between gap-4 rounded-md px-2.5 py-1.5 text-left font-sans text-[length:var(--size-sm)]";
|
|
199
|
+
|
|
200
|
+
function menuItemClass(enabled: boolean): string {
|
|
201
|
+
return enabled
|
|
202
|
+
? `${MENU_ITEM_BASE} cursor-pointer hover:bg-surface-hover`
|
|
203
|
+
: `${MENU_ITEM_BASE} cursor-default opacity-45`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One-shot command menu for bulk-selecting files by age; replaces the current selection. */
|
|
207
|
+
function SelectMenu({
|
|
208
|
+
fileEntries,
|
|
209
|
+
onSelect,
|
|
210
|
+
}: {
|
|
211
|
+
fileEntries: ListingEntry[];
|
|
212
|
+
onSelect: (names: Set<string>) => void;
|
|
213
|
+
}) {
|
|
214
|
+
const [open, setOpen] = useState(false);
|
|
215
|
+
// Snapshot "now" when the menu opens so the live counts don't drift while it's open.
|
|
216
|
+
const [now, setNow] = useState(0);
|
|
217
|
+
const [customDays, setCustomDays] = useState(3);
|
|
218
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
219
|
+
|
|
220
|
+
function toggle() {
|
|
221
|
+
if (!open) setNow(Date.now());
|
|
222
|
+
setOpen((current) => !current);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function olderThan(days: number): ListingEntry[] {
|
|
226
|
+
return fileEntries.filter(
|
|
227
|
+
(entry) => typeof entry.mtime === "number" && now - entry.mtime > days * DAY_MS,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function select(names: Set<string>) {
|
|
232
|
+
onSelect(names);
|
|
233
|
+
setOpen(false);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Close on outside click, mirroring client/builtins/Dropdown.tsx.
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!open) return;
|
|
239
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
240
|
+
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
|
241
|
+
};
|
|
242
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
243
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
244
|
+
}, [open]);
|
|
245
|
+
|
|
246
|
+
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
247
|
+
if (event.key === "Escape") {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
setOpen(false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const customMatches = olderThan(customDays);
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div ref={rootRef} className="relative" onKeyDown={onKeyDown}>
|
|
257
|
+
<Button
|
|
258
|
+
variant="secondary"
|
|
259
|
+
size="sm"
|
|
260
|
+
icon="list-checks"
|
|
261
|
+
iconRight="chevron-down"
|
|
262
|
+
disabled={fileEntries.length === 0}
|
|
263
|
+
onClick={toggle}
|
|
264
|
+
>
|
|
265
|
+
Select
|
|
266
|
+
</Button>
|
|
267
|
+
<AnimatePresence>
|
|
268
|
+
{open ? (
|
|
269
|
+
<motion.div
|
|
270
|
+
{...VARIANTS.pop}
|
|
271
|
+
className="absolute left-0 top-full z-[var(--z-dropdown)] mt-1 min-w-56 rounded-lg border border-border-default bg-surface-raised p-1 shadow-md"
|
|
272
|
+
>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
disabled={fileEntries.length === 0}
|
|
276
|
+
onClick={
|
|
277
|
+
fileEntries.length > 0
|
|
278
|
+
? () => select(new Set(fileEntries.map((entry) => entry.name)))
|
|
279
|
+
: undefined
|
|
280
|
+
}
|
|
281
|
+
className={menuItemClass(fileEntries.length > 0)}
|
|
282
|
+
>
|
|
283
|
+
<span>All files</span>
|
|
284
|
+
<span className="font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
|
|
285
|
+
{fileEntries.length}
|
|
286
|
+
</span>
|
|
287
|
+
</button>
|
|
288
|
+
{DAY_PRESETS.map((days) => {
|
|
289
|
+
const matches = olderThan(days);
|
|
290
|
+
return (
|
|
291
|
+
<button
|
|
292
|
+
key={days}
|
|
293
|
+
type="button"
|
|
294
|
+
disabled={matches.length === 0}
|
|
295
|
+
onClick={
|
|
296
|
+
matches.length > 0
|
|
297
|
+
? () => select(new Set(matches.map((entry) => entry.name)))
|
|
298
|
+
: undefined
|
|
299
|
+
}
|
|
300
|
+
className={menuItemClass(matches.length > 0)}
|
|
301
|
+
>
|
|
302
|
+
<span>Older than {days} days</span>
|
|
303
|
+
<span className="font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
|
|
304
|
+
{matches.length}
|
|
305
|
+
</span>
|
|
306
|
+
</button>
|
|
307
|
+
);
|
|
308
|
+
})}
|
|
309
|
+
<div className="my-1 border-t border-border-subtle" />
|
|
310
|
+
<div className="flex items-center gap-2 px-2.5 py-1.5 font-sans text-[length:var(--size-sm)]">
|
|
311
|
+
<span>Older than</span>
|
|
312
|
+
<input
|
|
313
|
+
type="number"
|
|
314
|
+
min={1}
|
|
315
|
+
value={customDays}
|
|
316
|
+
data-bare-focus
|
|
317
|
+
onChange={(event) => {
|
|
318
|
+
const next = Number(event.target.value);
|
|
319
|
+
setCustomDays(Number.isFinite(next) && next >= 1 ? next : 1);
|
|
320
|
+
}}
|
|
321
|
+
onKeyDown={(event) => {
|
|
322
|
+
if (event.key === "Enter" && customMatches.length > 0) {
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
select(new Set(customMatches.map((entry) => entry.name)));
|
|
325
|
+
}
|
|
326
|
+
}}
|
|
327
|
+
className="w-12 rounded border border-border-default bg-surface-card px-1 py-0.5 text-center font-mono text-[length:var(--size-xs)] tabular-nums"
|
|
328
|
+
/>
|
|
329
|
+
<span>days</span>
|
|
330
|
+
<span
|
|
331
|
+
className={
|
|
332
|
+
"ml-auto font-mono text-[length:var(--size-xs)] tabular-nums text-text-subtle " +
|
|
333
|
+
(customMatches.length > 0 ? "" : "opacity-45")
|
|
334
|
+
}
|
|
335
|
+
>
|
|
336
|
+
{customMatches.length}
|
|
337
|
+
</span>
|
|
338
|
+
</div>
|
|
339
|
+
</motion.div>
|
|
340
|
+
) : null}
|
|
341
|
+
</AnimatePresence>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const SORT_OPTIONS = [
|
|
347
|
+
{ value: "name", label: "Name" },
|
|
348
|
+
{ value: "modified", label: "Last modified" },
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
export interface ListingViewRoute {
|
|
352
|
+
path: string;
|
|
353
|
+
/** The served root this folder lives in (no trailing slash). */
|
|
354
|
+
rootDir: string;
|
|
355
|
+
entries: ListingEntry[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function ListingView({
|
|
359
|
+
route,
|
|
360
|
+
singleRoot,
|
|
361
|
+
mode = "page",
|
|
362
|
+
activePath,
|
|
363
|
+
}: {
|
|
364
|
+
route: ListingViewRoute;
|
|
365
|
+
/** Whether the server serves only one root — hides the top-of-root parent row. */
|
|
366
|
+
singleRoot: boolean;
|
|
367
|
+
/** "sidebar": slim rail beside a doc — no selection/delete, no mtime/size, no resize handles, icon-only sort. */
|
|
368
|
+
mode?: "page" | "sidebar";
|
|
369
|
+
/** Route path of the doc being viewed; its row gets the active pill (sidebar mode). */
|
|
370
|
+
activePath?: string;
|
|
371
|
+
}) {
|
|
372
|
+
const { path, rootDir, entries } = route;
|
|
373
|
+
const sidebar = mode === "sidebar";
|
|
374
|
+
const atRootTop = path === `${rootDir}/`;
|
|
375
|
+
const segments = path.split("/").filter(Boolean);
|
|
376
|
+
const parentSegments = segments.slice(0, -1);
|
|
377
|
+
const parentHref = atRootTop
|
|
378
|
+
? "/"
|
|
379
|
+
: parentSegments.length
|
|
380
|
+
? `/${parentSegments.join("/")}/`
|
|
381
|
+
: "/";
|
|
382
|
+
const showParentRow = !atRootTop || !singleRoot;
|
|
383
|
+
|
|
384
|
+
const [sort, setSort] = useAtom(listingSortAtom);
|
|
385
|
+
const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]);
|
|
386
|
+
// Non-doc files aren't navigable in the sidebar rail and just cost space there.
|
|
387
|
+
const visible = useMemo(
|
|
388
|
+
() => (sidebar ? sorted.filter((entry) => entry.isDir || entry.isDoc) : sorted),
|
|
389
|
+
[sorted, sidebar],
|
|
390
|
+
);
|
|
391
|
+
const setWidth = useSetAtom(listingWidthAtom);
|
|
392
|
+
const container = useRef<HTMLDivElement>(null);
|
|
393
|
+
|
|
394
|
+
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
|
|
395
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
396
|
+
const [pending, setPending] = useState(false);
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
setSelectedNames(new Set());
|
|
399
|
+
setConfirmOpen(false);
|
|
400
|
+
}, [path]);
|
|
401
|
+
|
|
402
|
+
const fileEntries = useMemo(() => entries.filter((entry) => !entry.isDir), [entries]);
|
|
403
|
+
// Drop names that left the listing (watcher push, external deletes) from the
|
|
404
|
+
// set itself — otherwise a file recreated with the same name would come back
|
|
405
|
+
// already checked.
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
setSelectedNames((current) => {
|
|
408
|
+
const live = new Set(fileEntries.map((entry) => entry.name));
|
|
409
|
+
const next = new Set([...current].filter((name) => live.has(name)));
|
|
410
|
+
return next.size === current.size ? current : next;
|
|
411
|
+
});
|
|
412
|
+
}, [fileEntries]);
|
|
413
|
+
// Derived pruning as well, so mid-render staleness can't reach the UI or the POST.
|
|
414
|
+
const selected = useMemo(
|
|
415
|
+
() => fileEntries.filter((entry) => selectedNames.has(entry.name)),
|
|
416
|
+
[fileEntries, selectedNames],
|
|
417
|
+
);
|
|
418
|
+
const selectionActive = selected.length > 0;
|
|
419
|
+
|
|
420
|
+
const toggleSelected = useCallback((name: string) => {
|
|
421
|
+
setSelectedNames((current) => {
|
|
422
|
+
const next = new Set(current);
|
|
423
|
+
if (next.has(name)) next.delete(name);
|
|
424
|
+
else next.add(name);
|
|
425
|
+
return next;
|
|
426
|
+
});
|
|
427
|
+
}, []);
|
|
428
|
+
|
|
429
|
+
// If every selected file vanishes while the dialog is up (external delete +
|
|
430
|
+
// watcher refresh), close it rather than offering to delete nothing.
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (confirmOpen && !pending && selected.length === 0) setConfirmOpen(false);
|
|
433
|
+
}, [confirmOpen, pending, selected.length]);
|
|
434
|
+
|
|
435
|
+
// Escape clears the selection, unless a popover (e.g. SelectMenu) already
|
|
436
|
+
// consumed it, or the confirm dialog is up (it handles its own Escape).
|
|
437
|
+
// Sidebar mode has no selection to clear, so skip wiring the listener.
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (sidebar) return;
|
|
440
|
+
function onKeyDown(event: globalThis.KeyboardEvent) {
|
|
441
|
+
if (event.defaultPrevented || confirmOpen) return;
|
|
442
|
+
if (event.key === "Escape" && selectionActive) setSelectedNames(new Set());
|
|
443
|
+
}
|
|
444
|
+
document.addEventListener("keydown", onKeyDown);
|
|
445
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
446
|
+
}, [sidebar, confirmOpen, selectionActive]);
|
|
447
|
+
|
|
448
|
+
const trashMutation = useMutation(
|
|
449
|
+
trpc.moveDocsToTrash.mutationOptions({
|
|
450
|
+
onSuccess: () => queryClient.invalidateQueries(trpc.getFolderListing.queryFilter({ path })),
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// Ref, not just state: a second click in the same tick would see stale
|
|
455
|
+
// `pending` and fire a duplicate request before React re-renders.
|
|
456
|
+
const pendingRef = useRef(false);
|
|
457
|
+
const handleConfirm = async () => {
|
|
458
|
+
if (pendingRef.current) return;
|
|
459
|
+
pendingRef.current = true;
|
|
460
|
+
setPending(true);
|
|
461
|
+
const byPath = new Map(selected.map((entry) => [`${path}${entry.name}`, entry.name]));
|
|
462
|
+
try {
|
|
463
|
+
const data = await trashMutation.mutateAsync({ paths: [...byPath.keys()] });
|
|
464
|
+
setConfirmOpen(false);
|
|
465
|
+
if (data.failed.length === 0) {
|
|
466
|
+
setSelectedNames(new Set());
|
|
467
|
+
pushToast({
|
|
468
|
+
tone: "ok",
|
|
469
|
+
icon: "trash-2",
|
|
470
|
+
text: `Moved ${data.deleted.length} ${data.deleted.length === 1 ? "file" : "files"} to Trash`,
|
|
471
|
+
});
|
|
472
|
+
} else {
|
|
473
|
+
// Keep only the failures selected so the user can see and retry them.
|
|
474
|
+
setSelectedNames(
|
|
475
|
+
new Set(
|
|
476
|
+
data.failed
|
|
477
|
+
.map((failure) => byPath.get(failure.path))
|
|
478
|
+
.filter((name): name is string => name !== undefined),
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
console.error(data.failed);
|
|
482
|
+
pushToast({
|
|
483
|
+
tone: "danger",
|
|
484
|
+
text: `Couldn't delete ${data.failed.length} of ${byPath.size} files`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
pushToast({
|
|
489
|
+
tone: "danger",
|
|
490
|
+
text: "Couldn't reach the server",
|
|
491
|
+
});
|
|
492
|
+
} finally {
|
|
493
|
+
pendingRef.current = false;
|
|
494
|
+
setPending(false);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div ref={container} className="relative">
|
|
500
|
+
{sidebar ? null : (
|
|
501
|
+
<>
|
|
502
|
+
<ResizeHandle
|
|
503
|
+
side="left"
|
|
504
|
+
container={container}
|
|
505
|
+
onResize={setWidth}
|
|
506
|
+
label="Resize listing"
|
|
507
|
+
minWidth={LISTING_MIN_WIDTH}
|
|
508
|
+
maxWidth={LISTING_MAX_WIDTH}
|
|
509
|
+
/>
|
|
510
|
+
<ResizeHandle
|
|
511
|
+
side="right"
|
|
512
|
+
container={container}
|
|
513
|
+
onResize={setWidth}
|
|
514
|
+
label="Resize listing"
|
|
515
|
+
minWidth={LISTING_MIN_WIDTH}
|
|
516
|
+
maxWidth={LISTING_MAX_WIDTH}
|
|
517
|
+
/>
|
|
518
|
+
</>
|
|
519
|
+
)}
|
|
520
|
+
{sidebar ? (
|
|
521
|
+
<div data-print-hide className="mb-1 flex justify-end">
|
|
522
|
+
<Dropdown
|
|
523
|
+
icon="arrow-up-down"
|
|
524
|
+
size="sm"
|
|
525
|
+
label="Sort"
|
|
526
|
+
value={sort}
|
|
527
|
+
onChange={(next) => setSort(next as SortKey)}
|
|
528
|
+
options={SORT_OPTIONS}
|
|
529
|
+
/>
|
|
530
|
+
</div>
|
|
531
|
+
) : (
|
|
532
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
533
|
+
<div data-print-hide className="flex items-center gap-2">
|
|
534
|
+
<SelectMenu fileEntries={fileEntries} onSelect={setSelectedNames} />
|
|
535
|
+
{selectionActive ? (
|
|
536
|
+
<>
|
|
537
|
+
<span className="font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle tabular-nums">
|
|
538
|
+
{selected.length} selected
|
|
539
|
+
</span>
|
|
540
|
+
<Button variant="ghost" size="sm" onClick={() => setSelectedNames(new Set())}>
|
|
541
|
+
Clear
|
|
542
|
+
</Button>
|
|
543
|
+
<Button
|
|
544
|
+
variant="danger"
|
|
545
|
+
size="sm"
|
|
546
|
+
icon="trash-2"
|
|
547
|
+
onClick={() => setConfirmOpen(true)}
|
|
548
|
+
>
|
|
549
|
+
Delete…
|
|
550
|
+
</Button>
|
|
551
|
+
</>
|
|
552
|
+
) : null}
|
|
553
|
+
</div>
|
|
554
|
+
<div className="flex items-center gap-2 font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle">
|
|
555
|
+
<span>Sort by</span>
|
|
556
|
+
<div className="w-40">
|
|
557
|
+
<Dropdown
|
|
558
|
+
size="sm"
|
|
559
|
+
value={sort}
|
|
560
|
+
onChange={(next) => setSort(next as SortKey)}
|
|
561
|
+
options={SORT_OPTIONS}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
<motion.div
|
|
568
|
+
key={sort}
|
|
569
|
+
variants={stagger}
|
|
570
|
+
initial="initial"
|
|
571
|
+
animate="enter"
|
|
572
|
+
className={
|
|
573
|
+
sidebar
|
|
574
|
+
? "flex flex-col gap-0.5"
|
|
575
|
+
: "divide-y divide-border-subtle border-y border-border-subtle"
|
|
576
|
+
}
|
|
577
|
+
>
|
|
578
|
+
{showParentRow ? (
|
|
579
|
+
<Row
|
|
580
|
+
href={parentHref}
|
|
581
|
+
icon="folder"
|
|
582
|
+
label=".."
|
|
583
|
+
muted={false}
|
|
584
|
+
selectable={false}
|
|
585
|
+
dense={sidebar}
|
|
586
|
+
active={false}
|
|
587
|
+
/>
|
|
588
|
+
) : null}
|
|
589
|
+
{visible.map((entry: ListingEntry) => {
|
|
590
|
+
const href = `${path}${entry.name}${entry.isDir ? "/" : ""}`;
|
|
591
|
+
const common = {
|
|
592
|
+
label: entry.name,
|
|
593
|
+
...(sidebar ? {} : { size: entry.size, mtime: entry.mtime }),
|
|
594
|
+
};
|
|
595
|
+
const dense = sidebar;
|
|
596
|
+
const active = sidebar && href === activePath;
|
|
597
|
+
if (entry.isDir) {
|
|
598
|
+
return (
|
|
599
|
+
<Row
|
|
600
|
+
key={entry.name}
|
|
601
|
+
href={href}
|
|
602
|
+
icon="folder"
|
|
603
|
+
muted={false}
|
|
604
|
+
selectable={false}
|
|
605
|
+
dense={dense}
|
|
606
|
+
active={active}
|
|
607
|
+
{...common}
|
|
608
|
+
/>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const selection = sidebar
|
|
612
|
+
? {}
|
|
613
|
+
: {
|
|
614
|
+
selectable: true,
|
|
615
|
+
checked: selectedNames.has(entry.name),
|
|
616
|
+
selectionActive,
|
|
617
|
+
onToggle: () => toggleSelected(entry.name),
|
|
618
|
+
};
|
|
619
|
+
if (entry.isDoc) {
|
|
620
|
+
return (
|
|
621
|
+
<Row
|
|
622
|
+
key={entry.name}
|
|
623
|
+
href={href}
|
|
624
|
+
icon="file"
|
|
625
|
+
muted={false}
|
|
626
|
+
dense={dense}
|
|
627
|
+
active={active}
|
|
628
|
+
{...common}
|
|
629
|
+
{...selection}
|
|
630
|
+
label={entry.title ?? entry.name}
|
|
631
|
+
labelHtml={entry.titleHtml}
|
|
632
|
+
sublabel={entry.title ? entry.name : undefined}
|
|
633
|
+
/>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return (
|
|
637
|
+
<Row
|
|
638
|
+
key={entry.name}
|
|
639
|
+
href={null}
|
|
640
|
+
icon="file"
|
|
641
|
+
muted
|
|
642
|
+
dense={dense}
|
|
643
|
+
active={active}
|
|
644
|
+
{...common}
|
|
645
|
+
{...selection}
|
|
646
|
+
/>
|
|
647
|
+
);
|
|
648
|
+
})}
|
|
649
|
+
</motion.div>
|
|
650
|
+
{sidebar ? null : (
|
|
651
|
+
<ConfirmDeleteDialog
|
|
652
|
+
open={confirmOpen}
|
|
653
|
+
files={selected}
|
|
654
|
+
pending={pending}
|
|
655
|
+
onCancel={() => {
|
|
656
|
+
if (!pending) setConfirmOpen(false);
|
|
657
|
+
}}
|
|
658
|
+
onConfirm={handleConfirm}
|
|
659
|
+
/>
|
|
660
|
+
)}
|
|
661
|
+
</div>
|
|
662
|
+
);
|
|
663
|
+
}
|