@plannotator/ui 0.24.0 → 0.26.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.
@@ -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>
@@ -23,6 +23,16 @@ interface UseResizablePanelOptions {
23
23
  * (still rAF-coalesced).
24
24
  */
25
25
  apply?: (width: number) => void;
26
+ /**
27
+ * Fires on pointer-up when the pointer never moved beyond `clickThreshold` —
28
+ * i.e. a genuine click on the handle, not a drag. The hook owns the pointer
29
+ * state machine, so this is the only reliable way to tell a click from a
30
+ * drag-start. Lets a host make the whole handle a click target (e.g. click
31
+ * anywhere on the handle to collapse the panel). Not fired on a snap-close.
32
+ */
33
+ onClick?: () => void;
34
+ /** Max pointer travel (px) still counted as a click for `onClick`. Default 4. */
35
+ clickThreshold?: number;
26
36
  }
27
37
 
28
38
  export interface ResizeHandleProps {
@@ -43,6 +53,8 @@ export function useResizablePanel({
43
53
  onSnapClose,
44
54
  snapCloseRatio = 0.6,
45
55
  apply,
56
+ onClick,
57
+ clickThreshold = 4,
46
58
  }: UseResizablePanelOptions) {
47
59
  const [width, setWidth] = useState(() => {
48
60
  const saved = storage.getItem(storageKey);
@@ -61,6 +73,9 @@ export function useResizablePanel({
61
73
  const startWidthRef = useRef(0);
62
74
  const draggingRef = useRef(false);
63
75
  const snappedRef = useRef(false);
76
+ // Latches true once the pointer travels past clickThreshold — distinguishes a
77
+ // click from a drag. Reset on each pointerdown.
78
+ const movedRef = useRef(false);
64
79
  const latestXRef = useRef(0);
65
80
  const rafRef = useRef<number | null>(null);
66
81
 
@@ -69,6 +84,8 @@ export function useResizablePanel({
69
84
  applyRef.current = apply;
70
85
  const onSnapCloseRef = useRef(onSnapClose);
71
86
  onSnapCloseRef.current = onSnapClose;
87
+ const onClickRef = useRef(onClick);
88
+ onClickRef.current = onClick;
72
89
 
73
90
  // rAF tick: compute the width from the most recent pointer position and apply
74
91
  // it. At most one DOM/state write per frame regardless of pointer-event rate.
@@ -110,6 +127,7 @@ export function useResizablePanel({
110
127
  startWidthRef.current = widthRef.current;
111
128
  latestXRef.current = pos;
112
129
  snappedRef.current = false;
130
+ movedRef.current = false;
113
131
  draggingRef.current = true;
114
132
  setIsDragging(true);
115
133
 
@@ -120,6 +138,8 @@ export function useResizablePanel({
120
138
  const onMove = (ev: PointerEvent) => {
121
139
  if (!draggingRef.current) return;
122
140
  latestXRef.current = axis === 'y' ? ev.clientY : ev.clientX;
141
+ if (Math.abs(latestXRef.current - startXRef.current) > clickThreshold)
142
+ movedRef.current = true;
123
143
  if (rafRef.current == null) rafRef.current = requestAnimationFrame(flush);
124
144
  };
125
145
  const cleanup = () => {
@@ -127,7 +147,11 @@ export function useResizablePanel({
127
147
  window.removeEventListener('pointerup', onUp);
128
148
  window.removeEventListener('pointercancel', onUp);
129
149
  };
130
- function onUp() {
150
+ function onUp(e: PointerEvent) {
151
+ // pointercancel = the browser aborted the gesture (palm rejection, a
152
+ // system gesture, focus loss). It is NOT a completed click — only clean
153
+ // up drag state, never treat it as a click-to-collapse.
154
+ const cancelled = e?.type === 'pointercancel';
131
155
  const wasSnapped = snappedRef.current;
132
156
  draggingRef.current = false;
133
157
  snappedRef.current = false;
@@ -137,9 +161,16 @@ export function useResizablePanel({
137
161
  }
138
162
  setIsDragging(false);
139
163
  if (!wasSnapped) {
140
- // Commit the live width to React state + persist.
141
- setWidth(widthRef.current);
142
- storage.setItem(storageKey, String(widthRef.current));
164
+ if (onClickRef.current && !movedRef.current && !cancelled) {
165
+ // A pointerdown+up that never crossed the threshold is a click, not
166
+ // a resize — fire onClick and leave the width untouched. Only when a
167
+ // host opts in via onClick; otherwise commit exactly as before.
168
+ onClickRef.current();
169
+ } else {
170
+ // Commit the live width to React state + persist.
171
+ setWidth(widthRef.current);
172
+ storage.setItem(storageKey, String(widthRef.current));
173
+ }
143
174
  }
144
175
  cleanup();
145
176
  }
@@ -148,7 +179,7 @@ export function useResizablePanel({
148
179
  window.addEventListener('pointerup', onUp);
149
180
  window.addEventListener('pointercancel', onUp);
150
181
  },
151
- [flush, storageKey, axis],
182
+ [flush, storageKey, axis, clickThreshold],
152
183
  );
153
184
 
154
185
  const resetWidth = useCallback(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",