From ea6387219323989bbd27c4c0716ae4dfc551f4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 13:02:41 -0400 Subject: [PATCH 1/4] Clear file tree selection on pane click --- apps/desktop/src/app/utils/navigation.ts | 6 ++++ .../editor/MultiPaneWorkspace.test.tsx | 28 +++++++++++++++ .../features/editor/MultiPaneWorkspace.tsx | 6 ++++ .../editor/WorkspaceSplitContainer.tsx | 11 +++++- .../src/features/vault/FileTree.test.tsx | 34 +++++++++++++++++++ apps/desktop/src/features/vault/FileTree.tsx | 32 ++++++++++++++++- 6 files changed, 115 insertions(+), 2 deletions(-) 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 3007648a..1eeb769f 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, @@ -219,6 +220,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 3a27c5b9..db64e9d2 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, @@ -339,6 +340,10 @@ export function MultiPaneWorkspace() { [focusPane], ); + const handlePanePointerDown = useCallback(() => { + clearFileTreeSelection(); + }, []); + const handleResizeSplit = useCallback( (splitId: string, sizes: readonly number[]) => { resizePaneSplit(splitId, sizes); @@ -510,6 +515,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 1076be9b..4148832f 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,39 @@ 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("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 58a67e32..7b7c55c0 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, @@ -3576,6 +3579,33 @@ export function FileTree() { } }, []); + const clearTreeSelection = useCallback(() => { + if ( + selectedRowCount === 0 && + lastClickedRowKeyRef.current === null + ) { + return; + } + + setSelectedNoteIds((prev) => (prev.size === 0 ? prev : new Set())); + setSelectedEntryPaths((prev) => (prev.size === 0 ? prev : new Set())); + setSelectedFolderPaths((prev) => (prev.size === 0 ? prev : new Set())); + lastClickedRowKeyRef.current = null; + }, [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()); From fc189ef3abd4e8c5393d86d5e369c4e1b1681483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 22 May 2026 13:27:38 -0400 Subject: [PATCH 2/4] Clear file tree cursor highlight on pane focus --- .../src/features/vault/FileTree.test.tsx | 55 +++++++++++++++++++ apps/desktop/src/features/vault/FileTree.tsx | 36 ++++++++---- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 90570490..34461893 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -763,6 +763,61 @@ describe("FileTree", () => { 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("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 ef802028..f174e1bd 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -2169,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([]); @@ -2176,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 @@ -2322,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}`; } @@ -3200,7 +3209,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; @@ -3223,7 +3232,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; handleToggleFolder(path); }, - [selectRowRange], + [selectRowRange, showTreeKeyboardCursor], ); const handleRevealToggle = () => { @@ -3356,7 +3365,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; } @@ -3382,7 +3391,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; openPdf(entry.id, entry.title, entry.path); }, - [activeEntryPath, openPdf, selectRowRange], + [activeEntryPath, openPdf, selectRowRange, showTreeKeyboardCursor], ); const handlePdfMouseDown = useCallback( @@ -3475,7 +3484,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; } @@ -3501,7 +3510,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; void openVaultFileEntry(entry); }, - [activeEntryPath, selectRowRange], + [activeEntryPath, selectRowRange, showTreeKeyboardCursor], ); const handleFileMouseDown = useCallback( @@ -3600,16 +3609,19 @@ export function FileTree() { const clearTreeSelection = useCallback(() => { if ( selectedRowCount === 0 && - lastClickedRowKeyRef.current === null + 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; - }, [selectedRowCount]); + }, [keyboardCursorKey, selectedRowCount]); useEffect(() => { window.addEventListener( @@ -3738,7 +3750,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; @@ -3760,7 +3772,7 @@ export function FileTree() { lastClickedRowKeyRef.current = rowKey; await openTreeNote(note); }, - [clearEntrySelection, openTreeNote, selectRowRange], + [clearEntrySelection, openTreeNote, selectRowRange, showTreeKeyboardCursor], ); const handleNoteAuxClick = useCallback( @@ -4403,7 +4415,7 @@ export function FileTree() { const rowKey = getSelectableRowKey(row); if (!rowKey) return; - setKeyboardCursorKey(rowKey); + showTreeKeyboardCursor(rowKey); lastClickedRowKeyRef.current = rowKey; if (row.kind === "folder") { @@ -4428,7 +4440,7 @@ export function FileTree() { setSelectedFolderPaths(new Set()); lastClickedEntryPathRef.current = row.entry.path; } - }, [clearEntrySelection]); + }, [clearEntrySelection, showTreeKeyboardCursor]); const activateKeyboardRow = useCallback( (row: FlatTreeRow) => { From e47490f9380ab66de905c58b37bd81adc2fdbbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 22 May 2026 13:45:47 -0400 Subject: [PATCH 3/4] Use tree focus as shift-click selection anchor --- .../src/features/vault/FileTree.test.tsx | 54 +++++++++++++++++++ apps/desktop/src/features/vault/FileTree.tsx | 5 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 34461893..48e455e2 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -818,6 +818,60 @@ describe("FileTree", () => { ); }); + 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"); + }); + 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 f174e1bd..88ee7afd 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -3173,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; } @@ -3201,7 +3202,7 @@ export function FileTree() { } return true; }, - [applySelectionState, extendSelectionState], + [applySelectionState, extendSelectionState, keyboardCursorKey], ); const handleFolderClick = useCallback( From af3277f6a7924fc5bebcb657df8d3383c185fff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 22 May 2026 13:50:21 -0400 Subject: [PATCH 4/4] Keep shift-click range anchored to active tree row --- apps/desktop/src/features/vault/FileTree.test.tsx | 6 ++++++ apps/desktop/src/features/vault/FileTree.tsx | 1 + 2 files changed, 7 insertions(+) diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 48e455e2..c4dcf811 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -870,6 +870,12 @@ describe("FileTree", () => { 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 () => { diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 88ee7afd..91b634b1 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -3200,6 +3200,7 @@ export function FileTree() { } else { applySelectionState(selection); } + lastClickedRowKeyRef.current = anchorKey; return true; }, [applySelectionState, extendSelectionState, keyboardCursorKey],