diff --git a/apps/desktop/native-backend/src/main.rs b/apps/desktop/native-backend/src/main.rs index 208d759e..80e4395b 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"])?; @@ -1461,6 +1462,77 @@ 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(), + }; + Self::refresh_vault_state(state)?; + 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)) + } + 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"])?; @@ -3311,6 +3383,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::(); diff --git a/apps/desktop/src-electron/main/nativeBackend.ts b/apps/desktop/src-electron/main/nativeBackend.ts index b7bbb89d..43a5f6e3 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/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.test.tsx b/apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx index 3007648a..a4f3e90d 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, @@ -204,6 +204,10 @@ describe("MultiPaneWorkspace", () => { y: 40, }), }); + Object.defineProperty(document, "elementsFromPoint", { + configurable: true, + value: vi.fn(() => []), + }); }); it("focuses the clicked pane", () => { @@ -324,6 +328,113 @@ 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("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 3a27c5b9..83fa7672 100644 --- a/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx +++ b/apps/desktop/src/features/editor/MultiPaneWorkspace.tsx @@ -21,8 +21,11 @@ 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 { logError } from "../../app/utils/runtimeLog"; import { AGENT_SIDEBAR_DRAG_EVENT, type AgentSidebarDragDetail, @@ -45,6 +48,43 @@ import { const AGENT_SIDEBAR_DROP_SOURCE_PANE_ID = "__agents-sidebar__"; +function resolveFileTreeFolderAtPoint(x: number, y: number): string | null { + const els = document.elementsFromPoint(x, y); + 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( + sourcePaths: string[], + targetFolder: string, +) { + const results = await Promise.allSettled( + sourcePaths.map((sourcePath) => + vaultInvoke("copy_external_file_to_vault", { + sourcePath, + targetFolder, + }), + ), + ); + let copiedCount = 0; + 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, + ); + } else if (result?.status === "fulfilled") { + copiedCount += 1; + } + } + return copiedCount; +} + function getAppWindow() { return getCurrentWindow(); } @@ -199,6 +239,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); @@ -370,6 +413,13 @@ export function MultiPaneWorkspace() { getWorkspaceFileDropPaneId(target.target), ); } + const folderPath = position + ? resolveFileTreeFolderAtPoint( + position.x, + position.y, + ) + : null; + emitExternalFileTreeDrag({ phase: "over", folderPath }); return; } @@ -377,11 +427,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 +439,26 @@ export function MultiPaneWorkspace() { return; } + const fileTreeFolder = position + ? resolveFileTreeFolderAtPoint(position.x, position.y) + : null; + + if (fileTreeFolder !== null) { + void copyExternalFilesToVaultFolder( + paths, + fileTreeFolder, + ).then((copiedCount) => { + if (copiedCount > 0) { + void refreshVaultStructure(); + } + }); + return; + } + + if (!isWorkspaceFileDropTarget(target.target)) { + return; + } + void openDroppedVaultPathsAtTarget(paths, target.target); }) .then((cleanup) => { @@ -407,7 +475,7 @@ export function MultiPaneWorkspace() { dispatchCrossPaneTabDropPreview(null); unlisten?.(); }; - }, []); + }, [refreshVaultStructure]); useEffect(() => { const handleTreeDrag = (event: Event) => { diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 17dcb672..3b2d7a53 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -2452,6 +2452,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"); }); @@ -2495,10 +2496,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 () => { @@ -2545,6 +2545,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 58a67e32..e63cc298 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -62,8 +62,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"; @@ -1232,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"} @@ -1361,6 +1364,7 @@ const FlatTreeRowView = memo(
{ + const handler = (event: Event) => { + if (dragStateRef.current) return; + 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);