@plannotator/ui 0.25.0 → 0.27.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/README.md +32 -0
- package/components/InlineMarkdown.tsx +41 -3
- package/components/MarkdownEditor.tsx +46 -0
- package/components/ResizeHandle.tsx +86 -47
- package/components/html-viewer/HtmlViewer.tsx +1 -1
- package/components/html-viewer/useHtmlAnnotation.ts +0 -1
- package/components/sidebar/FileBrowser.tsx +132 -6
- package/hooks/useResizablePanel.ts +36 -5
- package/package.json +3 -3
- package/styles.css +1 -1
- package/theme.css +16 -0
package/README.md
CHANGED
|
@@ -30,6 +30,38 @@ configurePlannotatorUI({
|
|
|
30
30
|
|
|
31
31
|
Anything you don't pass keeps Plannotator's default. A few component-specific overrides (e.g. an "open in editor" diff action) are passed as props where you render that component.
|
|
32
32
|
|
|
33
|
+
### Resize-handle seams (`ResizeHandle` / `useResizablePanel`)
|
|
34
|
+
|
|
35
|
+
The sidebar/panel resize handle exposes seams for hosts that want different edge interactions (e.g. no hover reveal, click-to-collapse). All default to today's behavior — pass nothing and it's unchanged.
|
|
36
|
+
|
|
37
|
+
- **Suppress / restyle the hover reveal.** The inner visible track carries a `[data-resize-track]` attribute (same host-CSS pattern as the collapse button's `[data-collapse]`), and `ResizeHandle` takes a `trackClassName` prop. To kill the pop-in from host CSS:
|
|
38
|
+
```css
|
|
39
|
+
[data-resize-track] { background: none !important; }
|
|
40
|
+
```
|
|
41
|
+
- **Click-to-collapse anywhere on the handle.** The handle can't tell a click from a drag-start on its own — the hook owns the pointer state machine. Pass `onClick` to `useResizablePanel`; it fires on pointer-up only when the pointer never traveled past `clickThreshold` (default 4px), so a genuine click on the full-width handle can collapse the panel while drags still resize:
|
|
42
|
+
```ts
|
|
43
|
+
const resize = useResizablePanel({ storageKey, side: "left", onSnapClose: collapse, onClick: collapse });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle).
|
|
47
|
+
|
|
48
|
+
### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
|
|
49
|
+
|
|
50
|
+
- **`MarkdownEditor` takes CM6 extensions.** `extensions?: readonly Extension[]` (from `@codemirror/state`) is forwarded verbatim into the underlying editor — the seam for `wikiLinks(config)`, `y-codemirror.next` collab bindings, custom keymaps.
|
|
51
|
+
|
|
52
|
+
> **⚠️ Captured once per `documentId` — not reactive.** The engine reads the array a single time at document mount; swapping the array later is silently ignored until a `documentId` change remounts. Pass a stable reference, and feed changing data through extension config callbacks that close over live state (refs/getters) — never through new arrays.
|
|
53
|
+
|
|
54
|
+
- **`wikiLinks` is re-exported from `@plannotator/ui/components/MarkdownEditor`** together with `WikiLinksConfig`, `WikiLinkSuggestion`, `WikiLinkResolvedTarget`, `WikiLinkStatus`. Import it from there — `@plannotator/atomic-editor` stays off the supported-import list:
|
|
55
|
+
```tsx
|
|
56
|
+
import { MarkdownEditor, wikiLinks } from "@plannotator/ui/components/MarkdownEditor";
|
|
57
|
+
|
|
58
|
+
const editorExtensions = [wikiLinks({ suggest, resolve, onOpen })]; // stable reference!
|
|
59
|
+
<MarkdownEditor markdown={md} documentId={docId} editorHandleRef={ref} extensions={editorExtensions} />
|
|
60
|
+
```
|
|
61
|
+
- **The viewer resolves wiki-links synchronously.** `InlineMarkdown` takes `resolveLinkedDoc?: (target) => { label?; status?: 'active' | 'deleted' } | null` — called with the raw stored target (opaque ids like `doc_01XYZ`, no `.md` normalization). Return a `label` to display live titles (stored label is the fallback, target the last resort); return `status: 'deleted'` for a muted non-link ("Document deleted") instead of a live link. Absent or `null` → rendering is unchanged. Sync-only by design: back it with an in-memory cache.
|
|
62
|
+
|
|
63
|
+
Requires `@plannotator/markdown-editor ^0.3.2` and `@plannotator/atomic-editor ^0.7.0`. See HANDOFF.md § "Wiki-link seams (0.27.0)".
|
|
64
|
+
|
|
33
65
|
## Consuming it (e.g. from Workspaces)
|
|
34
66
|
|
|
35
67
|
```bash
|
|
@@ -443,12 +443,28 @@ function emitPlainTextWithBareUrls(
|
|
|
443
443
|
export const InlineMarkdown: React.FC<{
|
|
444
444
|
text: string;
|
|
445
445
|
onOpenLinkedDoc?: (path: string) => void;
|
|
446
|
+
/**
|
|
447
|
+
* SYNCHRONOUS wiki-link resolution (host-backed, e.g. an in-memory cache).
|
|
448
|
+
* Called with the RAW stored target of a `[[target]]` / `[[target|label]]`
|
|
449
|
+
* wiki-link — before any path normalization (no `.md` appended), so opaque
|
|
450
|
+
* ids like `doc_01XYZ` arrive verbatim.
|
|
451
|
+
*
|
|
452
|
+
* Absent, or returning null/undefined → exactly today's rendering (stored
|
|
453
|
+
* label, live link). `label` → displayed instead of the stored label (the
|
|
454
|
+
* stored label is the fallback, the target the last resort). `status:
|
|
455
|
+
* 'deleted'` → a muted, non-interactive span (no anchor, no link icon,
|
|
456
|
+
* `onOpenLinkedDoc` is NOT wired) titled "Document deleted".
|
|
457
|
+
*
|
|
458
|
+
* Deliberately sync-only: no async variant, no loading states. Back it
|
|
459
|
+
* with a cache you keep hydrated.
|
|
460
|
+
*/
|
|
461
|
+
resolveLinkedDoc?: (target: string) => { label?: string; status?: 'active' | 'deleted' } | null;
|
|
446
462
|
onOpenCodeFile?: (path: string) => void;
|
|
447
463
|
onNavigateAnchor?: (hash: string) => void;
|
|
448
464
|
imageBaseDir?: string;
|
|
449
465
|
onImageClick?: (src: string, alt: string) => void;
|
|
450
466
|
githubRepo?: string;
|
|
451
|
-
}> = ({ text, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
|
|
467
|
+
}> = ({ text, onOpenLinkedDoc, resolveLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
|
|
452
468
|
const validation = useCodePathValidation();
|
|
453
469
|
const parts: React.ReactNode[] = [];
|
|
454
470
|
let remaining = text;
|
|
@@ -614,6 +630,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
614
630
|
onImageClick={onImageClick}
|
|
615
631
|
text={match[1]}
|
|
616
632
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
633
|
+
resolveLinkedDoc={resolveLinkedDoc}
|
|
617
634
|
onOpenCodeFile={onOpenCodeFile}
|
|
618
635
|
onNavigateAnchor={onNavigateAnchor}
|
|
619
636
|
githubRepo={githubRepo}
|
|
@@ -636,6 +653,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
636
653
|
onImageClick={onImageClick}
|
|
637
654
|
text={match[1]}
|
|
638
655
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
656
|
+
resolveLinkedDoc={resolveLinkedDoc}
|
|
639
657
|
onOpenCodeFile={onOpenCodeFile}
|
|
640
658
|
onNavigateAnchor={onNavigateAnchor}
|
|
641
659
|
githubRepo={githubRepo}
|
|
@@ -658,6 +676,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
658
676
|
onImageClick={onImageClick}
|
|
659
677
|
text={match[1]}
|
|
660
678
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
679
|
+
resolveLinkedDoc={resolveLinkedDoc}
|
|
661
680
|
onOpenCodeFile={onOpenCodeFile}
|
|
662
681
|
onNavigateAnchor={onNavigateAnchor}
|
|
663
682
|
githubRepo={githubRepo}
|
|
@@ -679,6 +698,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
679
698
|
onImageClick={onImageClick}
|
|
680
699
|
text={match[1]}
|
|
681
700
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
701
|
+
resolveLinkedDoc={resolveLinkedDoc}
|
|
682
702
|
onOpenCodeFile={onOpenCodeFile}
|
|
683
703
|
onNavigateAnchor={onNavigateAnchor}
|
|
684
704
|
githubRepo={githubRepo}
|
|
@@ -701,6 +721,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
701
721
|
onImageClick={onImageClick}
|
|
702
722
|
text={match[1]}
|
|
703
723
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
724
|
+
resolveLinkedDoc={resolveLinkedDoc}
|
|
704
725
|
onOpenCodeFile={onOpenCodeFile}
|
|
705
726
|
onNavigateAnchor={onNavigateAnchor}
|
|
706
727
|
githubRepo={githubRepo}
|
|
@@ -834,12 +855,28 @@ export const InlineMarkdown: React.FC<{
|
|
|
834
855
|
match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
|
|
835
856
|
if (match) {
|
|
836
857
|
const target = match[1].trim();
|
|
837
|
-
const
|
|
858
|
+
const storedLabel = match[2]?.trim();
|
|
859
|
+
// Host resolution gets the RAW target (pre-`.md` normalization): host
|
|
860
|
+
// targets are opaque doc ids, not paths. null → today's rendering.
|
|
861
|
+
const resolution = resolveLinkedDoc?.(target) ?? null;
|
|
862
|
+
const display = resolution?.label || storedLabel || target;
|
|
838
863
|
const targetPath = /\.(mdx?|txt|html?)$/i.test(target)
|
|
839
864
|
? target
|
|
840
865
|
: `${target}.md`;
|
|
841
866
|
|
|
842
|
-
if (
|
|
867
|
+
if (resolution?.status === 'deleted') {
|
|
868
|
+
// Deleted doc: muted NON-link — no anchor, no pointer, no link icon —
|
|
869
|
+
// even when onOpenLinkedDoc is present.
|
|
870
|
+
parts.push(
|
|
871
|
+
<span
|
|
872
|
+
key={key++}
|
|
873
|
+
className="text-muted-foreground line-through decoration-muted-foreground/60"
|
|
874
|
+
title="Document deleted"
|
|
875
|
+
>
|
|
876
|
+
{display}
|
|
877
|
+
</span>,
|
|
878
|
+
);
|
|
879
|
+
} else if (onOpenLinkedDoc) {
|
|
843
880
|
parts.push(
|
|
844
881
|
<a
|
|
845
882
|
key={key++}
|
|
@@ -1061,6 +1098,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
1061
1098
|
key={key++}
|
|
1062
1099
|
text={before}
|
|
1063
1100
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
1101
|
+
resolveLinkedDoc={resolveLinkedDoc}
|
|
1064
1102
|
onOpenCodeFile={onOpenCodeFile}
|
|
1065
1103
|
onNavigateAnchor={onNavigateAnchor}
|
|
1066
1104
|
githubRepo={githubRepo}
|
|
@@ -3,11 +3,37 @@ import {
|
|
|
3
3
|
MarkdownEditor as PackagedMarkdownEditor,
|
|
4
4
|
type MarkdownEditorHandle,
|
|
5
5
|
} from '@plannotator/markdown-editor';
|
|
6
|
+
import type { Extension } from '@codemirror/state';
|
|
6
7
|
import '@plannotator/markdown-editor/themes/plannotator.css';
|
|
7
8
|
import { useTheme } from './ThemeProvider';
|
|
8
9
|
|
|
9
10
|
export type { MarkdownEditorHandle };
|
|
10
11
|
|
|
12
|
+
/* Wiki-links, re-exported through the ui surface. @plannotator/ui is the single
|
|
13
|
+
supported contract for hosts — do NOT import @plannotator/atomic-editor
|
|
14
|
+
directly (it's outside the import allowlist). Build the extension with
|
|
15
|
+
wikiLinks(config) and pass it through the `extensions` prop below; the
|
|
16
|
+
capture-once-per-documentId caveat on `extensions` applies (config callbacks
|
|
17
|
+
like suggest/resolve/onOpen may close over live state). */
|
|
18
|
+
export { wikiLinks } from '@plannotator/atomic-editor';
|
|
19
|
+
export type {
|
|
20
|
+
WikiLinksConfig,
|
|
21
|
+
WikiLinkSuggestion,
|
|
22
|
+
WikiLinkResolvedTarget,
|
|
23
|
+
WikiLinkStatus,
|
|
24
|
+
} from '@plannotator/atomic-editor';
|
|
25
|
+
|
|
26
|
+
/* The engine's other opt-in UI extensions, re-exported for the same reason:
|
|
27
|
+
slashCommands() is a Notion-style insert menu on `/` at the start of a
|
|
28
|
+
line; selectionToolbar() is a floating bold/italic/strike/code/link bar
|
|
29
|
+
over selected text (multi-line and table-cell aware). Both compose
|
|
30
|
+
through the `extensions` prop and theme via the --atomic-editor-menu-*
|
|
31
|
+
CSS variables. */
|
|
32
|
+
export { defaultSlashCommands, slashCommandSource, slashCommands } from '@plannotator/atomic-editor';
|
|
33
|
+
export type { SlashCommandItem, SlashCommandsConfig } from '@plannotator/atomic-editor';
|
|
34
|
+
export { selectionToolbar } from '@plannotator/atomic-editor';
|
|
35
|
+
export type { SelectionToolbarConfig, InlineFormat } from '@plannotator/atomic-editor';
|
|
36
|
+
|
|
11
37
|
/* Grid-mode card utilities stay here (not in the package): they're Plannotator
|
|
12
38
|
design-system Tailwind classes, and this file is @source-scanned. */
|
|
13
39
|
const GRID_CARD_CLASSES = 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50';
|
|
@@ -27,6 +53,26 @@ interface MarkdownEditorProps {
|
|
|
27
53
|
/** Theme color mode. Defaults to the ThemeProvider's resolved mode (Plannotator
|
|
28
54
|
passes nothing); a host without ThemeProvider can supply it directly. */
|
|
29
55
|
mode?: React.ComponentProps<typeof PackagedMarkdownEditor>['mode'];
|
|
56
|
+
/**
|
|
57
|
+
* Extra CodeMirror 6 extensions, forwarded verbatim to the underlying editor
|
|
58
|
+
* (e.g. `wikiLinks(config)`, collaboration bindings like y-codemirror.next).
|
|
59
|
+
* They are appended after the editor's built-ins, so they compose on top —
|
|
60
|
+
* use `Prec.high` from `@codemirror/state` when an extension must beat a
|
|
61
|
+
* built-in keymap. Build them against YOUR copy of the `@codemirror/*`
|
|
62
|
+
* packages; two live copies of `@codemirror/state` break the editor.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ CAPTURED ONCE PER `documentId` — NOT reactive. The engine reads this
|
|
65
|
+
* array a single time, when it mounts the document (keyed on `documentId`).
|
|
66
|
+
* Swapping in a different array afterwards does NOT re-apply it: the change
|
|
67
|
+
* is silently ignored until the next remount (i.e. a `documentId` change).
|
|
68
|
+
* Therefore:
|
|
69
|
+
* - pass a stable reference (module constant, or `useMemo` keyed on
|
|
70
|
+
* `documentId`), and
|
|
71
|
+
* - never encode changing data in the array itself. Extension config
|
|
72
|
+
* callbacks MAY close over live state (refs/getters) — that is the
|
|
73
|
+
* supported way to feed dynamic data into a mounted editor.
|
|
74
|
+
*/
|
|
75
|
+
extensions?: readonly Extension[];
|
|
30
76
|
}
|
|
31
77
|
|
|
32
78
|
/* Theme-bridging shim around @plannotator/markdown-editor. App.tsx renders its
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import type { ResizeHandleProps as BaseProps } from '../hooks/useResizablePanel';
|
|
3
4
|
|
|
4
5
|
interface Props extends BaseProps {
|
|
5
6
|
className?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Extra classes for the inner visible track (the 4px bar that reveals on
|
|
9
|
+
* hover). `className` only reaches the outer wrapper, so this is the seam for
|
|
10
|
+
* restyling or suppressing the hover affordance. The track also carries a
|
|
11
|
+
* `[data-resize-track]` attribute (same pattern as `[data-collapse]`) so a
|
|
12
|
+
* host can target it from plain CSS — e.g. to kill the hover reveal:
|
|
13
|
+
* `[data-resize-track] { background: none !important; }`.
|
|
14
|
+
*/
|
|
15
|
+
trackClassName?: string;
|
|
16
|
+
/** Suppress the hover color-change on the track entirely (no pop-in). */
|
|
17
|
+
hideHoverTrack?: boolean;
|
|
18
|
+
/** Cursor-following hint shown while hovering the handle (hidden mid-drag). */
|
|
19
|
+
tooltip?: React.ReactNode;
|
|
6
20
|
/** When provided, renders a hover-reveal collapse button centered on the
|
|
7
21
|
* handle (prototype's gutter affordance). Chevron direction follows `side`:
|
|
8
22
|
* a left sidebar collapses leftward, a right sidebar rightward. */
|
|
@@ -34,53 +48,78 @@ export const ResizeHandle: React.FC<Props> = ({
|
|
|
34
48
|
onDoubleClick,
|
|
35
49
|
style,
|
|
36
50
|
className,
|
|
51
|
+
trackClassName,
|
|
52
|
+
hideHoverTrack,
|
|
53
|
+
tooltip,
|
|
37
54
|
side,
|
|
38
55
|
onCollapse,
|
|
39
|
-
}) =>
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
invisible until hover/drag. */}
|
|
45
|
-
<div className={`absolute inset-y-0 -left-0.5 -right-0.5 transition-colors ${
|
|
46
|
-
isDragging ? 'bg-transparent' : 'group-hover:bg-border'
|
|
47
|
-
}`} />
|
|
48
|
-
{/* Wider grab/touch zone — must never have zero width (see `side` docs).
|
|
49
|
-
Pointer events + setPointerCapture live here; touch-action:none (from
|
|
50
|
-
style) stops touch drags from scroll-hijacking. */}
|
|
56
|
+
}) => {
|
|
57
|
+
const [tip, setTip] = React.useState<{ x: number; y: number } | null>(null);
|
|
58
|
+
const showTip = tooltip != null && tip != null && !isDragging;
|
|
59
|
+
|
|
60
|
+
return (
|
|
51
61
|
<div
|
|
52
|
-
className={`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
62
|
+
className={`relative w-0 cursor-col-resize flex-shrink-0 group${className ? ` ${className}` : ''}`}
|
|
63
|
+
>
|
|
64
|
+
{/* Visible track — 4px wide, centered on the zero-width layout box,
|
|
65
|
+
invisible until hover/drag. `data-resize-track` + `trackClassName` are
|
|
66
|
+
host seams for restyling/suppressing the hover reveal. */}
|
|
67
|
+
<div
|
|
68
|
+
data-resize-track={side ?? ''}
|
|
69
|
+
className={`absolute inset-y-0 -left-0.5 -right-0.5 transition-colors ${
|
|
70
|
+
isDragging || hideHoverTrack ? 'bg-transparent' : 'group-hover:bg-border'
|
|
71
|
+
}${trackClassName ? ` ${trackClassName}` : ''}`}
|
|
72
|
+
/>
|
|
73
|
+
{/* Wider grab/touch zone — must never have zero width (see `side` docs).
|
|
74
|
+
Pointer events + setPointerCapture live here; touch-action:none (from
|
|
75
|
+
style) stops touch drags from scroll-hijacking. */}
|
|
76
|
+
<div
|
|
77
|
+
className={`absolute inset-y-0 ${
|
|
78
|
+
side === 'left' ? '-right-2 -left-1' :
|
|
79
|
+
side === 'right' ? '-right-3 left-0' :
|
|
80
|
+
'-inset-x-2'
|
|
81
|
+
}`}
|
|
82
|
+
style={style}
|
|
83
|
+
onPointerDown={onPointerDown}
|
|
84
|
+
onDoubleClick={onDoubleClick}
|
|
85
|
+
onPointerMove={tooltip != null && !isDragging ? (e) => setTip({ x: e.clientX, y: e.clientY }) : undefined}
|
|
86
|
+
onPointerLeave={tooltip != null ? () => setTip(null) : undefined}
|
|
87
|
+
/>
|
|
88
|
+
{/* Cursor-following hint. pointer-events-none so it never blocks the drag;
|
|
89
|
+
portaled to body so the zero-width parent doesn't clip it. */}
|
|
90
|
+
{showTip && typeof document !== 'undefined' && createPortal(
|
|
91
|
+
<div
|
|
92
|
+
style={{ position: 'fixed', left: tip.x + 14, top: tip.y + 16, zIndex: 100 }}
|
|
93
|
+
className="pointer-events-none whitespace-nowrap rounded-md bg-surface-1 px-2 py-1 text-xs text-foreground shadow-md ring-1 ring-border/60"
|
|
94
|
+
>
|
|
95
|
+
{tooltip}
|
|
96
|
+
</div>,
|
|
97
|
+
document.body,
|
|
98
|
+
)}
|
|
99
|
+
{/* Hover-reveal collapse button, centered on the handle. stopPropagation on
|
|
100
|
+
pointerdown so clicking it never starts a drag. Hidden mid-drag. */}
|
|
101
|
+
{onCollapse && !isDragging && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
onCollapse();
|
|
108
|
+
}}
|
|
109
|
+
title="Collapse sidebar"
|
|
110
|
+
aria-label="Collapse sidebar"
|
|
111
|
+
data-collapse={side}
|
|
112
|
+
className="absolute top-1/2 left-1/2 z-20 flex h-6 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center before:absolute before:-inset-2 before:content-[''] rounded-sm bg-surface-1 text-muted-foreground/60 opacity-0 ring-1 ring-border/40 transition-opacity hover:text-foreground group-hover:opacity-100 group-hover/sidebar:opacity-100"
|
|
113
|
+
>
|
|
114
|
+
<svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
115
|
+
<path
|
|
116
|
+
strokeLinecap="round"
|
|
117
|
+
strokeLinejoin="round"
|
|
118
|
+
d={side === 'right' ? 'M9 5l7 7-7 7' : 'M15 19l-7-7 7-7'}
|
|
119
|
+
/>
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
|
+
import { Search, X } from "lucide-react";
|
|
9
10
|
import type { VaultNode } from "../../types";
|
|
10
11
|
import type { DirState } from "../../hooks/useFileBrowser";
|
|
11
12
|
import { CountBadge } from "./CountBadge";
|
|
@@ -42,6 +43,40 @@ interface AggregateWorkspaceChange {
|
|
|
42
43
|
files: number;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const FILE_EXTENSION_RE = /\.(mdx?|txt|html?)$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeFilterText(value: string): string {
|
|
49
|
+
return value.replace(/\\/g, "/").toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFileTreeFilterTokens(query: string): string[] {
|
|
53
|
+
return normalizeFilterText(query)
|
|
54
|
+
.trim()
|
|
55
|
+
.split(/\s+/)
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nodeMatchesFilter(node: VaultNode, tokens: string[]): boolean {
|
|
60
|
+
if (tokens.length === 0) return true;
|
|
61
|
+
const displayName = node.name.replace(FILE_EXTENSION_RE, "");
|
|
62
|
+
const haystack = normalizeFilterText(`${node.name} ${displayName} ${node.path}`);
|
|
63
|
+
return tokens.every((token) => haystack.includes(token));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function filterFileTree(nodes: VaultNode[], tokens: string[]): VaultNode[] {
|
|
67
|
+
if (tokens.length === 0) return nodes;
|
|
68
|
+
|
|
69
|
+
return nodes.flatMap((node) => {
|
|
70
|
+
if (node.type === "file") return nodeMatchesFilter(node, tokens) ? [node] : [];
|
|
71
|
+
|
|
72
|
+
if (nodeMatchesFilter(node, tokens)) return [node];
|
|
73
|
+
|
|
74
|
+
const children = filterFileTree(node.children ?? [], tokens);
|
|
75
|
+
if (children.length === 0) return [];
|
|
76
|
+
return [{ ...node, children }];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
45
80
|
export function normalizePathForLookup(path: string): string {
|
|
46
81
|
return normalizeBrowserPath(path);
|
|
47
82
|
}
|
|
@@ -207,21 +242,26 @@ const TreeNode: React.FC<{
|
|
|
207
242
|
highlightedFiles?: Set<string>;
|
|
208
243
|
editStatuses?: Map<string, FileEditStatus>;
|
|
209
244
|
workspaceStatus?: WorkspaceStatusPayload;
|
|
210
|
-
|
|
245
|
+
forceExpandFolders?: boolean;
|
|
246
|
+
}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false }) => {
|
|
211
247
|
const folderKey = `${dirPath}:${node.path}`;
|
|
212
248
|
const absolutePath = `${dirPath}/${node.path}`;
|
|
213
|
-
const isExpanded = expandedFolders.has(folderKey);
|
|
249
|
+
const isExpanded = forceExpandFolders || expandedFolders.has(folderKey);
|
|
214
250
|
const isActive = node.type === "file" && absolutePath === activeFile;
|
|
215
251
|
const paddingLeft = 8 + depth * 14;
|
|
216
252
|
|
|
217
253
|
if (node.type === "folder") {
|
|
218
254
|
const aggregateCount = annotationCounts ? getAggregateCount(node, dirPath, annotationCounts, workspaceStatus) : 0;
|
|
219
255
|
const aggregateChange = getAggregateWorkspaceChange(node, dirPath, workspaceStatus);
|
|
256
|
+
const folderButtonClassName = forceExpandFolders
|
|
257
|
+
? "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground transition-colors rounded-sm cursor-default disabled:opacity-100"
|
|
258
|
+
: "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm";
|
|
220
259
|
return (
|
|
221
260
|
<>
|
|
222
261
|
<button
|
|
262
|
+
disabled={forceExpandFolders}
|
|
223
263
|
onClick={() => onToggleFolder(folderKey)}
|
|
224
|
-
className=
|
|
264
|
+
className={folderButtonClassName}
|
|
225
265
|
style={{ paddingLeft }}
|
|
226
266
|
>
|
|
227
267
|
<svg
|
|
@@ -261,6 +301,7 @@ const TreeNode: React.FC<{
|
|
|
261
301
|
highlightedFiles={highlightedFiles}
|
|
262
302
|
editStatuses={editStatuses}
|
|
263
303
|
workspaceStatus={workspaceStatus}
|
|
304
|
+
forceExpandFolders={forceExpandFolders}
|
|
264
305
|
/>
|
|
265
306
|
))}
|
|
266
307
|
</>
|
|
@@ -348,7 +389,8 @@ const DirSection: React.FC<{
|
|
|
348
389
|
annotationCounts?: Map<string, number>;
|
|
349
390
|
highlightedFiles?: Set<string>;
|
|
350
391
|
editStatuses?: Map<string, FileEditStatus>;
|
|
351
|
-
|
|
392
|
+
forceExpandFolders?: boolean;
|
|
393
|
+
}> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false }) => {
|
|
352
394
|
const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]);
|
|
353
395
|
|
|
354
396
|
if (dir.isLoading) {
|
|
@@ -397,6 +439,7 @@ const DirSection: React.FC<{
|
|
|
397
439
|
highlightedFiles={highlightedFiles}
|
|
398
440
|
editStatuses={editStatuses}
|
|
399
441
|
workspaceStatus={workspaceStatus}
|
|
442
|
+
forceExpandFolders={forceExpandFolders}
|
|
400
443
|
/>
|
|
401
444
|
))}
|
|
402
445
|
</div>
|
|
@@ -417,6 +460,30 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
417
460
|
highlightedFiles,
|
|
418
461
|
editStatuses,
|
|
419
462
|
}) => {
|
|
463
|
+
const [isFilterOpen, setIsFilterOpen] = React.useState(false);
|
|
464
|
+
const [filterQuery, setFilterQuery] = React.useState("");
|
|
465
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
466
|
+
const deferredFilterQuery = React.useDeferredValue(filterQuery);
|
|
467
|
+
const filterTokens = React.useMemo(() => getFileTreeFilterTokens(deferredFilterQuery), [deferredFilterQuery]);
|
|
468
|
+
const isFiltering = filterTokens.length > 0;
|
|
469
|
+
const showFilterInput = isFilterOpen || filterQuery.trim().length > 0;
|
|
470
|
+
const visibleDirs = React.useMemo(() => {
|
|
471
|
+
if (!isFiltering) return dirs;
|
|
472
|
+
return dirs
|
|
473
|
+
.map((dir) => ({ ...dir, tree: filterFileTree(dir.tree, filterTokens) }))
|
|
474
|
+
.filter((dir) => dir.isLoading || dir.error || dir.tree.length > 0);
|
|
475
|
+
}, [dirs, filterTokens, isFiltering]);
|
|
476
|
+
const handleFilterBlur = React.useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
|
477
|
+
if (filterQuery.trim()) return;
|
|
478
|
+
if (event.currentTarget.contains(event.relatedTarget)) return;
|
|
479
|
+
setIsFilterOpen(false);
|
|
480
|
+
}, [filterQuery]);
|
|
481
|
+
|
|
482
|
+
React.useEffect(() => {
|
|
483
|
+
if (!showFilterInput) return;
|
|
484
|
+
inputRef.current?.focus();
|
|
485
|
+
}, [showFilterInput]);
|
|
486
|
+
|
|
420
487
|
if (dirs.length === 0) {
|
|
421
488
|
return (
|
|
422
489
|
<div className="p-3 text-[11px] text-muted-foreground">
|
|
@@ -454,8 +521,66 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
454
521
|
{workspaceTotals.deletions > 0 && <span className={`deletions ${workspaceTotals.additions > 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}</span>}
|
|
455
522
|
</div>
|
|
456
523
|
)}
|
|
457
|
-
|
|
458
|
-
|
|
524
|
+
<div className="border-b border-border/20 px-2 py-0.5">
|
|
525
|
+
{showFilterInput ? (
|
|
526
|
+
<div
|
|
527
|
+
onBlur={handleFilterBlur}
|
|
528
|
+
className="flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
|
|
529
|
+
>
|
|
530
|
+
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
|
|
531
|
+
<input
|
|
532
|
+
ref={inputRef}
|
|
533
|
+
type="search"
|
|
534
|
+
value={filterQuery}
|
|
535
|
+
onChange={(event) => setFilterQuery(event.target.value)}
|
|
536
|
+
onKeyDown={(event) => {
|
|
537
|
+
if (event.key !== "Escape") return;
|
|
538
|
+
if (filterQuery) setFilterQuery("");
|
|
539
|
+
else setIsFilterOpen(false);
|
|
540
|
+
}}
|
|
541
|
+
placeholder="Filter"
|
|
542
|
+
aria-label="Filter files"
|
|
543
|
+
autoComplete="off"
|
|
544
|
+
spellCheck={false}
|
|
545
|
+
data-lpignore="true"
|
|
546
|
+
data-1p-ignore
|
|
547
|
+
className="file-browser-filter-input h-full min-w-0 flex-1 bg-transparent p-0 text-[16px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/45 sm:text-[11px]"
|
|
548
|
+
/>
|
|
549
|
+
{filterQuery && (
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
onClick={() => {
|
|
553
|
+
setFilterQuery("");
|
|
554
|
+
inputRef.current?.focus();
|
|
555
|
+
}}
|
|
556
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
|
|
557
|
+
aria-label="Clear file filter"
|
|
558
|
+
title="Clear file filter"
|
|
559
|
+
>
|
|
560
|
+
<X size={12} aria-hidden="true" />
|
|
561
|
+
</button>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
) : (
|
|
565
|
+
<button
|
|
566
|
+
type="button"
|
|
567
|
+
onClick={() => setIsFilterOpen(true)}
|
|
568
|
+
className="flex h-6 w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[10px] text-muted-foreground hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:bg-muted/50 focus-visible:text-foreground"
|
|
569
|
+
aria-label="Filter files"
|
|
570
|
+
title="Filter files"
|
|
571
|
+
>
|
|
572
|
+
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
|
|
573
|
+
<span className="truncate">Filter</span>
|
|
574
|
+
</button>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
{isFiltering && visibleDirs.length === 0 && (
|
|
578
|
+
<div className="px-3 py-8 text-center text-[11px] text-muted-foreground">
|
|
579
|
+
No files match "{deferredFilterQuery.trim()}"
|
|
580
|
+
</div>
|
|
581
|
+
)}
|
|
582
|
+
{visibleDirs.map((dir) => {
|
|
583
|
+
const isCollapsed = !isFiltering && collapsedDirs.has(dir.path);
|
|
459
584
|
return (
|
|
460
585
|
<div key={dir.path}>
|
|
461
586
|
<button
|
|
@@ -488,6 +613,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
488
613
|
annotationCounts={annotationCounts}
|
|
489
614
|
highlightedFiles={highlightedFiles}
|
|
490
615
|
editStatuses={editStatuses}
|
|
616
|
+
forceExpandFolders={isFiltering}
|
|
491
617
|
/>
|
|
492
618
|
)}
|
|
493
619
|
</div>
|