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) => {