From 77c74e6df9e2b518c2ef5c02174515a6f873558d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 15 May 2026 01:35:12 -0400 Subject: [PATCH 1/4] Fix move to folder picker --- .../src/features/vault/FileTree.test.tsx | 308 +++++++++- apps/desktop/src/features/vault/FileTree.tsx | 551 +++++++++++++----- .../src/features/vault/fileTreeMoves.test.ts | 71 +++ .../src/features/vault/fileTreeMoves.ts | 72 +++ 4 files changed, 836 insertions(+), 166 deletions(-) diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 27f7efb8..37aa7d67 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -1110,12 +1110,15 @@ describe("FileTree", () => { const rootMoveTarget = await screen.findByRole("button", { name: "/ Root", }); - const moveMenu = getFixedMenuElement(rootMoveTarget); - expect(moveMenu).not.toBeNull(); - expect(moveMenu).toHaveStyle({ - overflowY: "auto", - }); - expect(moveMenu?.getAttribute("style")).toContain("max-height:"); + expect(screen.getByText("Move to Folder")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Search folders..."), + ).toBeInTheDocument(); + expect(getFixedMenuElement(rootMoveTarget)).not.toBeNull(); + const picker = screen.getByRole("dialog", { name: "Move to Folder" }); + expect( + within(picker).getByRole("button", { name: "notes" }), + ).toBeDisabled(); const archiveTargets = await screen.findAllByRole("button", { name: "archive", @@ -1129,6 +1132,299 @@ describe("FileTree", () => { expect(renameNote).toHaveBeenCalledWith("notes/beta", "archive/beta"); }); + it("moves a PDF from the context menu with the folder picker", async () => { + const user = userEvent.setup(); + + setVaultNotes([]); + setVaultEntries([ + buildFolderEntry("docs"), + buildFolderEntry("archive"), + { + id: "docs/reference.pdf", + path: "/vault/docs/reference.pdf", + relative_path: "docs/reference.pdf", + title: "Reference", + file_name: "reference.pdf", + extension: "pdf", + kind: "pdf", + modified_at: 1, + created_at: 1, + size: 256, + mime_type: "application/pdf", + }, + ]); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "move_vault_entry") { + return { + id: "archive/reference.pdf", + path: "/vault/archive/reference.pdf", + relative_path: "archive/reference.pdf", + title: "Reference", + file_name: "reference.pdf", + extension: "pdf", + kind: "pdf", + modified_at: 2, + created_at: 1, + size: 256, + mime_type: "application/pdf", + }; + } + if (command === "list_vault_entries") { + return [ + buildFolderEntry("docs"), + buildFolderEntry("archive"), + { + id: "archive/reference.pdf", + path: "/vault/archive/reference.pdf", + relative_path: "archive/reference.pdf", + title: "Reference", + file_name: "reference.pdf", + extension: "pdf", + kind: "pdf", + modified_at: 2, + created_at: 1, + size: 256, + mime_type: "application/pdf", + }, + ]; + } + return undefined; + }); + + renderComponent(); + await expandFolder(user, "docs"); + + fireEvent.contextMenu(getFileRow("Reference")); + await user.click( + await screen.findByRole("button", { name: "Move File to…" }), + ); + await user.click( + within(screen.getByRole("dialog", { name: "Move to Folder" })) + .getByRole("button", { name: "archive" }), + ); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("move_vault_entry", { + relativePath: "docs/reference.pdf", + newRelativePath: "archive/reference.pdf", + vaultPath: "/vault", + }); + }); + }); + + it("moves a generic file from the context menu with the folder picker", async () => { + const user = userEvent.setup(); + + setVaultNotes([]); + setVaultEntries([ + buildFolderEntry("docs"), + buildFolderEntry("archive"), + buildFileEntry("docs/config.toml", "application/toml"), + ]); + useSettingsStore + .getState() + .setSetting("fileTreeContentMode", "all_files"); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "move_vault_entry") { + return { + ...buildFileEntry("archive/config.toml", "application/toml"), + modified_at: 2, + }; + } + if (command === "list_vault_entries") { + return [ + buildFolderEntry("docs"), + buildFolderEntry("archive"), + buildFileEntry("archive/config.toml", "application/toml"), + ]; + } + return undefined; + }); + + renderComponent(); + await expandFolder(user, "docs"); + + fireEvent.contextMenu(getFileRow("config")); + await user.click( + await screen.findByRole("button", { name: "Move File to…" }), + ); + await user.click( + within(screen.getByRole("dialog", { name: "Move to Folder" })) + .getByRole("button", { name: "archive" }), + ); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("move_vault_entry", { + relativePath: "docs/config.toml", + newRelativePath: "archive/config.toml", + vaultPath: "/vault", + }); + }); + }); + + it("moves mixed selected notes and files from the context menu", async () => { + const user = userEvent.setup(); + const renameNote = vi + .fn() + .mockImplementation(async (_noteId: string, newPath: string) => ({ + id: newPath, + path: `/vault/${newPath}.md`, + title: newPath.split("/").pop() ?? newPath, + })); + + useVaultStore.setState({ renameNote }); + setVaultNotes([ + { + id: "notes/alpha", + path: "/vault/notes/alpha.md", + title: "Alpha", + modified_at: 1, + created_at: 1, + }, + ]); + setVaultEntries([ + buildFolderEntry("notes"), + buildFolderEntry("docs"), + buildFolderEntry("archive"), + buildFileEntry("docs/config.toml", "application/toml"), + ]); + useSettingsStore + .getState() + .setSetting("fileTreeContentMode", "all_files"); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "move_vault_entry") { + return { + ...buildFileEntry("archive/config.toml", "application/toml"), + modified_at: 2, + }; + } + if (command === "list_vault_entries") { + return [ + buildFolderEntry("notes"), + buildFolderEntry("docs"), + buildFolderEntry("archive"), + buildFileEntry("archive/config.toml", "application/toml"), + ]; + } + return undefined; + }); + + renderComponent(); + await expandFolder(user, "notes"); + await expandFolder(user, "docs"); + + fireEvent.click(getNoteRow("Alpha"), { metaKey: true }); + fireEvent.click(getFileRow("config"), { metaKey: true }); + fireEvent.contextMenu(getFileRow("config")); + + await user.click( + await screen.findByRole("button", { + name: "Move Selected Items to…", + }), + ); + await user.click( + within(screen.getByRole("dialog", { name: "Move to Folder" })) + .getByRole("button", { name: "archive" }), + ); + + await waitFor(() => { + expect(renameNote).toHaveBeenCalledWith("notes/alpha", "archive/alpha"); + expect(invoke).toHaveBeenCalledWith("move_vault_entry", { + relativePath: "docs/config.toml", + newRelativePath: "archive/config.toml", + vaultPath: "/vault", + }); + }); + }); + + it("shows move for mixed selections opened from a selected folder", async () => { + const user = userEvent.setup(); + + setVaultNotes([]); + setVaultEntries([ + buildFolderEntry("docs"), + buildFolderEntry("archive"), + buildFileEntry("docs/config.toml", "application/toml"), + ]); + useSettingsStore + .getState() + .setSetting("fileTreeContentMode", "all_files"); + + renderComponent(); + await expandFolder(user, "docs"); + + fireEvent.click(getFolderRow("docs"), { metaKey: true }); + fireEvent.click(getFileRow("config"), { metaKey: true }); + fireEvent.contextMenu(getFolderRow("docs")); + + await user.click( + await screen.findByRole("button", { + name: "Move Selected Items to…", + }), + ); + + const picker = screen.getByRole("dialog", { name: "Move to Folder" }); + expect( + within(picker).getByRole("button", { name: "docs" }), + ).toBeDisabled(); + expect( + within(picker).getByRole("button", { name: "archive" }), + ).toBeEnabled(); + }); + + it("filters folders in the move destination picker", async () => { + const user = userEvent.setup(); + const renameNote = vi + .fn() + .mockImplementation(async (_noteId: string, newPath: string) => ({ + id: newPath, + path: `/vault/${newPath}.md`, + title: newPath.split("/").pop() ?? newPath, + })); + + useVaultStore.setState({ renameNote }); + setVaultNotes([ + { + id: "notes/alpha", + path: "/vault/notes/alpha.md", + title: "Alpha", + modified_at: 1, + created_at: 1, + }, + { + id: "archive/keep", + path: "/vault/archive/keep.md", + title: "Keep", + modified_at: 1, + created_at: 1, + }, + { + id: "receipts/may", + path: "/vault/receipts/may.md", + title: "May", + modified_at: 1, + created_at: 1, + }, + ]); + + renderComponent(); + await expandFolder(user, "notes"); + + fireEvent.contextMenu(getNoteRow("Alpha")); + await user.click( + await screen.findByRole("button", { name: "Move Note to…" }), + ); + const picker = screen.getByRole("dialog", { name: "Move to Folder" }); + await user.type(within(picker).getByPlaceholderText("Search folders..."), "rece"); + + expect( + within(picker).getByRole("button", { name: "receipts" }), + ).toBeInTheDocument(); + expect( + within(picker).queryByRole("button", { name: "archive" }), + ).not.toBeInTheDocument(); + }); + it("opens a note when clicked in the tree", async () => { const user = userEvent.setup(); diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 211af923..08d97333 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -38,9 +38,12 @@ import { useLayoutStore } from "../../app/store/layoutStore"; import { buildEntryMovePath, buildNoteMoveOperations, - canMoveFolderToTarget, + canMoveTargetsToFolder, getBaseName, + getMoveTargetCount, getParentPath, + normalizeMoveTargets, + type MoveTargets, } from "./fileTreeMoves"; import { buildCopiedFolderPath, @@ -71,6 +74,7 @@ import { subscribeSafeStorage, } from "../../app/utils/safeStorage"; import { logError } from "../../app/utils/runtimeLog"; +import { getViewportSafeMenuPosition } from "../../app/utils/menuPosition"; // --- Sort --- @@ -179,6 +183,12 @@ type ChatContextTargets = { folderPaths: string[]; }; +type MovePickerState = { + x: number; + y: number; + targets: MoveTargets; +}; + function getSelectableRowKey(row: FlatTreeRow): string | null { if (row.kind === "folder") { return `folder:${row.path}`; @@ -749,7 +759,6 @@ type FileTreeContextPayload = | { kind: "blank" } | { kind: "folder"; path: string; expanded: boolean } | { kind: "note"; note: NoteDto } - | { kind: "move-note"; note: NoteDto } | { kind: "pdf"; entry: VaultEntryDto } | { kind: "file"; entry: VaultEntryDto }; @@ -1567,7 +1576,7 @@ interface DragState { | { kind: "folder"; path: string } | { kind: "pdf"; entry: VaultEntryDto } | { kind: "file"; entry: VaultEntryDto } - | { kind: "selection"; targets: ChatContextTargets }; + | { kind: "selection"; targets: MoveTargets }; startX: number; startY: number; active: boolean; @@ -1634,6 +1643,245 @@ function buildFileTreeDragDetail( }; } +function getDragMoveTargets(item: DragState["item"]): MoveTargets { + switch (item.kind) { + case "selection": + return item.targets; + case "folder": + return { notes: [], entries: [], folderPaths: [item.path] }; + case "pdf": + case "file": + return { notes: [], entries: [item.entry], folderPaths: [] }; + case "notes": + return { notes: item.notes, entries: [], folderPaths: [] }; + } +} + +function getMoveMenuLabel(targets: MoveTargets) { + const count = getMoveTargetCount(targets); + if (count > 1) { + if (targets.notes.length === count) return "Move Selected Notes to…"; + if (targets.entries.length === count) return "Move Selected Files to…"; + return "Move Selected Items to…"; + } + + if (targets.folderPaths.length === 1) return "Move Folder to…"; + if (targets.notes.length === 1) return "Move Note to…"; + return "Move File to…"; +} + +function hasMoveDestination(targets: MoveTargets, folderPaths: string[]) { + if (canMoveTargetsToFolder(targets, "")) return true; + return folderPaths.some((folderPath) => + canMoveTargetsToFolder(targets, folderPath), + ); +} + +function MoveDestinationPicker({ + state, + folderPaths, + onMove, + onClose, +}: { + state: MovePickerState; + folderPaths: string[]; + onMove: (targetFolder: string) => void; + onClose: () => void; +}) { + const ref = useRef(null); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [position, setPosition] = useState({ x: state.x, y: state.y }); + const normalizedQuery = query.trim().toLowerCase(); + const visibleFolderPaths = useMemo(() => { + if (!normalizedQuery) return folderPaths; + return folderPaths.filter((folderPath) => { + const folderName = getBaseName(folderPath).toLowerCase(); + return ( + folderName.includes(normalizedQuery) || + folderPath.toLowerCase().includes(normalizedQuery) + ); + }); + }, [folderPaths, normalizedQuery]); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + setPosition( + getViewportSafeMenuPosition( + state.x, + state.y, + rect.width, + rect.height, + ), + ); + }, [state.x, state.y, visibleFolderPaths.length]); + + useEffect(() => { + inputRef.current?.focus(); + + const handleDown = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + onClose(); + } + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + document.addEventListener("mousedown", handleDown); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleDown); + document.removeEventListener("keydown", handleKey); + }; + }, [onClose]); + + const renderTargetButton = (folderPath: string, label: string) => { + const disabled = !canMoveTargetsToFolder(state.targets, folderPath); + return ( + + ); + }; + + return createPortal( +
+
+ Move to Folder +
+ setQuery(event.target.value)} + placeholder="Search folders..." + style={{ + width: "100%", + borderRadius: 7, + border: "1px solid var(--border)", + backgroundColor: "var(--bg-primary)", + color: "var(--text-primary)", + fontSize: 12, + padding: "6px 8px", + outline: "none", + }} + /> +
+ {renderTargetButton("", "/ Root")} + {visibleFolderPaths.map((folderPath) => + renderTargetButton(folderPath, folderPath), + )} + {visibleFolderPaths.length === 0 ? ( +
+ No matching folders +
+ ) : null} +
+
, + document.body, + ); +} + // --- Main FileTree --- export function FileTree() { @@ -1730,6 +1978,7 @@ export function FileTree() { const [creatingParentPath, setCreatingParentPath] = useState(""); const [contextMenu, setContextMenu] = useState | null>(null); + const [movePicker, setMovePicker] = useState(null); const [renamingNoteId, setRenamingNoteId] = useState(null); const [renamingFolderPath, setRenamingFolderPath] = useState( null, @@ -2514,42 +2763,37 @@ export function FileTree() { [relocateVaultEntry], ); - const getDragTargetFolder = useCallback( - (item: DragState["item"], hoveredFolder: string | null) => { - if (hoveredFolder === null) return null; + const applyMoveTargets = useCallback( + async (targets: MoveTargets, targetFolder: string) => { + const normalized = normalizeMoveTargets(targets); - if (item.kind === "selection") { - const foldersMovable = item.targets.folderPaths.every((path) => - canMoveFolderToTarget(path, hoveredFolder), - ); - const entriesMovable = item.targets.entries.every( - (entry) => buildEntryMovePath(entry, hoveredFolder) !== null, - ); - const noteOperations = buildNoteMoveOperations( - item.targets.notes, - hoveredFolder, - ); - const notesMovable = - item.targets.notes.length === 0 || - noteOperations.length > 0; - return foldersMovable && entriesMovable && notesMovable - ? hoveredFolder - : null; + for (const folderPath of normalized.folderPaths) { + await moveFolder(folderPath, targetFolder); } - if (item.kind === "folder") { - return canMoveFolderToTarget(item.path, hoveredFolder) - ? hoveredFolder - : null; + const noteOperations = buildNoteMoveOperations( + normalized.notes, + targetFolder, + ); + if (noteOperations.length > 0) { + await applyMoveOperations(noteOperations); } - if (item.kind === "pdf" || item.kind === "file") { - return buildEntryMovePath(item.entry, hoveredFolder) !== null - ? hoveredFolder - : null; + for (const entry of normalized.entries) { + await moveVaultEntry(entry, targetFolder); } + }, + [applyMoveOperations, moveFolder, moveVaultEntry], + ); - return buildNoteMoveOperations(item.notes, hoveredFolder).length > 0 + const getDragTargetFolder = useCallback( + (item: DragState["item"], hoveredFolder: string | null) => { + if (hoveredFolder === null) return null; + + return canMoveTargetsToFolder( + getDragMoveTargets(item), + hoveredFolder, + ) ? hoveredFolder : null; }, @@ -2667,56 +2911,7 @@ export function FileTree() { if (folder === null) return; - if (s.item.kind === "selection") { - const selectedFolderPaths = s.item.targets.folderPaths; - const isInsideSelectedFolder = (path: string) => - selectedFolderPaths.some((folderPath) => - path.startsWith(`${folderPath}/`), - ); - const folderPathsToMove = selectedFolderPaths.filter( - (path) => - !selectedFolderPaths.some( - (candidate) => - candidate !== path && - path.startsWith(`${candidate}/`), - ), - ); - const notesToMove = s.item.targets.notes.filter( - (note) => !isInsideSelectedFolder(note.id), - ); - const entriesToMove = s.item.targets.entries.filter( - (entry) => !isInsideSelectedFolder(entry.relative_path), - ); - - for (const folderPath of folderPathsToMove) { - await moveFolder(folderPath, folder); - } - if (notesToMove.length > 0) { - await applyMoveOperations( - buildNoteMoveOperations(notesToMove, folder), - ); - } - for (const entry of entriesToMove) { - await moveVaultEntry(entry, folder); - } - return; - } - - if (s.item.kind === "folder") { - await moveFolder(s.item.path, folder); - return; - } - - if (s.item.kind === "notes") { - await applyMoveOperations( - buildNoteMoveOperations(s.item.notes, folder), - ); - return; - } - - if (s.item.kind === "pdf" || s.item.kind === "file") { - await moveVaultEntry(s.item.entry, folder); - } + await applyMoveTargets(getDragMoveTargets(s.item), folder); }; window.addEventListener("mousemove", onMove); @@ -2726,10 +2921,8 @@ export function FileTree() { window.removeEventListener("mouseup", onUp); }; }, [ - applyMoveOperations, + applyMoveTargets, getDragTargetFolder, - moveFolder, - moveVaultEntry, resetDragState, ]); @@ -2882,6 +3075,24 @@ export function FileTree() { ], ); + const getSelectedMoveTargets = useCallback( + (): MoveTargets => ({ + notes: notes.filter((item) => selectedNoteIds.has(item.id)), + entries: entries.filter((item) => selectedEntryPaths.has(item.path)), + folderPaths: allFolderPaths.filter((path) => + selectedFolderPaths.has(path), + ), + }), + [ + allFolderPaths, + entries, + notes, + selectedEntryPaths, + selectedFolderPaths, + selectedNoteIds, + ], + ); + const handleNoteMouseDown = useCallback( (note: NoteDto, e: React.MouseEvent) => { if (e.button !== 0) return; @@ -2890,7 +3101,7 @@ export function FileTree() { dragStateRef.current = { item: { kind: "selection", - targets: getSelectedChatContextTargets(), + targets: getSelectedMoveTargets(), }, startX: e.clientX, startY: e.clientY, @@ -2910,7 +3121,7 @@ export function FileTree() { }; }, [ - getSelectedChatContextTargets, + getSelectedMoveTargets, notes, selectedNoteIds, selectedRowCount, @@ -2925,7 +3136,7 @@ export function FileTree() { dragStateRef.current = { item: { kind: "selection", - targets: getSelectedChatContextTargets(), + targets: getSelectedMoveTargets(), }, startX: e.clientX, startY: e.clientY, @@ -2941,7 +3152,7 @@ export function FileTree() { }; }, [ - getSelectedChatContextTargets, + getSelectedMoveTargets, selectedFolderPaths, selectedRowCount, ], @@ -2990,7 +3201,7 @@ export function FileTree() { dragStateRef.current = { item: { kind: "selection", - targets: getSelectedChatContextTargets(), + targets: getSelectedMoveTargets(), }, startX: e.clientX, startY: e.clientY, @@ -3006,7 +3217,7 @@ export function FileTree() { }; }, [ - getSelectedChatContextTargets, + getSelectedMoveTargets, selectedEntryPaths, selectedRowCount, ], @@ -3024,6 +3235,7 @@ export function FileTree() { setSelectedEntryPaths(new Set([entry.path])); setSelectedFolderPaths(new Set()); } + setMovePicker(null); setContextMenu({ x: e.clientX, y: e.clientY, @@ -3107,7 +3319,7 @@ export function FileTree() { dragStateRef.current = { item: { kind: "selection", - targets: getSelectedChatContextTargets(), + targets: getSelectedMoveTargets(), }, startX: e.clientX, startY: e.clientY, @@ -3123,7 +3335,7 @@ export function FileTree() { }; }, [ - getSelectedChatContextTargets, + getSelectedMoveTargets, selectedEntryPaths, selectedRowCount, ], @@ -3141,6 +3353,7 @@ export function FileTree() { setSelectedEntryPaths(new Set([entry.path])); setSelectedFolderPaths(new Set()); } + setMovePicker(null); setContextMenu({ x: e.clientX, y: e.clientY, @@ -3354,6 +3567,7 @@ export function FileTree() { lastClickedRowKeyRef.current = `note:${note.id}`; } + setMovePicker(null); setContextMenu({ x: e.clientX, y: e.clientY, @@ -3374,6 +3588,7 @@ export function FileTree() { lastClickedRowKeyRef.current = `folder:${path}`; } + setMovePicker(null); setContextMenu({ x: e.clientX, y: e.clientY, @@ -3390,6 +3605,7 @@ export function FileTree() { setFocusedFolderPath(""); clearEntrySelection(); setSelectedNoteIds(new Set()); + setMovePicker(null); setContextMenu({ x: e.clientX, y: e.clientY, @@ -3441,13 +3657,34 @@ export function FileTree() { [getSelectedChatContextTargets, selectedFolderPaths, selectedRowCount], ); - const applyMove = useCallback( - async (notesToMove: NoteDto[], targetFolder: string) => { - await applyMoveOperations( - buildNoteMoveOperations(notesToMove, targetFolder), - ); + const getContextMoveTargetsForNote = useCallback( + (note: NoteDto): MoveTargets => { + if (selectedRowCount > 1 && selectedNoteIds.has(note.id)) { + return getSelectedMoveTargets(); + } + return { notes: [note], entries: [], folderPaths: [] }; }, - [applyMoveOperations], + [getSelectedMoveTargets, selectedNoteIds, selectedRowCount], + ); + + const getContextMoveTargetsForEntry = useCallback( + (entry: VaultEntryDto): MoveTargets => { + if (selectedRowCount > 1 && selectedEntryPaths.has(entry.path)) { + return getSelectedMoveTargets(); + } + return { notes: [], entries: [entry], folderPaths: [] }; + }, + [getSelectedMoveTargets, selectedEntryPaths, selectedRowCount], + ); + + const getContextMoveTargetsForFolder = useCallback( + (path: string): MoveTargets => { + if (selectedRowCount > 1 && selectedFolderPaths.has(path)) { + return getSelectedMoveTargets(); + } + return { notes: [], entries: [], folderPaths: [path] }; + }, + [getSelectedMoveTargets, selectedFolderPaths, selectedRowCount], ); const handleCopyNotes = useCallback( @@ -3952,15 +4189,13 @@ export function FileTree() { ], ); - const openMoveMenu = useCallback( - (menu: ContextMenuState) => { - if (menu.payload.kind !== "note") return; - const note = menu.payload.note; + const openMovePicker = useCallback( + ( + menu: ContextMenuState, + targets: MoveTargets, + ) => { queueMicrotask(() => { - setContextMenu({ - ...menu, - payload: { kind: "move-note", note }, - }); + setMovePicker({ x: menu.x, y: menu.y, targets }); }); }, [], @@ -4004,6 +4239,7 @@ export function FileTree() { const folderName = path.split("/").pop() ?? path; const absolutePath = getAbsoluteVaultPath(vaultPath, path); const chatTargets = getContextChatTargetsForFolder(path); + const moveTargets = getContextMoveTargetsForFolder(path); const chatTargetCount = chatTargets.notes.length + chatTargets.entries.length + @@ -4056,6 +4292,14 @@ export function FileTree() { label: "Rename", action: () => handleFolderRenameStart(path), }, + { + label: getMoveMenuLabel(moveTargets), + action: () => openMovePicker(contextMenu, moveTargets), + disabled: !hasMoveDestination( + moveTargets, + allFolderPaths, + ), + }, { label: "Reveal in Finder", action: () => handleRevealFolderInFinder(path), @@ -4075,6 +4319,7 @@ export function FileTree() { case "note": { const { note } = contextMenu.payload; const contextTargetNotes = getContextTargetNotes(note); + const moveTargets = getContextMoveTargetsForNote(note); const chatTargets = getContextChatTargetsForNote(note); const chatTargetCount = chatTargets.notes.length + @@ -4085,10 +4330,7 @@ export function FileTree() { deleteTargets.length > 1 ? "Delete Selected Notes" : "Delete Note"; - const moveLabel = - contextTargetNotes.length > 1 - ? "Move Selected Notes to…" - : "Move Note to…"; + const moveLabel = getMoveMenuLabel(moveTargets); const addToChatLabel = chatTargetCount > 1 ? "Add Selected to Chat" @@ -4143,8 +4385,11 @@ export function FileTree() { }, { label: moveLabel, - action: () => openMoveMenu(contextMenu), - disabled: allFolderPaths.length === 0, + action: () => openMovePicker(contextMenu, moveTargets), + disabled: !hasMoveDestination( + moveTargets, + allFolderPaths, + ), }, { label: "Duplicate", @@ -4195,6 +4440,7 @@ export function FileTree() { } case "pdf": { const { entry } = contextMenu.payload; + const moveTargets = getContextMoveTargetsForEntry(entry); const chatTargets = getContextChatTargetsForEntry(entry); const chatTargetCount = chatTargets.notes.length + @@ -4243,6 +4489,14 @@ export function FileTree() { label: "Copy Full Path", action: () => handleCopyFullPath(entry.path), }, + { + label: getMoveMenuLabel(moveTargets), + action: () => openMovePicker(contextMenu, moveTargets), + disabled: !hasMoveDestination( + moveTargets, + allFolderPaths, + ), + }, { type: "separator" }, { label: bookmarkItems.some( @@ -4276,6 +4530,7 @@ export function FileTree() { case "file": { const { entry } = contextMenu.payload; const canOpenInApp = canOpenVaultFileEntryInApp(entry); + const moveTargets = getContextMoveTargetsForEntry(entry); const chatTargets = getContextChatTargetsForEntry(entry); const chatTargetCount = chatTargets.notes.length + @@ -4326,6 +4581,14 @@ export function FileTree() { label: "Copy Full Path", action: () => handleCopyFullPath(entry.path), }, + { + label: getMoveMenuLabel(moveTargets), + action: () => openMovePicker(contextMenu, moveTargets), + disabled: !hasMoveDestination( + moveTargets, + allFolderPaths, + ), + }, { type: "separator" }, { label: bookmarkItems.some( @@ -4356,60 +4619,17 @@ export function FileTree() { }, ]; } - case "move-note": { - const { note } = contextMenu.payload; - const moveTargets = getContextTargetNotes(note); - const firstParent = moveTargets[0]?.id.includes("/") - ? moveTargets[0].id.split("/").slice(0, -1).join("/") - : ""; - const sameParent = moveTargets.every((item) => { - const parent = item.id.includes("/") - ? item.id.split("/").slice(0, -1).join("/") - : ""; - return parent === firstParent; - }); - const currentParent = sameParent ? firstParent : null; - const folderTargets = - currentParent === null - ? allFolderPaths - : allFolderPaths.filter( - (folder) => folder !== currentParent, - ); - - return [ - { - label: "Back", - action: () => - setContextMenu({ - ...contextMenu, - payload: { - kind: "note", - note, - }, - }), - }, - { type: "separator" }, - { - label: "/ Root", - action: () => void applyMove(moveTargets, ""), - disabled: - currentParent !== null && currentParent === "", - }, - ...folderTargets.map((folder) => ({ - label: folder, - action: () => void applyMove(moveTargets, folder), - })), - ]; - } } }, [ allFolderPaths, - applyMove, contextMenu, expandedFolders.size, getContextChatTargetsForEntry, getContextChatTargetsForFolder, getContextChatTargetsForNote, + getContextMoveTargetsForEntry, + getContextMoveTargetsForFolder, + getContextMoveTargetsForNote, getContextTargetNotes, handleAddChatTargetsToChat, handleCopyFolder, @@ -4427,7 +4647,7 @@ export function FileTree() { handlePasteIntoFolder, handleRevealFolderInFinder, handleRevealNoteInFinder, - openMoveMenu, + openMovePicker, openTreeNote, startCreating, treeClipboard, @@ -5161,6 +5381,17 @@ export function FileTree() { document.body, )} + {movePicker && ( + + void applyMoveTargets(movePicker.targets, targetFolder) + } + onClose={() => setMovePicker(null)} + /> + )} + {/* Context menu */} {contextMenu && ( { it("builds move operations for multiple notes to a folder", () => { expect( @@ -94,4 +111,58 @@ describe("fileTreeMoves", () => { expect(canMoveFolderToTarget("notes", "notes")).toBe(false); expect(canMoveFolderToTarget("notes", "notes/sub")).toBe(false); }); + + it("normalizes move targets nested under selected folders", () => { + expect( + normalizeMoveTargets({ + notes: [NOTES[0], NOTES[2], NOTES[3]], + entries: [ENTRY], + folderPaths: ["notes", "notes/sub"], + }), + ).toEqual({ + notes: [NOTES[3]], + entries: [ENTRY], + folderPaths: ["notes"], + }); + }); + + it("allows mixed move targets when at least one item can move", () => { + expect( + canMoveTargetsToFolder( + { + notes: [NOTES[0]], + entries: [ENTRY], + folderPaths: [], + }, + "files", + ), + ).toBe(true); + expect( + canMoveTargetsToFolder( + { + notes: [], + entries: [ENTRY], + folderPaths: [], + }, + "files", + ), + ).toBe(false); + }); + + it("detects a common move parent only when targets share one", () => { + expect( + getMoveTargetsCommonParent({ + notes: [NOTES[0], NOTES[1]], + entries: [], + folderPaths: [], + }), + ).toBe("notes"); + expect( + getMoveTargetsCommonParent({ + notes: [NOTES[0]], + entries: [ENTRY], + folderPaths: [], + }), + ).toBe(null); + }); }); diff --git a/apps/desktop/src/features/vault/fileTreeMoves.ts b/apps/desktop/src/features/vault/fileTreeMoves.ts index e11ce9bc..5ab6c6e0 100644 --- a/apps/desktop/src/features/vault/fileTreeMoves.ts +++ b/apps/desktop/src/features/vault/fileTreeMoves.ts @@ -6,6 +6,12 @@ export interface NoteMoveOperation { toPath: string; } +export interface MoveTargets { + notes: NoteDto[]; + entries: VaultEntryDto[]; + folderPaths: string[]; +} + export function getParentPath(path: string) { return path.includes("/") ? path.split("/").slice(0, -1).join("/") : ""; } @@ -77,3 +83,69 @@ export function buildFolderMoveOperations( ]; }); } + +export function getMoveTargetCount(targets: MoveTargets) { + return ( + targets.notes.length + + targets.entries.length + + targets.folderPaths.length + ); +} + +export function getMoveTargetsCommonParent(targets: MoveTargets) { + const parents = [ + ...targets.notes.map((note) => getParentPath(note.id)), + ...targets.entries.map((entry) => getParentPath(entry.relative_path)), + ...targets.folderPaths.map(getParentPath), + ]; + + if (parents.length === 0) return null; + const firstParent = parents[0] ?? ""; + return parents.every((parent) => parent === firstParent) + ? firstParent + : null; +} + +export function normalizeMoveTargets(targets: MoveTargets): MoveTargets { + const folderPaths = targets.folderPaths.filter( + (path) => + !targets.folderPaths.some( + (candidate) => + candidate !== path && path.startsWith(`${candidate}/`), + ), + ); + const isInsideSelectedFolder = (path: string) => + folderPaths.some((folderPath) => path.startsWith(`${folderPath}/`)); + + return { + folderPaths, + notes: targets.notes.filter((note) => !isInsideSelectedFolder(note.id)), + entries: targets.entries.filter( + (entry) => !isInsideSelectedFolder(entry.relative_path), + ), + }; +} + +export function canMoveTargetsToFolder( + targets: MoveTargets, + targetFolder: string, +) { + const normalized = normalizeMoveTargets(targets); + if (getMoveTargetCount(normalized) === 0) return false; + + const foldersMovable = normalized.folderPaths.every((path) => + canMoveFolderToTarget(path, targetFolder), + ); + if (!foldersMovable) return false; + + const folderMoves = normalized.folderPaths.length; + const noteMoves = buildNoteMoveOperations( + normalized.notes, + targetFolder, + ).length; + const entryMoves = normalized.entries.filter( + (entry) => buildEntryMovePath(entry, targetFolder) !== null, + ).length; + + return folderMoves + noteMoves + entryMoves > 0; +} From 958dcd0276f3eee806e3a07862d5b72bfb100630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 15 May 2026 01:39:05 -0400 Subject: [PATCH 2/4] Open move picker without extra defer --- apps/desktop/src/features/vault/FileTree.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 08d97333..5cf5e5dd 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -4194,9 +4194,7 @@ export function FileTree() { menu: ContextMenuState, targets: MoveTargets, ) => { - queueMicrotask(() => { - setMovePicker({ x: menu.x, y: menu.y, targets }); - }); + setMovePicker({ x: menu.x, y: menu.y, targets }); }, [], ); From 3ed4cf73594cc9220ba4101abc0dcf98998c19ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 15 May 2026 01:53:38 -0400 Subject: [PATCH 3/4] Add folder icons to move picker --- apps/desktop/src/features/vault/FileTree.tsx | 51 +++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index 5cf5e5dd..ae57393c 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -1754,8 +1754,8 @@ function MoveDestinationPicker({ className="text-left rounded" style={{ display: "flex", - flexDirection: "column", - gap: 1, + alignItems: "center", + gap: 8, width: "100%", padding: "6px 8px", color: "var(--text-primary)", @@ -1772,34 +1772,51 @@ function MoveDestinationPicker({ event.currentTarget.style.backgroundColor = "transparent"; }} > + - {label} - - {folderPath ? ( - ) : null} + {folderPath ? ( + + ) : null} + ); }; From be4ad9225e81636937903ecc41335403196a96f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 15 May 2026 01:58:31 -0400 Subject: [PATCH 4/4] Show folder names in move picker --- apps/desktop/src/features/vault/FileTree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/vault/FileTree.tsx b/apps/desktop/src/features/vault/FileTree.tsx index ae57393c..27c718e7 100644 --- a/apps/desktop/src/features/vault/FileTree.tsx +++ b/apps/desktop/src/features/vault/FileTree.tsx @@ -1880,7 +1880,7 @@ function MoveDestinationPicker({ > {renderTargetButton("", "/ Root")} {visibleFolderPaths.map((folderPath) => - renderTargetButton(folderPath, folderPath), + renderTargetButton(folderPath, getBaseName(folderPath)), )} {visibleFolderPaths.length === 0 ? (