diff --git a/apps/desktop/src/app/utils/navigation.ts b/apps/desktop/src/app/utils/navigation.ts index f6c94933..fc996ad6 100644 --- a/apps/desktop/src/app/utils/navigation.ts +++ b/apps/desktop/src/app/utils/navigation.ts @@ -1,4 +1,6 @@ export const REVEAL_NOTE_IN_TREE_EVENT = "neverwrite:reveal-note-in-tree"; +export const CLEAR_FILE_TREE_SELECTION_EVENT = + "neverwrite:clear-file-tree-selection"; export function revealNoteInTree(noteId: string) { window.dispatchEvent( @@ -7,3 +9,7 @@ export function revealNoteInTree(noteId: string) { }), ); } + +export function clearFileTreeSelection() { + window.dispatchEvent(new CustomEvent(CLEAR_FILE_TREE_SELECTION_EVENT)); +} diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx index a4f3e90d..008be4d5 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx @@ -10,6 +10,7 @@ import { } from "../../test/test-utils"; import { publishWindowTabDropZone } from "../../app/detachedWindows"; import { useEditorStore } from "../../app/store/editorStore"; +import { CLEAR_FILE_TREE_SELECTION_EVENT } from "../../app/utils/navigation"; import { createInitialLayout, splitPane, @@ -223,6 +224,33 @@ describe("MultiPaneWorkspace", () => { expect(useEditorStore.getState().focusedPaneId).toBe("secondary"); }); + it("requests file tree selection cleanup when a pane is clicked", () => { + const events: Event[] = []; + const handleClearSelection = (event: Event) => events.push(event); + window.addEventListener( + CLEAR_FILE_TREE_SELECTION_EVENT, + handleClearSelection, + ); + + try { + renderComponent(); + + const targetPane = screen + .getByTestId("pane-content-secondary") + .closest('[data-editor-pane-id="secondary"]'); + expect(targetPane).not.toBeNull(); + + fireEvent.pointerDown(targetPane!, { pointerId: 1, button: 0 }); + + expect(events).toHaveLength(1); + } finally { + window.removeEventListener( + CLEAR_FILE_TREE_SELECTION_EVENT, + handleClearSelection, + ); + } + }); + it("opens a dragged vault file in the pane under the pointer", async () => { setVaultEntries([ { diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx index 83fa7672..d7b5b275 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx @@ -13,6 +13,7 @@ import { getCurrentWindowLabel, publishWindowTabDropZone, } from "../../app/detachedWindows"; +import { clearFileTreeSelection } from "../../app/utils/navigation"; import { selectFocusedPaneId, selectLeafPaneIds, @@ -382,6 +383,10 @@ export function MultiPaneWorkspace() { [focusPane], ); + const handlePanePointerDown = useCallback(() => { + clearFileTreeSelection(); + }, []); + const handleResizeSplit = useCallback( (splitId: string, sizes: readonly number[]) => { resizePaneSplit(splitId, sizes); @@ -578,6 +583,7 @@ export function MultiPaneWorkspace() { node={layoutTree} focusedPaneId={focusedPaneId} externalFileDropPaneId={externalFileDropPaneId} + onPanePointerDown={handlePanePointerDown} onPaneFocus={handlePaneFocus} onResizeSplit={handleResizeSplit} /> diff --git a/apps/desktop/src/features/editor/WorkspaceSplitContainer.tsx b/apps/desktop/src/features/editor/WorkspaceSplitContainer.tsx index 78066cc6..b1a7dcd2 100644 --- a/apps/desktop/src/features/editor/WorkspaceSplitContainer.tsx +++ b/apps/desktop/src/features/editor/WorkspaceSplitContainer.tsx @@ -36,6 +36,7 @@ interface WorkspaceSplitContainerProps { node: WorkspaceLayoutNode; focusedPaneId: string | null; externalFileDropPaneId: string | null; + onPanePointerDown: () => void; onPaneFocus: (paneId: string) => void; onResizeSplit: (splitId: string, sizes: readonly number[]) => void; } @@ -94,11 +95,13 @@ const WorkspacePane = memo(function WorkspacePane({ paneId, isFocused, isExternalFileDropActive, + onPanePointerDown, onPaneFocus, }: { paneId: string; isFocused: boolean; isExternalFileDropActive: boolean; + onPanePointerDown: () => void; onPaneFocus: (paneId: string) => void; }) { return ( @@ -116,7 +119,10 @@ const WorkspacePane = memo(function WorkspacePane({ ? "inset 0 1px 0 color-mix(in srgb, var(--accent) 16%, transparent)" : "none", }} - onPointerDownCapture={() => onPaneFocus(paneId)} + onPointerDownCapture={() => { + onPanePointerDown(); + onPaneFocus(paneId); + }} onFocusCapture={() => onPaneFocus(paneId)} data-editor-pane-id={paneId} data-editor-pane-focused={isFocused || undefined} @@ -149,6 +155,7 @@ export function WorkspaceSplitContainer({ node, focusedPaneId, externalFileDropPaneId, + onPanePointerDown, onPaneFocus, onResizeSplit, }: WorkspaceSplitContainerProps) { @@ -281,6 +288,7 @@ export function WorkspaceSplitContainer({ isExternalFileDropActive={ node.paneId === externalFileDropPaneId } + onPanePointerDown={onPanePointerDown} onPaneFocus={onPaneFocus} /> ); @@ -320,6 +328,7 @@ export function WorkspaceSplitContainer({ node={child} focusedPaneId={focusedPaneId} externalFileDropPaneId={externalFileDropPaneId} + onPanePointerDown={onPanePointerDown} onPaneFocus={onPaneFocus} onResizeSplit={onResizeSplit} /> diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 3b2d7a53..c4dcf811 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -20,6 +20,7 @@ import { import { useBookmarkStore } from "../../app/store/bookmarkStore"; import { useSettingsStore } from "../../app/store/settingsStore"; import { useVaultStore } from "../../app/store/vaultStore"; +import { clearFileTreeSelection } from "../../app/utils/navigation"; import { safeStorageSetItem } from "../../app/utils/safeStorage"; import { buildVaultFileEntry as buildFileEntry, @@ -729,6 +730,154 @@ describe("FileTree", () => { ).toBeInTheDocument(); }); + it("clears explicit multi-selection when another pane requests it", async () => { + const user = userEvent.setup(); + setVaultNotes([ + { + id: "notes/alpha", + path: "/vault/notes/alpha.md", + title: "Alpha", + modified_at: 1, + created_at: 1, + }, + { + id: "notes/beta", + path: "/vault/notes/beta.md", + title: "Beta", + modified_at: 1, + created_at: 1, + }, + ]); + + renderComponent(); + await expandFolder(user, "notes"); + + fireEvent.click(getNoteRow("Alpha"), { metaKey: true }); + fireEvent.click(getNoteRow("Beta"), { metaKey: true }); + expect(getNoteRow("Alpha")).toHaveAttribute("data-selected", "true"); + expect(getNoteRow("Beta")).toHaveAttribute("data-selected", "true"); + + act(() => clearFileTreeSelection()); + + expect(getNoteRow("Alpha")).toHaveAttribute("data-selected", "false"); + expect(getNoteRow("Beta")).toHaveAttribute("data-selected", "false"); + }); + + it("clears the active tree cursor highlight when another pane requests it", async () => { + const user = userEvent.setup(); + setVaultNotes([ + { + id: "notes/alpha", + path: "/vault/notes/alpha.md", + title: "Alpha", + modified_at: 1, + created_at: 1, + }, + { + id: "notes/beta", + path: "/vault/notes/beta.md", + title: "Beta", + modified_at: 1, + created_at: 1, + }, + ]); + setEditorTabs( + [ + { + id: "tab-alpha", + noteId: "notes/alpha", + title: "Alpha", + content: "Alpha", + }, + ], + "tab-alpha", + ); + + renderComponent(); + await expandFolder(user, "notes"); + + await waitFor(() => { + expect(getNoteRow("Alpha")).toHaveAttribute( + "data-keyboard-focus", + "true", + ); + }); + + act(() => clearFileTreeSelection()); + + expect(getNoteRow("Alpha")).toHaveAttribute( + "data-keyboard-focus", + "false", + ); + + await user.click(getNoteRow("Beta")); + + expect(getNoteRow("Beta")).toHaveAttribute( + "data-keyboard-focus", + "true", + ); + }); + + it("uses the active tree cursor as the shift-click range anchor", async () => { + const user = userEvent.setup(); + setVaultNotes([ + { + id: "notes/alpha", + path: "/vault/notes/alpha.md", + title: "Alpha", + modified_at: 1, + created_at: 1, + }, + { + id: "notes/beta", + path: "/vault/notes/beta.md", + title: "Beta", + modified_at: 1, + created_at: 1, + }, + { + id: "notes/gamma", + path: "/vault/notes/gamma.md", + title: "Gamma", + modified_at: 1, + created_at: 1, + }, + ]); + setEditorTabs( + [ + { + id: "tab-alpha", + noteId: "notes/alpha", + title: "Alpha", + content: "Alpha", + }, + ], + "tab-alpha", + ); + + renderComponent(); + await expandFolder(user, "notes"); + + await waitFor(() => { + expect(getNoteRow("Alpha")).toHaveAttribute( + "data-keyboard-focus", + "true", + ); + }); + + fireEvent.click(getNoteRow("Gamma"), { shiftKey: true }); + + expect(getNoteRow("Alpha")).toHaveAttribute("data-selected", "true"); + expect(getNoteRow("Beta")).toHaveAttribute("data-selected", "true"); + expect(getNoteRow("Gamma")).toHaveAttribute("data-selected", "true"); + + fireEvent.click(getNoteRow("Beta"), { shiftKey: true }); + + expect(getNoteRow("Alpha")).toHaveAttribute("data-selected", "true"); + expect(getNoteRow("Beta")).toHaveAttribute("data-selected", "true"); + expect(getNoteRow("Gamma")).toHaveAttribute("data-selected", "false"); + }); + it("deletes all selected notes from the context menu", async () => { const user = userEvent.setup(); const deleteNote = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index e63cc298..91b634b1 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -21,7 +21,10 @@ import { shouldShowVaultEntryInFileTree, } from "../../app/utils/vaultEntries"; import { useSettingsStore } from "../../app/store/settingsStore"; -import { REVEAL_NOTE_IN_TREE_EVENT } from "../../app/utils/navigation"; +import { + CLEAR_FILE_TREE_SELECTION_EVENT, + REVEAL_NOTE_IN_TREE_EVENT, +} from "../../app/utils/navigation"; import { useVaultStore, type NoteDto, @@ -2166,6 +2169,7 @@ export function FileTree() { const pendingRevealRef = useRef(null); const lastClickedEntryPathRef = useRef(null); const lastClickedRowKeyRef = useRef(null); + const suppressActiveKeyboardCursorRef = useRef(false); const flatRowsRef = useRef([]); const displayRowsRef = useRef([]); const shortcutNavigableRowsRef = useRef([]); @@ -2173,6 +2177,11 @@ export function FileTree() { const expandedFoldersVaultPathRef = useRef(vaultPath); const skipExpandedFoldersPersistRef = useRef(false); const restoredScrollVaultPathRef = useRef(null); + + const showTreeKeyboardCursor = useCallback((rowKey: string) => { + suppressActiveKeyboardCursorRef.current = false; + setKeyboardCursorKey(rowKey); + }, []); const suppressRevealActivePathRef = useRef(null); // Virtualization state @@ -2319,6 +2328,9 @@ export function FileTree() { ); setKeyboardCursorKey((current) => { + if (suppressActiveKeyboardCursorRef.current) { + return current && visibleKeys.has(current) ? current : null; + } if (activeNoteId && visibleKeys.has(`note:${activeNoteId}`)) { return `note:${activeNoteId}`; } @@ -3161,7 +3173,8 @@ export function FileTree() { const selectRowRange = useCallback( (targetKey: string, append: boolean) => { - const anchorKey = lastClickedRowKeyRef.current; + const anchorKey = + lastClickedRowKeyRef.current ?? keyboardCursorKey; if (!anchorKey) { return false; } @@ -3187,9 +3200,10 @@ export function FileTree() { } else { applySelectionState(selection); } + lastClickedRowKeyRef.current = anchorKey; return true; }, - [applySelectionState, extendSelectionState], + [applySelectionState, extendSelectionState, keyboardCursorKey], ); const handleFolderClick = useCallback( @@ -3197,7 +3211,7 @@ export function FileTree() { if (wasJustDraggingRef.current) return; setFocusedFolderPath(path); const rowKey = `folder:${path}`; - setKeyboardCursorKey(rowKey); + showTreeKeyboardCursor(rowKey); if (modifiers.shift && selectRowRange(rowKey, modifiers.cmd)) { return; @@ -3220,7 +3234,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; handleToggleFolder(path); }, - [selectRowRange], + [selectRowRange, showTreeKeyboardCursor], ); const handleRevealToggle = () => { @@ -3353,7 +3367,7 @@ export function FileTree() { if (wasJustDraggingRef.current) return; setFocusedFolderPath(getParentPath(entry.relative_path)); const rowKey = `entry:${entry.path}`; - setKeyboardCursorKey(rowKey); + showTreeKeyboardCursor(rowKey); if (modifiers.shift && selectRowRange(rowKey, modifiers.cmd)) { return; } @@ -3379,7 +3393,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; openPdf(entry.id, entry.title, entry.path); }, - [activeEntryPath, openPdf, selectRowRange], + [activeEntryPath, openPdf, selectRowRange, showTreeKeyboardCursor], ); const handlePdfMouseDown = useCallback( @@ -3472,7 +3486,7 @@ export function FileTree() { if (wasJustDraggingRef.current) return; setFocusedFolderPath(getParentPath(entry.relative_path)); const rowKey = `entry:${entry.path}`; - setKeyboardCursorKey(rowKey); + showTreeKeyboardCursor(rowKey); if (modifiers.shift && selectRowRange(rowKey, modifiers.cmd)) { return; } @@ -3498,7 +3512,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; void openVaultFileEntry(entry); }, - [activeEntryPath, selectRowRange], + [activeEntryPath, selectRowRange, showTreeKeyboardCursor], ); const handleFileMouseDown = useCallback( @@ -3594,6 +3608,36 @@ export function FileTree() { } }, []); + const clearTreeSelection = useCallback(() => { + if ( + selectedRowCount === 0 && + lastClickedRowKeyRef.current === null && + keyboardCursorKey === null + ) { + return; + } + + suppressActiveKeyboardCursorRef.current = true; + setSelectedNoteIds((prev) => (prev.size === 0 ? prev : new Set())); + setSelectedEntryPaths((prev) => (prev.size === 0 ? prev : new Set())); + setSelectedFolderPaths((prev) => (prev.size === 0 ? prev : new Set())); + setKeyboardCursorKey(null); + lastClickedRowKeyRef.current = null; + }, [keyboardCursorKey, selectedRowCount]); + + useEffect(() => { + window.addEventListener( + CLEAR_FILE_TREE_SELECTION_EVENT, + clearTreeSelection, + ); + return () => { + window.removeEventListener( + CLEAR_FILE_TREE_SELECTION_EVENT, + clearTreeSelection, + ); + }; + }, [clearTreeSelection]); + const clearEntrySelection = useCallback(() => { setSelectedEntryPaths(new Set()); setSelectedFolderPaths(new Set()); @@ -3708,7 +3752,7 @@ export function FileTree() { if (wasJustDraggingRef.current) return; setFocusedFolderPath(getParentPath(note.id)); const rowKey = `note:${note.id}`; - setKeyboardCursorKey(rowKey); + showTreeKeyboardCursor(rowKey); if (modifiers.shift && selectRowRange(rowKey, modifiers.cmd)) { return; @@ -3730,7 +3774,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; await openTreeNote(note); }, - [clearEntrySelection, openTreeNote, selectRowRange], + [clearEntrySelection, openTreeNote, selectRowRange, showTreeKeyboardCursor], ); const handleNoteAuxClick = useCallback( @@ -4373,7 +4417,7 @@ export function FileTree() { const rowKey = getSelectableRowKey(row); if (!rowKey) return; - setKeyboardCursorKey(rowKey); + showTreeKeyboardCursor(rowKey); lastClickedRowKeyRef.current = rowKey; if (row.kind === "folder") { @@ -4398,7 +4442,7 @@ export function FileTree() { setSelectedFolderPaths(new Set()); lastClickedEntryPathRef.current = row.entry.path; } - }, [clearEntrySelection]); + }, [clearEntrySelection, showTreeKeyboardCursor]); const activateKeyboardRow = useCallback( (row: FlatTreeRow) => {