Enhance FilesView with breadcrumbs, drafts, and editor UX (#363)

* feat: enable search panel in CodeMirrorEditor

Add enableSearch and searchOpen props to toggle search UI
Wire CodeMirror search extension and keybindings into the editor
Automatically open or close the search panel based on searchOpen

* feat(files): add breadcrumb navigation and inline comments

Add breadcrumb trail derived from file path and root to navigate directories
Introduce inline comment UI with draft storage for per-file discussions
Enable long-press gestures on items to reveal quick actions

* feat: add useLongPress hook for long-press gestures

Provide a reusable long-press hook with onLongPress and onTap callbacks.
Support pointer and touch events with movement threshold to cancel.
Optionally vibrate on long press for haptic feedback.

* feat: add expandedPaths state and expand actions for file tabs

Introduce expandedPaths per root to remember expanded folders
Add actions to toggle, expand a path, and expand multiple paths
Initialize expandedPaths for new roots and clamp state to valid roots

* feat: add onSearchOpenChange prop to CodeMirrorEditor

Expose onSearchOpenChange callback prop for search state
Notify parent when search panel visibility toggles
This commit is contained in:
Nelson Pires
2026-02-09 03:24:25 +02:00
committed by GitHub
parent 6d0419149a
commit baad4b6097
4 changed files with 710 additions and 329 deletions
@@ -5,6 +5,7 @@ import { Compartment, EditorState, RangeSetBuilder, StateField } from '@codemirr
import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType, gutters, keymap, lineNumbers } from '@codemirror/view';
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { search, searchKeymap, openSearchPanel, closeSearchPanel } from '@codemirror/search';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
@@ -26,6 +27,9 @@ type CodeMirrorEditorProps = {
blockWidgets?: BlockWidgetDef[];
onViewReady?: (view: EditorView) => void;
onViewDestroy?: () => void;
enableSearch?: boolean;
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
};
const lineNumbersCompartment = new Compartment();
@@ -33,6 +37,7 @@ const editableCompartment = new Compartment();
const externalExtensionsCompartment = new Compartment();
const highlightLinesCompartment = new Compartment();
const blockWidgetsCompartment = new Compartment();
const searchCompartment = new Compartment();
// Map to store widget container elements by ID
// This allows us to render portals into them even if they are created by CM
@@ -153,6 +158,8 @@ export function CodeMirrorEditor({
onViewReady,
onViewDestroy,
blockWidgets,
enableSearch,
searchOpen,
}: CodeMirrorEditorProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
@@ -203,6 +210,7 @@ export function CodeMirrorEditor({
externalExtensionsCompartment.of(extensions ?? []),
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets)),
searchCompartment.of(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
],
});
@@ -236,9 +244,22 @@ export function CodeMirrorEditor({
externalExtensionsCompartment.reconfigure(extensions ?? []),
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets)),
searchCompartment.reconfigure(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []),
],
});
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets]);
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]);
React.useEffect(() => {
const view = viewRef.current;
if (!view || enableSearch === false) {
return;
}
if (searchOpen) {
openSearchPanel(view);
} else {
closeSearchPanel(view);
}
}, [searchOpen, enableSearch]);
React.useEffect(() => {
const view = viewRef.current;
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
import { useRef, useCallback, useEffect } from 'react';
type LongPressOptions = {
delay?: number;
onLongPress: () => void;
onTap?: () => void;
enableHaptic?: boolean;
};
export function useLongPress({
delay = 500,
onLongPress,
onTap,
enableHaptic = true,
}: LongPressOptions) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isLongPressRef = useRef(false);
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
startPosRef.current = null;
}, []);
const onPointerDown = useCallback((e: React.PointerEvent | React.TouchEvent) => {
isLongPressRef.current = false;
// Store start position to detect movement
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.PointerEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.PointerEvent).clientY;
startPosRef.current = { x: clientX, y: clientY };
timerRef.current = setTimeout(() => {
isLongPressRef.current = true;
if (enableHaptic && typeof navigator !== 'undefined' && navigator.vibrate) {
try {
navigator.vibrate(15);
} catch {
// Ignore vibration errors
}
}
onLongPress();
}, delay);
}, [delay, onLongPress, enableHaptic]);
const onPointerMove = useCallback((e: React.PointerEvent | React.TouchEvent) => {
if (!startPosRef.current || !timerRef.current) return;
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.PointerEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.PointerEvent).clientY;
// If moved more than 10px, cancel long press
const dx = Math.abs(clientX - startPosRef.current.x);
const dy = Math.abs(clientY - startPosRef.current.y);
if (dx > 10 || dy > 10) {
clear();
}
}, [clear]);
const onPointerUp = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
if (!isLongPressRef.current && onTap) {
// Only trigger tap if we didn't drag too far (checked in move)
// and didn't trigger long press
onTap();
}
}
clear();
}, [clear, onTap]);
const onPointerLeave = useCallback(() => {
clear();
}, [clear]);
const onContextMenu = useCallback((e: React.MouseEvent) => {
// Prevent default context menu on long press
if (isLongPressRef.current) {
e.preventDefault();
}
}, []);
useEffect(() => clear, [clear]);
return {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerLeave,
onTouchStart: onPointerDown, // Add touch handlers for better mobile support
onTouchMove: onPointerMove,
onTouchEnd: onPointerUp,
onContextMenu,
};
}
@@ -6,6 +6,7 @@ import { getSafeStorage } from './utils/safeStorage';
type RootTabsState = {
openPaths: string[];
selectedPath: string | null;
expandedPaths: string[];
touchedAt: number;
};
@@ -19,6 +20,9 @@ type FilesViewTabsActions = {
removeOpenPathsByPrefix: (root: string, prefixPath: string) => void;
setSelectedPath: (root: string, path: string | null) => void;
ensureSelectedPath: (root: string) => void;
toggleExpandedPath: (root: string, path: string) => void;
expandPath: (root: string, path: string) => void;
expandPaths: (root: string, paths: string[]) => void;
};
export type FilesViewTabsStore = FilesViewTabsState & FilesViewTabsActions;
@@ -43,7 +47,7 @@ const touchRoot = (prev: RootTabsState | undefined): RootTabsState => {
if (prev) {
return { ...prev, touchedAt: Date.now() };
}
return { openPaths: [], selectedPath: null, touchedAt: Date.now() };
return { openPaths: [], selectedPath: null, expandedPaths: [], touchedAt: Date.now() };
};
export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
@@ -199,6 +203,92 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
get().setSelectedPath(normalizedRoot, first);
},
toggleExpandedPath: (root, path) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPath = normalizePath((path || '').trim());
if (!normalizedRoot || !normalizedPath) {
return;
}
set((state) => {
const prev = state.byRoot[normalizedRoot];
const current = touchRoot(prev);
const isExpanded = current.expandedPaths.includes(normalizedPath);
const nextExpandedPaths = isExpanded
? current.expandedPaths.filter((p) => p !== normalizedPath)
: [...current.expandedPaths, normalizedPath];
if (prev && prev.expandedPaths === nextExpandedPaths && prev.selectedPath === current.selectedPath && prev.openPaths === current.openPaths) {
return state;
}
const byRoot = {
...state.byRoot,
[normalizedRoot]: {
...current,
expandedPaths: nextExpandedPaths,
},
};
return { byRoot: clampRoots(byRoot, 20) };
});
},
expandPath: (root, path) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPath = normalizePath((path || '').trim());
if (!normalizedRoot || !normalizedPath) {
return;
}
set((state) => {
const prev = state.byRoot[normalizedRoot];
const current = touchRoot(prev);
const isExpanded = current.expandedPaths.includes(normalizedPath);
if (isExpanded && prev) {
return state;
}
const byRoot = {
...state.byRoot,
[normalizedRoot]: {
...current,
expandedPaths: [...current.expandedPaths, normalizedPath],
},
};
return { byRoot: clampRoots(byRoot, 20) };
});
},
expandPaths: (root, paths) => {
const normalizedRoot = normalizePath((root || '').trim());
if (!normalizedRoot || !paths || paths.length === 0) {
return;
}
const normalizedPaths = paths.map((p) => normalizePath((p || '').trim())).filter(Boolean);
set((state) => {
const prev = state.byRoot[normalizedRoot];
const current = touchRoot(prev);
const existingPaths = new Set(current.expandedPaths);
const newPaths = normalizedPaths.filter((p) => !existingPaths.has(p));
if (newPaths.length === 0) {
return state;
}
const byRoot = {
...state.byRoot,
[normalizedRoot]: {
...current,
expandedPaths: [...current.expandedPaths, ...newPaths],
},
};
return { byRoot: clampRoots(byRoot, 20) };
});
},
}),
{
name: 'files-view-tabs-store',