@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 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 display = match[2]?.trim() || target;
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 (onOpenLinkedDoc) {
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
- <div
41
- className={`relative w-0 cursor-col-resize flex-shrink-0 group${className ? ` ${className}` : ''}`}
42
- >
43
- {/* Visible track — 4px wide, centered on the zero-width layout box,
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={`absolute inset-y-0 ${
53
- side === 'left' ? '-right-2 -left-1' :
54
- side === 'right' ? '-right-3 left-0' :
55
- '-inset-x-2'
56
- }`}
57
- style={style}
58
- onPointerDown={onPointerDown}
59
- onDoubleClick={onDoubleClick}
60
- />
61
- {/* Hover-reveal collapse button, centered on the handle. stopPropagation on
62
- pointerdown so clicking it never starts a drag. Hidden mid-drag. */}
63
- {onCollapse && !isDragging && (
64
- <button
65
- type="button"
66
- onPointerDown={(e) => e.stopPropagation()}
67
- onClick={(e) => {
68
- e.stopPropagation();
69
- onCollapse();
70
- }}
71
- title="Collapse sidebar"
72
- aria-label="Collapse sidebar"
73
- data-collapse={side}
74
- 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"
75
- >
76
- <svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
77
- <path
78
- strokeLinecap="round"
79
- strokeLinejoin="round"
80
- d={side === 'right' ? 'M9 5l7 7-7 7' : 'M15 19l-7-7 7-7'}
81
- />
82
- </svg>
83
- </button>
84
- )}
85
- </div>
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
+ };
@@ -1,4 +1,4 @@
1
- import React, {
1
+ import {
2
2
  forwardRef,
3
3
  useCallback,
4
4
  useEffect,
@@ -52,7 +52,6 @@ function postToIframe(iframe: HTMLIFrameElement | null, msg: Record<string, unkn
52
52
 
53
53
  export function useHtmlAnnotation({
54
54
  iframeRef,
55
- annotations,
56
55
  onAddAnnotation,
57
56
  onSelectAnnotation,
58
57
  selectedAnnotationId,
@@ -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
- }> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus }) => {
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="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"
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
- }> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses }) => {
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
- {dirs.map((dir) => {
458
- const isCollapsed = collapsedDirs.has(dir.path);
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>