From 0722acb0241f093ad919816a7cef1d0993759c79 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 13:19:03 +0200 Subject: [PATCH 1/6] fix(file-tree): support dragging files from Finder into vault folders Tauri's onDragDropEvent only handled drops onto editor panes; drops over the file tree were silently discarded. - Add copy_external_file_to_vault backend command: copies an external file (absolute path) into a vault-relative folder using the existing dedup logic, then emits a vault change event - Add EXTERNAL_FILE_TREE_DRAG_EVENT so MultiPaneWorkspace can broadcast hover state to the file tree without prop-drilling - Extend onDragDropEvent: on enter/over emit folder hover events; on drop, if the cursor is over a data-folder-path element, copy files into that folder instead of opening them in an editor pane - FileTree listens for the event and sets dragOverPath, activating the existing accent-tinted folder highlight --- apps/desktop/native-backend/src/main.rs | 56 +++++++++++++++++++ apps/desktop/src/features/ai/dragEvents.ts | 17 ++++++ .../features/editor/MultiPaneWorkspace.tsx | 49 ++++++++++++++-- apps/desktop/src/features/vault/FileTree.tsx | 15 +++++ 4 files changed, 133 insertions(+), 4 deletions(-) diff --git a/apps/desktop/native-backend/src/main.rs b/apps/desktop/native-backend/src/main.rs index ef024d6b..8658e061 100644 --- a/apps/desktop/native-backend/src/main.rs +++ b/apps/desktop/native-backend/src/main.rs @@ -772,6 +772,7 @@ impl NativeBackend { } "save_vault_file" => self.save_vault_file(args), "save_vault_binary_file" => self.save_vault_binary_file(args), + "copy_external_file_to_vault" => self.copy_external_file_to_vault(args), "read_note" => { let state = self.state(&args)?; let note_id = required_string(&args, &["noteId", "note_id"])?; @@ -1460,6 +1461,61 @@ impl NativeBackend { Ok(json!(detail)) } + fn copy_external_file_to_vault(&mut self, args: Value) -> Result { + let source_path = required_string(&args, &["sourcePath", "source_path"])?; + let target_folder = optional_string(&args, &["targetFolder", "target_folder"]) + .unwrap_or_default(); + let (vault_path, state) = self.state_mut(&args)?; + + let source = std::path::PathBuf::from(&source_path); + if !source.is_file() { + return Err(format!("Source file not found: {source_path}")); + } + + let file_name = source + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Could not determine file name from source path".to_string())? + .to_string(); + + let target = state + .vault + .prepare_binary_file_target(&target_folder, &file_name) + .map_err(|error| error.to_string())?; + + state.write_tracker.track_any(target.clone()); + fs::copy(&source, &target).map_err(|error| error.to_string())?; + + let entry = state + .vault + .read_vault_entry_from_path(&target) + .map_err(|error| error.to_string())?; + + let detail = SavedBinaryFileDetail { + path: entry.path.clone(), + relative_path: entry.relative_path.clone(), + file_name: entry.file_name.clone(), + mime_type: entry.mime_type.clone(), + }; + let revision = + advance_revision(&mut state.file_revisions, &entry.relative_path, None).max(1); + Self::refresh_vault_state(state)?; + let change = build_vault_note_change( + &vault_path, + "upsert", + None, + None, + Some(entry), + Some(detail.relative_path.clone()), + None, + revision, + None, + state.graph_revision.max(1), + ); + self.emit_vault_change(change); + Ok(json!(detail)) + } + fn save_note(&mut self, args: Value) -> Result { let note_id = required_string(&args, &["noteId", "note_id"])?; let content = required_string_allow_empty(&args, &["content"])?; diff --git a/apps/desktop/src/features/ai/dragEvents.ts b/apps/desktop/src/features/ai/dragEvents.ts index cc15e29b..56d7f623 100644 --- a/apps/desktop/src/features/ai/dragEvents.ts +++ b/apps/desktop/src/features/ai/dragEvents.ts @@ -56,3 +56,20 @@ export function emitFileTreeAttachToNewChat(detail: FileTreeNoteDragDetail) { ), ); } + +export const EXTERNAL_FILE_TREE_DRAG_EVENT = + "neverwrite:external-file-tree-drag"; + +export interface ExternalFileTreeDragDetail { + phase: "over" | "cancel"; + folderPath: string | null; +} + +export function emitExternalFileTreeDrag(detail: ExternalFileTreeDragDetail) { + window.dispatchEvent( + new CustomEvent( + EXTERNAL_FILE_TREE_DRAG_EVENT, + { detail }, + ), + ); +} diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx index 3a27c5b9..5bd15c66 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx @@ -21,8 +21,10 @@ import { import { useVaultStore } from "../../app/store/vaultStore"; import { FILE_TREE_NOTE_DRAG_EVENT, + emitExternalFileTreeDrag, type FileTreeNoteDragDetail, } from "../ai/dragEvents"; +import { vaultInvoke } from "../../app/utils/vaultInvoke"; import { AGENT_SIDEBAR_DRAG_EVENT, type AgentSidebarDragDetail, @@ -45,6 +47,27 @@ import { const AGENT_SIDEBAR_DROP_SOURCE_PANE_ID = "__agents-sidebar__"; +function resolveFileTreeFolderAtPoint(x: number, y: number): string | null { + const els = document.elementsFromPoint(x, y); + const folderEl = els.find((el) => el.hasAttribute("data-folder-path")); + if (!folderEl) return null; + return folderEl.getAttribute("data-folder-path") ?? null; +} + +async function copyExternalFilesToVaultFolder( + sourcePaths: string[], + targetFolder: string, +) { + await Promise.all( + sourcePaths.map((sourcePath) => + vaultInvoke("copy_external_file_to_vault", { + sourcePath, + targetFolder, + }), + ), + ); +} + function getAppWindow() { return getCurrentWindow(); } @@ -370,6 +393,13 @@ export function MultiPaneWorkspace() { getWorkspaceFileDropPaneId(target.target), ); } + const folderPath = position + ? resolveFileTreeFolderAtPoint( + position.x, + position.y, + ) + : null; + emitExternalFileTreeDrag({ phase: "over", folderPath }); return; } @@ -377,11 +407,9 @@ export function MultiPaneWorkspace() { setExternalFileDropPaneId(null); } dispatchCrossPaneTabDropPreview(null); + emitExternalFileTreeDrag({ phase: "cancel", folderPath: null }); - if ( - type !== "drop" || - !isWorkspaceFileDropTarget(target.target) - ) { + if (type !== "drop") { return; } @@ -391,6 +419,19 @@ export function MultiPaneWorkspace() { return; } + const fileTreeFolder = position + ? resolveFileTreeFolderAtPoint(position.x, position.y) + : null; + + if (fileTreeFolder !== null) { + void copyExternalFilesToVaultFolder(paths, fileTreeFolder); + return; + } + + if (!isWorkspaceFileDropTarget(target.target)) { + return; + } + void openDroppedVaultPathsAtTarget(paths, target.target); }) .then((cleanup) => { diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 211af923..0c34aaae 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -57,8 +57,10 @@ import { import { FileTypeIcon } from "../../components/icons/FileTypeIcon"; import { FolderTypeIcon } from "../../components/icons/FolderTypeIcon"; import { + EXTERNAL_FILE_TREE_DRAG_EVENT, emitFileTreeAttachToNewChat, emitFileTreeNoteDrag, + type ExternalFileTreeDragDetail, type FileTreeNoteDragDetail, } from "../ai/dragEvents"; import { getPreferredWorkspaceChatSessionId } from "../ai/chatWorkspaceSelectors"; @@ -2733,6 +2735,19 @@ export function FileTree() { resetDragState, ]); + useEffect(() => { + const handler = (event: Event) => { + const { phase, folderPath } = ( + event as CustomEvent + ).detail; + setDragOverPath(phase === "over" ? folderPath : null); + }; + window.addEventListener(EXTERNAL_FILE_TREE_DRAG_EVENT, handler); + return () => { + window.removeEventListener(EXTERNAL_FILE_TREE_DRAG_EVENT, handler); + }; + }, []); + const handleToggleFolder = (path: string) => { setExpandedFolders((prev) => { const next = new Set(prev); From f73a1b8b1b1c7880e452ed51f9b5362d90d1e500 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 15:01:24 +0200 Subject: [PATCH 2/6] fix(file-tree): address review: error handling, drag race, test compat - Switch Promise.all to Promise.allSettled in copyExternalFilesToVaultFolder so one bad file doesn't silently drop the rest; log each failure via logError - Guard the EXTERNAL_FILE_TREE_DRAG_EVENT handler in FileTree with an early return when dragStateRef.current is set, preventing a Tauri cancel/leave event from clearing dragOverPath mid-internal-drag - Mock document.elementsFromPoint in MultiPaneWorkspace tests (jsdom does not implement it); returning [] makes resolveFileTreeFolderAtPoint return null so existing editor-pane drop tests are unaffected --- .../src/features/editor/MultiPaneWorkspace.test.tsx | 4 ++++ .../src/features/editor/MultiPaneWorkspace.tsx | 13 ++++++++++++- apps/desktop/src/features/vault/FileTree.tsx | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx index 3007648a..98b308de 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx @@ -204,6 +204,10 @@ describe("MultiPaneWorkspace", () => { y: 40, }), }); + Object.defineProperty(document, "elementsFromPoint", { + configurable: true, + value: vi.fn(() => []), + }); }); it("focuses the clicked pane", () => { diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx index 5bd15c66..a6323c8c 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx @@ -25,6 +25,7 @@ import { type FileTreeNoteDragDetail, } from "../ai/dragEvents"; import { vaultInvoke } from "../../app/utils/vaultInvoke"; +import { logError } from "../../app/utils/runtimeLog"; import { AGENT_SIDEBAR_DRAG_EVENT, type AgentSidebarDragDetail, @@ -58,7 +59,7 @@ async function copyExternalFilesToVaultFolder( sourcePaths: string[], targetFolder: string, ) { - await Promise.all( + const results = await Promise.allSettled( sourcePaths.map((sourcePath) => vaultInvoke("copy_external_file_to_vault", { sourcePath, @@ -66,6 +67,16 @@ async function copyExternalFilesToVaultFolder( }), ), ); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result?.status === "rejected") { + logError( + "file-tree", + `Failed to copy file into vault: ${sourcePaths[i]}`, + result.reason, + ); + } + } } function getAppWindow() { diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 0c34aaae..61d73c46 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -2737,6 +2737,7 @@ export function FileTree() { useEffect(() => { const handler = (event: Event) => { + if (dragStateRef.current) return; const { phase, folderPath } = ( event as CustomEvent ).detail; From 4fd8a5007d66398808b1d440cbd51e53155bc24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 10:47:04 -0400 Subject: [PATCH 3/6] Fix external file drops into vault folders --- .../src-electron/main/nativeBackend.ts | 1 + .../editor/MultiPaneWorkspace.test.tsx | 56 ++++++++++++++++++- .../features/editor/MultiPaneWorkspace.tsx | 18 +++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-electron/main/nativeBackend.ts b/apps/desktop/src-electron/main/nativeBackend.ts index 35b21f0e..5007da0f 100644 --- a/apps/desktop/src-electron/main/nativeBackend.ts +++ b/apps/desktop/src-electron/main/nativeBackend.ts @@ -24,6 +24,7 @@ const SUPPORTED_COMMANDS = new Set([ "read_vault_file", "save_vault_file", "save_vault_binary_file", + "copy_external_file_to_vault", "read_note", "save_note", "create_note", diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx index 98b308de..b1d2acbe 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, screen } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { flushPromises, getMockCurrentWebview, @@ -328,6 +328,60 @@ describe("MultiPaneWorkspace", () => { expect(useEditorStore.getState().focusedPaneId).toBe("secondary"); }); + it("copies external files into the hovered file-tree folder", async () => { + const originalRefreshStructure = + useVaultStore.getState().refreshStructure; + const refreshStructure = vi.fn(async () => {}); + useVaultStore.setState({ refreshStructure }); + mockInvoke().mockResolvedValue({ + relative_path: "Projects/draft.pdf", + path: "/vaults/main/Projects/draft.pdf", + file_name: "draft.pdf", + mime_type: "application/pdf", + }); + const folder = document.createElement("div"); + folder.setAttribute("data-folder-path", "Projects"); + vi.mocked(document.elementsFromPoint).mockReturnValue([folder]); + + renderComponent(); + await flushPromises(); + + const dragDropListener = onDragDropEventMock.mock.calls.at(-1)?.[0] as + | ((event: { + payload: { + type: "drop"; + position: { x: number; y: number }; + paths: string[]; + }; + }) => void) + | undefined; + expect(dragDropListener).toBeTypeOf("function"); + + await act(async () => { + dragDropListener?.({ + payload: { + type: "drop", + position: { x: 24, y: 48 }, + paths: ["/Users/jfg/Desktop/draft.pdf"], + }, + }); + await flushPromises(); + }); + + expect(mockInvoke()).toHaveBeenCalledWith("copy_external_file_to_vault", { + sourcePath: "/Users/jfg/Desktop/draft.pdf", + targetFolder: "Projects", + vaultPath: "/vaults/main", + }); + await waitFor(() => { + expect(refreshStructure).toHaveBeenCalledTimes(1); + }); + expect(useEditorStore.getState().panes.flatMap((pane) => pane.tabs)).toEqual( + [], + ); + useVaultStore.setState({ refreshStructure: originalRefreshStructure }); + }); + it("does not open a pane tab when the drop lands over the composer zone", async () => { setVaultEntries([ { diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx index a6323c8c..df255f5d 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx @@ -67,6 +67,7 @@ async function copyExternalFilesToVaultFolder( }), ), ); + let copiedCount = 0; for (let i = 0; i < results.length; i++) { const result = results[i]; if (result?.status === "rejected") { @@ -75,8 +76,11 @@ async function copyExternalFilesToVaultFolder( `Failed to copy file into vault: ${sourcePaths[i]}`, result.reason, ); + } else if (result?.status === "fulfilled") { + copiedCount += 1; } } + return copiedCount; } function getAppWindow() { @@ -233,6 +237,9 @@ export function MultiPaneWorkspace() { const leafPaneIds = useEditorStore(useShallow(selectLeafPaneIds)); const layoutTree = useEditorStore((state) => state.layoutTree); const focusedPaneId = useEditorStore(selectFocusedPaneId); + const refreshVaultStructure = useVaultStore( + (state) => state.refreshStructure, + ); const focusPane = useEditorStore((state) => state.focusPane); const resizePaneSplit = useEditorStore((state) => state.resizePaneSplit); const containerRef = useRef(null); @@ -435,7 +442,14 @@ export function MultiPaneWorkspace() { : null; if (fileTreeFolder !== null) { - void copyExternalFilesToVaultFolder(paths, fileTreeFolder); + void copyExternalFilesToVaultFolder( + paths, + fileTreeFolder, + ).then((copiedCount) => { + if (copiedCount > 0) { + void refreshVaultStructure(); + } + }); return; } @@ -459,7 +473,7 @@ export function MultiPaneWorkspace() { dispatchCrossPaneTabDropPreview(null); unlisten?.(); }; - }, []); + }, [refreshVaultStructure]); useEffect(() => { const handleTreeDrag = (event: Event) => { From 1184a1736a2fea76e3f76b1392aea2383e1d9fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 10:50:20 -0400 Subject: [PATCH 4/6] Ignore desktop test results --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 12b534b0..23c1df5b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ apps/desktop/dist-electron/ apps/desktop/dist-electron*/ apps/desktop/out/ apps/desktop/node_modules/ +apps/desktop/test-results/ assets/ !apps/desktop/src/assets/ !apps/desktop/src/assets/fonts/ From 32240348ff4943b35bb938eb1eedc0f52d1b5bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 12:24:50 -0400 Subject: [PATCH 5/6] Handle Finder drops over file rows --- .../editor/MultiPaneWorkspace.test.tsx | 53 +++++++++++++++++++ .../features/editor/MultiPaneWorkspace.tsx | 8 +-- .../src/features/vault/FileTree.test.tsx | 9 ++-- apps/desktop/src/features/vault/FileTree.tsx | 2 + 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx index b1d2acbe..a4f3e90d 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx @@ -382,6 +382,59 @@ describe("MultiPaneWorkspace", () => { useVaultStore.setState({ refreshStructure: originalRefreshStructure }); }); + it("copies external files into the parent folder when hovering a file row", async () => { + const originalRefreshStructure = + useVaultStore.getState().refreshStructure; + const refreshStructure = vi.fn(async () => {}); + useVaultStore.setState({ refreshStructure }); + mockInvoke().mockResolvedValue({ + relative_path: "Projects/assets/draft.pdf", + path: "/vaults/main/Projects/assets/draft.pdf", + file_name: "draft.pdf", + mime_type: "application/pdf", + }); + const fileRow = document.createElement("div"); + fileRow.setAttribute("data-folder-path", "Projects/assets"); + const fileLabel = document.createElement("span"); + fileRow.append(fileLabel); + vi.mocked(document.elementsFromPoint).mockReturnValue([fileLabel]); + + renderComponent(); + await flushPromises(); + + const dragDropListener = onDragDropEventMock.mock.calls.at(-1)?.[0] as + | ((event: { + payload: { + type: "drop"; + position: { x: number; y: number }; + paths: string[]; + }; + }) => void) + | undefined; + expect(dragDropListener).toBeTypeOf("function"); + + await act(async () => { + dragDropListener?.({ + payload: { + type: "drop", + position: { x: 24, y: 48 }, + paths: ["/Users/jfg/Desktop/draft.pdf"], + }, + }); + await flushPromises(); + }); + + expect(mockInvoke()).toHaveBeenCalledWith("copy_external_file_to_vault", { + sourcePath: "/Users/jfg/Desktop/draft.pdf", + targetFolder: "Projects/assets", + vaultPath: "/vaults/main", + }); + await waitFor(() => { + expect(refreshStructure).toHaveBeenCalledTimes(1); + }); + useVaultStore.setState({ refreshStructure: originalRefreshStructure }); + }); + it("does not open a pane tab when the drop lands over the composer zone", async () => { setVaultEntries([ { diff --git a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx index df255f5d..83fa7672 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx @@ -50,9 +50,11 @@ const AGENT_SIDEBAR_DROP_SOURCE_PANE_ID = "__agents-sidebar__"; function resolveFileTreeFolderAtPoint(x: number, y: number): string | null { const els = document.elementsFromPoint(x, y); - const folderEl = els.find((el) => el.hasAttribute("data-folder-path")); - if (!folderEl) return null; - return folderEl.getAttribute("data-folder-path") ?? null; + for (const el of els) { + const folderEl = el.closest("[data-folder-path]"); + if (folderEl) return folderEl.getAttribute("data-folder-path") ?? null; + } + return null; } async function copyExternalFilesToVaultFolder( diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 1076be9b..80fcaabb 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -2453,6 +2453,7 @@ describe("FileTree", () => { }); const row = getFileRow("photo"); + expect(row).toHaveAttribute("data-folder-path", "assets"); expect(row).toHaveAttribute("data-selected", "true"); expect(row).toHaveAttribute("data-active", "true"); }); @@ -2496,10 +2497,9 @@ describe("FileTree", () => { expect(await screen.findByText("design")).toBeInTheDocument(); const row = await screen.findByText("Blueprint"); - expect(row.closest('[role="button"]')).toHaveAttribute( - "data-active", - "true", - ); + const button = row.closest('[role="button"]'); + expect(button).toHaveAttribute("data-folder-path", "docs/design"); + expect(button).toHaveAttribute("data-active", "true"); }); it("reveals the active generic file tab in nested folders", async () => { @@ -2546,6 +2546,7 @@ describe("FileTree", () => { expect(await screen.findByText("images")).toBeInTheDocument(); const row = getFileRow("Photo"); + expect(row).toHaveAttribute("data-folder-path", "assets/images"); expect(row).toHaveAttribute("data-active", "true"); }); diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 3a0e784e..e63cc298 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -1234,6 +1234,7 @@ const FlatTreeRowView = memo( role="button" tabIndex={0} data-note-id={entry.id} + data-folder-path={getParentPath(entry.relative_path)} data-selected={isSelected ? "true" : "false"} data-active={isActive ? "true" : "false"} data-keyboard-focus={hasKeyboardCursor ? "true" : "false"} @@ -1363,6 +1364,7 @@ const FlatTreeRowView = memo(
Date: Thu, 21 May 2026 12:28:33 -0400 Subject: [PATCH 6/6] Emit note changes for imported markdown --- apps/desktop/native-backend/src/main.rs | 106 ++++++++++++++++++++---- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/desktop/native-backend/src/main.rs b/apps/desktop/native-backend/src/main.rs index fda00a79..d2cc190b 100644 --- a/apps/desktop/native-backend/src/main.rs +++ b/apps/desktop/native-backend/src/main.rs @@ -1463,8 +1463,8 @@ impl NativeBackend { fn copy_external_file_to_vault(&mut self, args: Value) -> Result { let source_path = required_string(&args, &["sourcePath", "source_path"])?; - let target_folder = optional_string(&args, &["targetFolder", "target_folder"]) - .unwrap_or_default(); + let target_folder = + optional_string(&args, &["targetFolder", "target_folder"]).unwrap_or_default(); let (vault_path, state) = self.state_mut(&args)?; let source = std::path::PathBuf::from(&source_path); @@ -1497,21 +1497,37 @@ impl NativeBackend { file_name: entry.file_name.clone(), mime_type: entry.mime_type.clone(), }; - let revision = - advance_revision(&mut state.file_revisions, &entry.relative_path, None).max(1); Self::refresh_vault_state(state)?; - let change = build_vault_note_change( - &vault_path, - "upsert", - None, - None, - Some(entry), - Some(detail.relative_path.clone()), - None, - revision, - None, - state.graph_revision.max(1), - ); + let change = if entry.kind == "note" { + let note = state + .vault + .read_note_from_path(&target) + .map_err(|error| error.to_string())?; + let revision = advance_revision(&mut state.note_revisions, ¬e.id.0, None).max(1); + note_change_from_document( + &vault_path, + ¬e, + detail.relative_path.clone(), + None, + revision, + state.graph_revision.max(1), + ) + } else { + let revision = + advance_revision(&mut state.file_revisions, &entry.relative_path, None).max(1); + build_vault_note_change( + &vault_path, + "upsert", + None, + None, + Some(entry), + Some(detail.relative_path.clone()), + None, + revision, + None, + state.graph_revision.max(1), + ) + }; self.emit_vault_change(change); Ok(json!(detail)) } @@ -3366,6 +3382,64 @@ mod tests { ); } + #[test] + fn external_markdown_copy_emits_note_change() { + let (event_tx, event_rx) = mpsc::channel::(); + let backend = Arc::new(Mutex::new(NativeBackend::new(event_tx))); + let vault_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("Imported.md"); + fs::write(&source_path, "# Imported\n\n[[Existing]] #tag-one\n").unwrap(); + + let vault_path = vault_dir.path().to_string_lossy().to_string(); + invoke(&backend, "start_open_vault", json!({ "path": vault_path })).unwrap(); + + let detail = invoke( + &backend, + "copy_external_file_to_vault", + json!({ + "vaultPath": vault_path, + "sourcePath": source_path.to_string_lossy().to_string(), + "targetFolder": "Inbox", + }), + ) + .unwrap(); + assert_eq!( + detail.get("relative_path").and_then(Value::as_str), + Some("Inbox/Imported.md") + ); + + let change = recv_vault_change(&event_rx); + assert_eq!(change.get("kind").and_then(Value::as_str), Some("upsert")); + assert_eq!( + change.get("note_id").and_then(Value::as_str), + Some("Inbox/Imported") + ); + assert_eq!( + change.get("relative_path").and_then(Value::as_str), + Some("Inbox/Imported.md") + ); + assert_eq!(change.get("entry"), Some(&Value::Null)); + assert_eq!( + change + .get("note") + .and_then(|note| note.get("id")) + .and_then(Value::as_str), + Some("Inbox/Imported") + ); + assert_eq!( + change.get("content_hash").and_then(Value::as_str), + Some(content_hash_bytes(b"# Imported\n\n[[Existing]] #tag-one\n").as_str()) + ); + + let notes = invoke(&backend, "list_notes", json!({ "vaultPath": vault_path })).unwrap(); + assert!(notes + .as_array() + .unwrap() + .iter() + .any(|note| note.get("id").and_then(Value::as_str) == Some("Inbox/Imported"))); + } + #[test] fn ai_review_file_ops_accept_absolute_paths_inside_vault() { let (event_tx, _event_rx) = mpsc::channel::();