From 152187e8dd7f5c8fd2786b72d99240873964e259 Mon Sep 17 00:00:00 2001 From: usman Date: Wed, 4 Nov 2026 20:36:29 +0100 Subject: [PATCH] feat: add right-click context menu to the project explorer (#455) Right-clicking a file, folder or empty space in the project explorer now opens a VS Code-style context menu. - Files: Open, Reveal in File Explorer/Finder, Cut, Copy, Paste, Copy Path, Copy Relative Path, Rename, Delete - Folders: New File, New Folder, plus the same actions - Empty space acts on the project folder: New File, New Folder, Paste, Copy Path (no Rename/Delete/Cut/Copy for the root) Backend: new /api/files/create, /rename, /delete, /copy and /move endpoints. All of them refuse paths outside the open project (including ".." and symlinks), never touch the project folder itself, and never overwrite an existing entry. Delete moves the entry to the Recycle Bin / Trash via send2trash (new dependency, added to the PyInstaller spec). Copying onto an existing name picks "a copy.md", "a copy 2.md", ... Frontend: open tabs follow renamed and moved files; deleted files close their clean tabs and detach unsaved ones so Save cannot recreate them. The menu supports arrow keys, Enter and Escape, and keeps on screen. Tests: 13 backend endpoint tests and 8 frontend path/tab tests. --- backend/routers/files.py | 237 ++++++++++++ frontend/src/app/page.tsx | 3 + frontend/src/components/ProjectExplorer.tsx | 397 +++++++++++++++++++- frontend/src/hooks/useEditor.ts | 59 +++ frontend/src/lib/api.ts | 35 ++ frontend/src/lib/explorer-paths.mjs | 190 ++++++++++ frontend/tests/explorer-paths.test.mjs | 91 +++++ markdown-reader-backend.spec | 3 + pyproject.toml | 1 + tests/test_files_endpoints.py | 281 ++++++++++++++ uv.lock | 11 + 11 files changed, 1288 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/explorer-paths.mjs create mode 100644 frontend/tests/explorer-paths.test.mjs diff --git a/backend/routers/files.py b/backend/routers/files.py index 8fb81d67..26fa3948 100644 --- a/backend/routers/files.py +++ b/backend/routers/files.py @@ -19,6 +19,7 @@ import json import logging import os +import shutil import sys import tempfile from importlib import import_module @@ -159,6 +160,26 @@ class FileEntry(BaseModel): extension: str +class ProjectPathPayload(BaseModel): + root: str + path: str + + +class RenamePayload(ProjectPathPayload): + new_name: str + + +class CreateEntryPayload(BaseModel): + root: str + parent: str + name: str + is_dir: bool = False + + +class TransferPayload(ProjectPathPayload): + dest_parent: str + + # ── Endpoints ───────────────────────────────────────────────────────────────── @@ -442,6 +463,222 @@ def list_files( return {"path": path, "entries": entries} +# ── Project explorer file management ───────────────────────────────────────── + + +def _resolve_in_project(root: str, path: str, *, allow_root: bool = False) -> Path: + """ + Resolve ``path`` and make sure it lies inside the project folder ``root``. + + The explorer only ever acts on entries it listed from the open project, so + anything that resolves outside it (``..`` segments, symlinks, another + drive) is rejected rather than touched. + + :param str root: Absolute path of the open project folder. + :param str path: Absolute path of the entry to act on. + :param bool allow_root: Whether ``path`` may be the project folder itself. + + :return: A Path with symlinks and ``..`` segments resolved. + + :raises HTTPException: 404 if the project folder does not exist, 403 if + ``path`` is outside it (or is the folder itself and that is not allowed). + """ + root_path = Path(root).resolve() + if not root_path.is_dir(): + raise HTTPException(status_code=404, detail=f"Project not found: {root}") + + target = Path(path).resolve() + if target != root_path and root_path not in target.parents: + raise HTTPException(status_code=403, detail="Path is outside the project") + + if target == root_path and not allow_root: + raise HTTPException( + status_code=403, detail="The project folder itself cannot be changed" + ) + return target + + +def _validate_entry_name(name: str) -> str: + """ + Check that ``name`` is a single file or folder name, not a path. + + :param str name: The name typed by the user. + + :return: The name with surrounding whitespace removed. + + :raises HTTPException: 400 if the name is empty, ``.``/``..``, or contains a + path separator or a character Windows does not allow in file names. + """ + cleaned = name.strip() + if not cleaned or cleaned in {".", ".."}: + raise HTTPException(status_code=400, detail="Name cannot be empty") + + # Reject any special/invalid characters + if any(char in cleaned for char in '/\\:*?"<>|') or any( + ord(char) < 32 for char in cleaned + ): + raise HTTPException( + status_code=400, detail=f"Name contains invalid characters: {cleaned}" + ) + + return cleaned + + +def _resolve_transfer(payload: TransferPayload) -> tuple[Path, Path]: + """ + Resolve and check the source and destination folder of a copy or move. + + :param TransferPayload payload: The project, the entry to transfer, and the + folder to paste it into. + + :return: The resolved source entry and destination folder. + + :raises HTTPException: 403 if either path is outside the project (or the + source is the project folder itself), 404 if the source or destination + is missing, 400 if a folder would be pasted into itself. + """ + source = _resolve_in_project(payload.root, payload.path) + dest = _resolve_in_project(payload.root, payload.dest_parent, allow_root=True) + if not source.exists(): + raise HTTPException(status_code=404, detail=f"Not found: {payload.path}") + if not dest.is_dir(): + raise HTTPException(status_code=404, detail=f"Folder not found: {dest}") + if source.is_dir() and (dest == source or source in dest.parents): + raise HTTPException( + status_code=400, detail="A folder cannot be pasted into itself" + ) + return source, dest + + +def _unique_copy_target(dest: Path, source: Path) -> Path: + """ + Pick a free name in ``dest`` for a copy of ``source``. + + Keeps the original name when it is free, otherwise follows VS Code: + ``a.md`` -> ``a copy.md`` -> ``a copy 2.md``. Folders have no extension. + + :param Path dest: Folder the copy goes into. + :param Path source: Entry being copied. + + :return: A path in ``dest`` that does not exist yet. + """ + target = dest / source.name + if not target.exists(): + return target + + if source.is_dir(): + stem, suffix = source.name, "" + else: + stem, suffix = source.stem, source.suffix + counter = 1 + while True: + label = " copy" if counter == 1 else f" copy {counter}" + target = dest / f"{stem}{label}{suffix}" + if not target.exists(): + return target + counter += 1 + + +def _move_to_trash(path: Path) -> None: + """Send ``path`` to the system Recycle Bin / Trash (patched in tests).""" + from send2trash import send2trash + + send2trash(str(path)) + + +@router.post("/create") +def create_entry(payload: CreateEntryPayload): + """Create an empty file or folder inside the parent path.""" + parent = _resolve_in_project(payload.root, payload.parent, allow_root=True) + + if not parent.is_dir(): + raise HTTPException(status_code=404, detail=f"Folder not found: {parent}") + + target = parent / _validate_entry_name(payload.name) + if target.exists(): + raise HTTPException(status_code=409, detail=f"Already exists: {target.name}") + + try: + if payload.is_dir: + target.mkdir() + else: + target.touch(exist_ok=False) + except OSError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"path": str(target), "is_dir": payload.is_dir} + + +@router.post("/rename") +def rename_entry(payload: RenamePayload): + """Rename a file or folder in place, keeping it in the same folder.""" + source = _resolve_in_project(payload.root, payload.path) + if not source.exists(): + raise HTTPException(status_code=404, detail=f"Not found: {payload.path}") + + target = source.with_name(_validate_entry_name(payload.new_name)) + if target == source: + return {"old_path": str(source), "path": str(target)} + + # A case-only rename ("notes.md" -> "Notes.md") resolves to the same file on + # Windows and macOS, so it is not a conflict. + if target.exists() and not os.path.samefile(source, target): + raise HTTPException(status_code=409, detail=f"Already exists: {target.name}") + try: + source.rename(target) + except OSError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"old_path": str(source), "path": str(target)} + + +@router.post("/copy") +def copy_entry(payload: TransferPayload): + """Copy a file or folder into ``dest_parent``, picking a free name on a clash.""" + source, dest = _resolve_transfer(payload) + target = _unique_copy_target(dest, source) + try: + if source.is_dir(): + # Copy links as links so a symlink cannot pull in files from + # outside the project. + shutil.copytree(source, target, symlinks=True) + else: + shutil.copy2(source, target) + except OSError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"path": str(target)} + + +@router.post("/move") +def move_entry(payload: TransferPayload): + """Move a file or folder into ``dest_parent`` (the explorer's Cut and Paste).""" + source, dest = _resolve_transfer(payload) + target = dest / source.name + if target == source: + # Pasting back into the folder it was cut from changes nothing. + return {"old_path": str(source), "path": str(target)} + if target.exists(): + raise HTTPException(status_code=409, detail=f"Already exists: {target.name}") + try: + shutil.move(source, target) + except OSError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"old_path": str(source), "path": str(target)} + + +@router.post("/delete") +def delete_entry(payload: ProjectPathPayload): + """Move a file or folder to the system Recycle Bin / Trash.""" + target = _resolve_in_project(payload.root, payload.path) + if not target.exists(): + raise HTTPException(status_code=404, detail=f"Not found: {payload.path}") + + try: + _move_to_trash(target) + except OSError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + return {"path": str(target), "deleted": True} + + @router.get("/recent") def get_recent_files(): """Return the most-recently-opened file paths.""" diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ed846b0e..57d64a30 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -152,6 +152,9 @@ export default function HomePage() { onOpenFile={(path) => { void editor.openFile(path); }} onOpenProject={() => { void openProject(); }} onCloseProject={project.closeProject} + onPathRenamed={editor.handlePathRenamed} + onPathDeleted={editor.handlePathDeleted} + canReveal={isDesktopRuntime} refreshToken={projectRefreshToken} /> )} diff --git a/frontend/src/components/ProjectExplorer.tsx b/frontend/src/components/ProjectExplorer.tsx index f1a349d5..cac12380 100644 --- a/frontend/src/components/ProjectExplorer.tsx +++ b/frontend/src/components/ProjectExplorer.tsx @@ -1,34 +1,95 @@ "use client"; -import { useEffect, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { Files, type FileEntry } from "@/lib/api"; +import { isWithin, joinPath, relativePath, remapPath, renamedPath } from "@/lib/explorer-paths.mjs"; +import { revealInFileManager } from "@/utils/revealInFileManager"; type Props = { root: string | null; onOpenFile: (path: string) => void; onOpenProject: () => void; onCloseProject: () => void; + /** Called after an entry is renamed on disk, so open tabs can follow it. */ + onPathRenamed: (oldPath: string, newPath: string) => void; + /** Called after an entry is moved to the trash, so its tabs can be closed. */ + onPathDeleted: (path: string) => void; + /** Reveal in File Explorer / Finder needs the Tauri shell. */ + canReveal: boolean; refreshToken: number; }; -function Tree({ path, depth, onOpenFile }: { path: string; depth: number; onOpenFile: (path: string) => void }) { +/** What was right-clicked: a file, a folder, or the empty area (project root). */ +type MenuTarget = { path: string; name: string; isDir: boolean; isRoot: boolean; parent: string }; +type MenuState = { target: MenuTarget; x: number; y: number }; +type DialogState = + | { kind: "create"; parent: string; isDir: boolean } + | { kind: "rename"; target: MenuTarget } + | { kind: "delete"; target: MenuTarget }; +/** An entry waiting to be pasted. Cut moves it; Copy duplicates it. */ +type ClipboardState = { mode: "cut" | "copy"; target: MenuTarget }; + +type ExplorerContextValue = { + onOpenFile: (path: string) => void; + openMenu: (event: ReactMouseEvent, target: MenuTarget) => void; + /** Bumped per folder to make that folder list itself again. */ + versions: Record; + expanded: Set; + toggleExpanded: (path: string) => void; + /** The entry that was cut, shown dimmed until it is pasted. */ + cutPath: string | null; +}; + +const ExplorerContext = createContext(null); + +function useExplorer() { + const value = useContext(ExplorerContext); + if (!value) throw new Error("ProjectExplorer context missing"); + return value; +} + +function revealLabel() { + const platform = typeof navigator === "undefined" ? "" : navigator.userAgent; + if (/Mac/i.test(platform)) return "Reveal in Finder"; + if (/Win/i.test(platform)) return "Reveal in File Explorer"; + return "Open Containing Folder"; +} + +function errorMessage(reason: unknown) { + const message = reason instanceof Error ? reason.message : String(reason); + // `apiFetch` errors read `API /api/files/move → 409: {"detail":"Already exists: a.md"}`; + // show only the backend's explanation. + const body = /→ \d+: (.*)$/s.exec(message)?.[1]; + if (body) { + try { + const { detail } = JSON.parse(body) as { detail?: unknown }; + if (typeof detail === "string") return detail; + } catch { + // Not JSON; fall back to the full message. + } + } + return message; +} + +function Tree({ path, depth }: { path: string; depth: number }) { + const { onOpenFile, openMenu, versions, cutPath } = useExplorer(); + const version = versions[path] ?? 0; const [entries, setEntries] = useState([]); - const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); useEffect(() => { - if (loaded) return; let active = true; Files.list(path) - .then(({ entries: next }) => { if (active) { setEntries(next); setLoaded(true); } }) - .catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : String(reason)); }); + .then(({ entries: next }) => { if (active) { setEntries(next); setError(""); } }) + .catch((reason) => { if (active) setError(errorMessage(reason)); }); return () => { active = false; }; - }, [loaded, path]); + }, [path, version]); return <> - {entries.map((entry) => entry.is_dir ? : ( - ))} @@ -36,24 +97,309 @@ function Tree({ path, depth, onOpenFile }: { path: string; depth: number; onOpen ; } -function DirectoryNode({ entry, depth, onOpenFile }: { entry: FileEntry; depth: number; onOpenFile: (path: string) => void }) { - const [open, setOpen] = useState(false); +function DirectoryNode({ entry, depth, parent }: { entry: FileEntry; depth: number; parent: string }) { + const { openMenu, expanded, toggleExpanded, cutPath } = useExplorer(); + const open = expanded.has(entry.path); return
- - {open && } + {open && }
; } +type MenuItem = { label: string; onSelect: () => void; disabled?: boolean } | "separator"; + +const isSelectable = (item: MenuItem) => item !== "separator" && !item.disabled; + +function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: MenuItem[]; onClose: () => void }) { + const ref = useRef(null); + const [position, setPosition] = useState({ left: x, top: y }); + // One highlight, driven by both the mouse and the arrow keys (like VS Code). + // Nothing is highlighted until the user hovers or presses an arrow key. + const [activeIndex, setActiveIndex] = useState(-1); + + // Keep the menu on screen when opened near the bottom or right edge. + useLayoutEffect(() => { + const menu = ref.current; + if (!menu) return; + const { width, height } = menu.getBoundingClientRect(); + setPosition({ + left: Math.max(4, Math.min(x, window.innerWidth - width - 4)), + top: Math.max(4, Math.min(y, window.innerHeight - height - 4)), + }); + // Focus the menu itself, not an item, so keys work without a highlight. + menu.focus(); + }, [x, y]); + + useEffect(() => { + const onPointerDown = (event: MouseEvent) => { + if (!ref.current?.contains(event.target as Node)) onClose(); + }; + window.addEventListener("mousedown", onPointerDown); + window.addEventListener("blur", onClose); + window.addEventListener("resize", onClose); + return () => { + window.removeEventListener("mousedown", onPointerDown); + window.removeEventListener("blur", onClose); + window.removeEventListener("resize", onClose); + }; + }, [onClose]); + + const select = (item: MenuItem) => { + if (item === "separator" || !isSelectable(item)) return; + onClose(); + item.onSelect(); + }; + + // Step to the next selectable item in `direction`, skipping separators and + // disabled items. + const move = (direction: 1 | -1) => { + // With nothing highlighted, Down starts at the first item and Up at the last. + let index = activeIndex >= 0 ? activeIndex : direction === 1 ? -1 : items.length; + for (let step = 0; step < items.length; step += 1) { + index = (index + direction + items.length) % items.length; + if (isSelectable(items[index])) { setActiveIndex(index); return; } + } + }; + + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") onClose(); + else if (event.key === "ArrowDown") move(1); + else if (event.key === "ArrowUp") move(-1); + else if (event.key === "Enter" && activeIndex >= 0) select(items[activeIndex]); + else return; + event.preventDefault(); + event.stopPropagation(); + }; + + return ( +
event.preventDefault()} + onMouseLeave={() => setActiveIndex(-1)} + aria-activedescendant={activeIndex >= 0 ? `explorer-menu-item-${activeIndex}` : undefined} + className="fixed z-50 min-w-[200px] rounded-md border border-gray-200 bg-white py-1 text-sm text-gray-800 shadow-lg outline-none dark:border-gray-600 dark:bg-[#252526] dark:text-gray-200" + style={position}> + {items.map((item, index) => item === "separator" ? ( +
+ ) : ( + + ))} +
+ ); +} + +function ExplorerDialog({ dialog, onCancel, onSubmit }: { + dialog: DialogState; + onCancel: () => void; + onSubmit: (value: string) => Promise; +}) { + const isDelete = dialog.kind === "delete"; + const initial = dialog.kind === "rename" ? dialog.target.name : ""; + const [value, setValue] = useState(initial); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + const confirmRef = useRef(null); + + useEffect(() => { + if (isDelete) { confirmRef.current?.focus(); return; } + const input = inputRef.current; + if (!input) return; + + input.focus(); + // Like VS Code, select the name without its extension when renaming a file. + const dot = dialog.kind === "rename" && !dialog.target.isDir ? initial.lastIndexOf(".") : -1; + input.setSelectionRange(0, dot > 0 ? dot : initial.length); + }, [dialog, initial, isDelete]); + + const title = dialog.kind === "create" ? (dialog.isDir ? "New Folder" : "New File") + : dialog.kind === "rename" ? "Rename" : "Delete"; + + const submit = async () => { + if (!isDelete && !value.trim()) { setError("Please enter a name."); return; } + setBusy(true); + setError(""); + try { + await onSubmit(value.trim()); + } catch (reason) { + setError(errorMessage(reason)); + setBusy(false); + } + }; + + return ( +
+
event.stopPropagation()} + onSubmit={(event) => { event.preventDefault(); void submit(); }} + onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); onCancel(); } }} + className="w-80 rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-800 shadow-xl dark:border-gray-600 dark:bg-[#252526] dark:text-gray-200"> +

{title}

+ {isDelete ? ( +

+ Delete ‘{dialog.target.name}’{dialog.target.isDir ? " and its contents" : ""}? + You can restore it from the Recycle Bin / Trash. +

+ ) : ( + setValue(event.target.value)} disabled={busy} + aria-label={title} spellCheck={false} + className="mb-3 w-full rounded border border-gray-300 px-2 py-1 outline-none focus:border-blue-500 dark:border-gray-600 dark:bg-[#1e1e1e]" /> + )} + {error &&

{error}

} +
+ + +
+
+
+ ); +} + function ExplorerIcon() { return ; } -export default function ProjectExplorer({ root, onOpenFile, onOpenProject, onCloseProject, refreshToken }: Props) { +export default function ProjectExplorer({ root, onOpenFile, onOpenProject, onCloseProject, onPathRenamed, onPathDeleted, canReveal, refreshToken }: Props) { const name = root?.split(/[\\/]/).filter(Boolean).pop() || "Project"; const [collapsed, setCollapsed] = useState(false); + const [menu, setMenu] = useState(null); + const [dialog, setDialog] = useState(null); + const [versions, setVersions] = useState>({}); + const [expanded, setExpanded] = useState>(() => new Set()); + const [clipboard, setClipboard] = useState(null); + /** A failed paste has no dialog to show its error in, so it shows here. */ + const [notice, setNotice] = useState(""); + + // A different project starts with every folder collapsed and nothing to paste. + useEffect(() => { + setExpanded(new Set()); setMenu(null); setDialog(null); setClipboard(null); setNotice(""); + }, [root]); + + const reload = useCallback((dir: string) => { + setVersions((current) => ({ ...current, [dir]: (current[dir] ?? 0) + 1 })); + }, []); + const toggleExpanded = useCallback((path: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); else next.add(path); + return next; + }); + }, []); + const openMenu = useCallback((event: ReactMouseEvent, target: MenuTarget) => { + event.preventDefault(); + event.stopPropagation(); + setNotice(""); + setMenu({ target, x: event.clientX, y: event.clientY }); + }, []); + const closeMenu = useCallback(() => setMenu(null), []); + + /** Keep expanded folders open after the folder holding them is renamed or moved. */ + const remapExpanded = (oldPath: string, newPath: string) => { + setExpanded((current) => new Set([...current].map((path) => remapPath(path, oldPath, newPath)))); + }; + /** Forget a cut or copied entry once it is renamed or deleted. */ + const dropClipboardWithin = (path: string) => { + setClipboard((current) => (current && isWithin(current.target.path, path) ? null : current)); + }; + + const paste = async (destParent: string) => { + if (!root || !clipboard) return; + const { mode, target } = clipboard; + try { + if (mode === "copy") { + await Files.copy(root, target.path, destParent); + } else { + const { path } = await Files.move(root, target.path, destParent); + // Use the folder's own path form, as for New File, so tabs match the tree. + const newPath = joinPath(destParent, path.split(/[/\\]/).pop() ?? target.name); + if (newPath !== target.path) { + remapExpanded(target.path, newPath); + onPathRenamed(target.path, newPath); + reload(target.parent); + } + // A cut entry can only be pasted once; a copy can be pasted again. + setClipboard(null); + } + setExpanded((current) => new Set(current).add(destParent)); + reload(destParent); + } catch (reason) { + setNotice(errorMessage(reason)); + } + }; + + const menuItems = (target: MenuTarget): MenuItem[] => { + if (!root) return []; + const copy = (text: string) => { void navigator.clipboard?.writeText(text).catch(console.error); }; + const items: MenuItem[] = []; + if (target.isDir) { + items.push( + { label: "New File…", onSelect: () => setDialog({ kind: "create", parent: target.path, isDir: false }) }, + { label: "New Folder…", onSelect: () => setDialog({ kind: "create", parent: target.path, isDir: true }) }, + "separator", + ); + } else { + items.push({ label: "Open", onSelect: () => onOpenFile(target.path) }, "separator"); + } + if (canReveal) { + items.push({ label: revealLabel(), onSelect: () => { void revealInFileManager(target.path).catch(console.error); } }, "separator"); + } + if (!target.isRoot) { + items.push( + { label: "Cut", onSelect: () => setClipboard({ mode: "cut", target }) }, + { label: "Copy", onSelect: () => setClipboard({ mode: "copy", target }) }, + ); + } + // Pasting on a file pastes next to it, as in VS Code. + const destParent = target.isDir ? target.path : target.parent; + items.push( + { label: "Paste", disabled: !clipboard, onSelect: () => { void paste(destParent); } }, + "separator", + { label: "Copy Path", onSelect: () => copy(target.path) }, + { label: "Copy Relative Path", onSelect: () => copy(relativePath(root, target.path)) }, + ); + if (!target.isRoot) { + items.push( + "separator", + { label: "Rename…", onSelect: () => setDialog({ kind: "rename", target }) }, + { label: "Delete", onSelect: () => setDialog({ kind: "delete", target }) }, + ); + } + return items; + }; + + const submitDialog = async (value: string) => { + if (!root || !dialog) return; + if (dialog.kind === "create") { + const { path } = await Files.create(root, dialog.parent, value, dialog.isDir); + setExpanded((current) => new Set(current).add(dialog.parent)); + reload(dialog.parent); + // Open the new file straight away, as VS Code does. Use the folder's own + // path form rather than the backend's resolved one so tabs match the tree. + if (!dialog.isDir) onOpenFile(joinPath(dialog.parent, path.split(/[/\\]/).pop() ?? value)); + } else if (dialog.kind === "rename") { + await Files.rename(root, dialog.target.path, value); + const newPath = renamedPath(dialog.target.path, value); + remapExpanded(dialog.target.path, newPath); + dropClipboardWithin(dialog.target.path); + onPathRenamed(dialog.target.path, newPath); + reload(dialog.target.parent); + } else { + await Files.delete(root, dialog.target.path); + dropClipboardWithin(dialog.target.path); + onPathDeleted(dialog.target.path); + reload(dialog.target.parent); + } + setDialog(null); + }; if (collapsed) { return ( @@ -106,20 +452,31 @@ export default function ProjectExplorer({ root, onOpenFile, onOpenProject, onClo
) : ( - <> +
{name}
-
- + {notice && ( +
+ {notice} + +
+ )} + {/* Right-clicking empty space below the tree acts on the project folder. */} +
openMenu(event, { path: root, name, isDir: true, isRoot: true, parent: root })}> +
- + )} )} + + {menu && } + {dialog && setDialog(null)} onSubmit={submitDialog} />}
); } diff --git a/frontend/src/hooks/useEditor.ts b/frontend/src/hooks/useEditor.ts index 796fb798..82c81b04 100644 --- a/frontend/src/hooks/useEditor.ts +++ b/frontend/src/hooks/useEditor.ts @@ -15,6 +15,10 @@ import { resolveTabClose } from "@/lib/tabLifecycle.mjs"; import { settleSavedTab } from "@/lib/save-settle.mjs"; import { parentDirOf, resolvePreviewBaseDir } from "@/lib/preview-base-dir.mjs"; import { createLatestRequestGuard } from "@/lib/latest-request.mjs"; +import { + remapTabsAfterRename, + resolveTabsAfterDelete, +} from "@/lib/explorer-paths.mjs"; export type Tab = { id: string; @@ -374,6 +378,59 @@ export function useEditor() { debounceRef.current = null; }, []); + // ── explorer rename / delete (issue #455) ────────────────────────────────── + const handlePathRenamed = useCallback((oldPath: string, newPath: string) => { + setTabs((prev) => remapTabsAfterRename(prev, oldPath, newPath)); + }, []); + + const handlePathDeleted = useCallback( + (deletedPath: string) => { + const { tabs: updated, closeIds } = resolveTabsAfterDelete( + tabs, + deletedPath, + ); + // Close every affected tab in one pass over the same list, so the active + // tab and preview end up where closing them one by one would leave them. + let remaining = updated; + let nextActiveId = activeTabId; + let previewTab: Tab | null = null; + + for (const id of closeIds) { + const result = resolveTabClose(remaining, nextActiveId, id); + remaining = result.remaining; + if (result.nextActiveTabId) nextActiveId = result.nextActiveTabId; + if (result.previewTab) previewTab = result.previewTab; + } + + if (closeIds.includes(activeTabId)) { + previewGuardRef.current.supersede(); + wordCountGuardRef.current.supersede(); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = null; + } + + if (remaining.length === 0) { + const fresh = makeTab(nextTabId()); + setTabs([fresh]); + setActiveTabId(fresh.id); + setPreviewHtml(""); + setWordCount(null); + return; + } + + setTabs(remaining); + setActiveTabId(nextActiveId); + + if (previewTab && previewTab.id === nextActiveId) { + refreshPreview( + previewTab.content, + previewTab.previewBaseDir ?? previewTab.filePath ?? undefined, + ); + } + }, + [tabs, activeTabId, refreshPreview], + ); + const applyProjectChanges = useCallback((changes: ProjectWorkChange[]) => { setTabs((previous) => { const next = [...previous]; @@ -434,6 +491,8 @@ export function useEditor() { newTab, closeTab, closeAllTabs, + handlePathRenamed, + handlePathDeleted, applyProjectChanges, loadRecentFiles, refreshPreview, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c7b5db66..1af3f99d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -302,6 +302,41 @@ export const Files = { clearRecent: () => apiFetch<{ entries: string[] }>("/api/files/recent", { method: "DELETE" }), + + // Explorer file management. `root` is the open project folder; the backend + // refuses any path that resolves outside it. + create: (root: string, parent: string, name: string, isDir: boolean) => + apiFetch<{ path: string; is_dir: boolean }>("/api/files/create", { + method: "POST", + body: JSON.stringify({ root, parent, name, is_dir: isDir }), + }), + + rename: (root: string, path: string, newName: string) => + apiFetch<{ old_path: string; path: string }>("/api/files/rename", { + method: "POST", + body: JSON.stringify({ root, path, new_name: newName }), + }), + + /** Moves the entry to the system Recycle Bin / Trash. */ + delete: (root: string, path: string) => + apiFetch<{ path: string; deleted: boolean }>("/api/files/delete", { + method: "POST", + body: JSON.stringify({ root, path }), + }), + + /** Copies the entry into `destParent`; a clashing name becomes "a copy.md". */ + copy: (root: string, path: string, destParent: string) => + apiFetch<{ path: string }>("/api/files/copy", { + method: "POST", + body: JSON.stringify({ root, path, dest_parent: destParent }), + }), + + /** Moves the entry into `destParent`; refuses to overwrite an existing entry. */ + move: (root: string, path: string, destParent: string) => + apiFetch<{ old_path: string; path: string }>("/api/files/move", { + method: "POST", + body: JSON.stringify({ root, path, dest_parent: destParent }), + }), }; // ── Markdown API ────────────────────────────────────────────────────────────── diff --git a/frontend/src/lib/explorer-paths.mjs b/frontend/src/lib/explorer-paths.mjs new file mode 100644 index 00000000..e9f8a959 --- /dev/null +++ b/frontend/src/lib/explorer-paths.mjs @@ -0,0 +1,190 @@ +/** + * explorer-paths.mjs + * ================== + * Path and open-tab bookkeeping for the project explorer's context menu + * (issue #455): copy relative path, and keeping editor tabs pointed at the + * right files after a rename or delete. + * + * Kept as a dependency-free `.mjs` module so the Node test runner can import + * the exact implementation the app uses (same pattern as `tabLifecycle.mjs`). + * See `tests/explorer-paths.test.mjs`. + * + * Paths come from the backend's directory listing, so they use the platform's + * separator (`\` on Windows, `/` elsewhere). Both are accepted everywhere. + */ + +import { parentDirOf } from "./preview-base-dir.mjs"; + +/** @param {string} path */ +function separatorOf(path) { + return path.includes("\\") && !path.includes("/") ? "\\" : "/"; +} + +/** @param {string} path */ +function trimTrailingSeparators(path) { + return path.replace(/[/\\]+$/, ""); +} + +/** + * Join a folder and a single entry name using the folder's own separator. + * + * @param {string} dir + * @param {string} name + * @returns {string} + */ +export function joinPath(dir, name) { + return `${trimTrailingSeparators(dir)}${separatorOf(dir)}${name}`; +} + +/** + * The path a renamed entry ends up at: same folder, new name. + * + * @param {string} path + * @param {string} newName + * @returns {string} + */ +export function renamedPath(path, newName) { + const trimmed = trimTrailingSeparators(path); + const parent = trimmed.replace(/[^/\\]+$/, ""); + return `${parent}${newName}`; +} + +/** + * When `path` is `ancestor` or lies inside it, the part after `ancestor` + * (empty string for the entry itself, otherwise starting with a separator). + * `null` when `path` is elsewhere. + * + * @param {string | null | undefined} path + * @param {string} ancestor + * @returns {string | null} + */ +function suffixWithin(path, ancestor) { + if (!path) return null; + const base = trimTrailingSeparators(ancestor); + if (path === base) return ""; + if (path.startsWith(base) && /[/\\]/.test(path.charAt(base.length))) { + return path.slice(base.length); + } + return null; +} + +/** + * Whether `path` is `ancestor` itself or lies inside it. + * + * @param {string | null | undefined} path + * @param {string} ancestor + * @returns {boolean} + */ +export function isWithin(path, ancestor) { + return suffixWithin(path, ancestor) !== null; +} + +/** + * Where `path` ends up after `oldPath` is renamed or moved to `newPath`. + * Paths outside `oldPath` are returned unchanged. + * + * @param {string} path + * @param {string} oldPath + * @param {string} newPath + * @returns {string} + */ +export function remapPath(path, oldPath, newPath) { + const suffix = suffixWithin(path, oldPath); + return suffix === null ? path : trimTrailingSeparators(newPath) + suffix; +} + +/** + * Path of `path` relative to the project folder, with the platform's + * separator kept, e.g. `docs\guide.md`. Returns the path unchanged when it is + * not inside `root`. + * + * @param {string} root + * @param {string} path + * @returns {string} + */ +export function relativePath(root, path) { + const suffix = suffixWithin(path, root); + if (suffix === null) return path; + return suffix.replace(/^[/\\]+/, "") || "."; +} + +/** + * The subset of `Tab` (`useEditor.ts`) these helpers read. Tabs are passed + * through whole, so extra fields survive untouched. + * + * @typedef {object} ExplorerTab + * @property {string} id + * @property {string} label + * @property {string | null} filePath + * @property {string} [sourceFilePath] + * @property {string} [previewBaseDir] + * @property {boolean} dirty + */ + +/** + * Point every tab under a renamed or moved file or folder at its new location. + * + * Covers the renamed file itself, files inside a renamed folder, and + * converted documents (`sourceFilePath`). The preview base folder moves with + * the file so relative images keep resolving. Unaffected tabs are returned as + * the same objects. + * + * @template {ExplorerTab} T + * @param {T[]} tabs + * @param {string} oldPath + * @param {string} newPath + * @returns {T[]} + */ +export function remapTabsAfterRename(tabs, oldPath, newPath) { + const target = trimTrailingSeparators(newPath); + return tabs.map((tab) => { + const fileSuffix = suffixWithin(tab.filePath, oldPath); + const sourceSuffix = suffixWithin(tab.sourceFilePath, oldPath); + if (fileSuffix === null && sourceSuffix === null) return tab; + + const next = { ...tab }; + if (fileSuffix !== null) { + next.filePath = target + fileSuffix; + next.label = next.filePath.split(/[/\\]/).pop() ?? next.label; + next.previewBaseDir = parentDirOf(next.filePath); + } + if (sourceSuffix !== null) { + next.sourceFilePath = target + sourceSuffix; + next.previewBaseDir = parentDirOf(next.sourceFilePath); + } + return next; + }); +} + +/** + * Decide what happens to open tabs when a file or folder is deleted. + * + * Tabs with no unsaved changes are closed. Tabs with unsaved changes stay open + * so the edits are not lost, but are detached from the deleted path + * (`filePath: null`), so Save asks for a new location instead of silently + * recreating the deleted file. + * + * @template {ExplorerTab} T + * @param {T[]} tabs + * @param {string} deletedPath + * @returns {{ tabs: T[], closeIds: string[] }} `tabs` has the dirty tabs + * detached; `closeIds` are the tabs the caller should close. + */ +export function resolveTabsAfterDelete(tabs, deletedPath) { + /** @type {string[]} */ + const closeIds = []; + const next = tabs.map((tab) => { + const affected = + suffixWithin(tab.filePath, deletedPath) !== null || + suffixWithin(tab.sourceFilePath, deletedPath) !== null; + if (!affected) return tab; + if (!tab.dirty) { + closeIds.push(tab.id); + return tab; + } + const detached = { ...tab, filePath: null }; + delete detached.sourceFilePath; + return detached; + }); + return { tabs: next, closeIds }; +} diff --git a/frontend/tests/explorer-paths.test.mjs b/frontend/tests/explorer-paths.test.mjs new file mode 100644 index 00000000..1e95595e --- /dev/null +++ b/frontend/tests/explorer-paths.test.mjs @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// Imports the exact module `ProjectExplorer` and `useEditor` use (issue #455). +import { + isWithin, + joinPath, + remapPath, + relativePath, + remapTabsAfterRename, + renamedPath, + resolveTabsAfterDelete, +} from "../src/lib/explorer-paths.mjs"; + +function tab(id, filePath, extra = {}) { + return { id, label: filePath?.split(/[/\\]/).pop() ?? id, filePath, dirty: false, content: "", ...extra }; +} + +test("relativePath works with POSIX and Windows separators", () => { + assert.equal(relativePath("/home/me/proj", "/home/me/proj/docs/a.md"), "docs/a.md"); + assert.equal(relativePath("C:\\proj", "C:\\proj\\docs\\a.md"), "docs\\a.md"); + assert.equal(relativePath("C:\\proj\\", "C:\\proj\\a.md"), "a.md"); + assert.equal(relativePath("/proj", "/proj"), "."); +}); + +test("relativePath does not treat a sibling with a shared prefix as inside", () => { + assert.equal(relativePath("/proj", "/project-two/a.md"), "/project-two/a.md"); +}); + +test("joinPath and renamedPath keep the folder's separator", () => { + assert.equal(joinPath("C:\\proj\\docs", "new.md"), "C:\\proj\\docs\\new.md"); + assert.equal(joinPath("/proj/docs/", "new.md"), "/proj/docs/new.md"); + assert.equal(renamedPath("C:\\proj\\docs\\a.md", "b.md"), "C:\\proj\\docs\\b.md"); + assert.equal(renamedPath("/proj/docs", "guides"), "/proj/guides"); +}); + +test("renaming a file updates its tab's path, label and preview folder", () => { + const tabs = [tab("t1", "/proj/docs/a.md"), tab("t2", "/proj/other.md")]; + const [renamed, untouched] = remapTabsAfterRename(tabs, "/proj/docs/a.md", "/proj/docs/b.md"); + assert.equal(renamed.filePath, "/proj/docs/b.md"); + assert.equal(renamed.label, "b.md"); + assert.equal(renamed.previewBaseDir, "/proj/docs/"); + assert.equal(untouched, tabs[1], "unaffected tabs keep their identity"); +}); + +test("renaming a folder moves every tab inside it, including converted documents", () => { + const tabs = [ + tab("t1", "C:\\proj\\docs\\a.md"), + tab("t2", "C:\\proj\\docs\\deep\\b.md"), + tab("t3", null, { label: "c.md", sourceFilePath: "C:\\proj\\docs\\c.docx" }), + tab("t4", "C:\\proj\\docs-old\\d.md"), + ]; + const next = remapTabsAfterRename(tabs, "C:\\proj\\docs", "C:\\proj\\guides"); + assert.equal(next[0].filePath, "C:\\proj\\guides\\a.md"); + assert.equal(next[1].filePath, "C:\\proj\\guides\\deep\\b.md"); + assert.equal(next[2].filePath, null); + assert.equal(next[2].sourceFilePath, "C:\\proj\\guides\\c.docx"); + assert.equal(next[2].previewBaseDir, "C:\\proj\\guides\\"); + assert.equal(next[3], tabs[3], "a folder sharing the name prefix is not inside"); +}); + +test("deleting closes clean tabs and detaches tabs with unsaved changes", () => { + const tabs = [ + tab("clean", "/proj/docs/a.md"), + tab("dirty", "/proj/docs/b.md", { dirty: true }), + tab("elsewhere", "/proj/c.md"), + ]; + const { tabs: next, closeIds } = resolveTabsAfterDelete(tabs, "/proj/docs"); + assert.deepEqual(closeIds, ["clean"]); + const dirty = next.find((t) => t.id === "dirty"); + // Keeping the path would make Save silently recreate the deleted file. + assert.equal(dirty.filePath, null); + assert.equal(dirty.dirty, true); + assert.equal(next.find((t) => t.id === "elsewhere"), tabs[2]); +}); + +test("isWithin and remapPath follow an entry that was moved", () => { + assert.equal(isWithin("C:\\proj\\docs\\a.md", "C:\\proj\\docs"), true); + assert.equal(isWithin("C:\\proj\\docs", "C:\\proj\\docs"), true); + assert.equal(isWithin("C:\\proj\\docs-old", "C:\\proj\\docs"), false); + assert.equal(remapPath("/proj/docs/deep", "/proj/docs", "/proj/archive/docs"), "/proj/archive/docs/deep"); + assert.equal(remapPath("/proj/other", "/proj/docs", "/proj/archive/docs"), "/proj/other"); +}); + +test("moving a file into another folder updates its open tab", () => { + const tabs = [tab("t1", "/proj/docs/a.md", { dirty: true })]; + const [moved] = remapTabsAfterRename(tabs, "/proj/docs/a.md", "/proj/archive/a.md"); + assert.equal(moved.filePath, "/proj/archive/a.md"); + assert.equal(moved.previewBaseDir, "/proj/archive/"); + assert.equal(moved.dirty, true, "unsaved edits are kept"); +}); diff --git a/markdown-reader-backend.spec b/markdown-reader-backend.spec index 106da3ec..42cb35c8 100644 --- a/markdown-reader-backend.spec +++ b/markdown-reader-backend.spec @@ -17,6 +17,9 @@ a = Analysis( 'backend.routers.citations', 'bibtexparser', *collect_submodules('markitdown'), + # Imported lazily by the explorer's delete endpoint; the platform + # backend (win/mac/plat_other) is chosen at runtime. + *collect_submodules('send2trash'), ], hookspath=[], hooksconfig={}, diff --git a/pyproject.toml b/pyproject.toml index 89bd4662..82471afe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "ziamath>=0.12,<0.14", "latex2mathml>=3.77,<4", "beautifulsoup4>=4.12,<5", + "send2trash>=1.8", ] [project.optional-dependencies] diff --git a/tests/test_files_endpoints.py b/tests/test_files_endpoints.py index 1ff06268..ed0e99d3 100644 --- a/tests/test_files_endpoints.py +++ b/tests/test_files_endpoints.py @@ -111,3 +111,284 @@ def test_convert_local_html_strips_a_utf8_bom(): assert "Title" in converted finally: shutil.rmtree(td, ignore_errors=True) + + +# ── Project explorer: create / rename / delete ───────────────────────────── + + +def _project() -> str: + td = tempfile.mkdtemp() + os.makedirs(os.path.join(td, "docs")) + with open(os.path.join(td, "docs", "a.md"), "w", encoding="utf-8") as f: + f.write("# A\n") + return td + + +def test_create_file_and_folder_inside_project(): + td = _project() + try: + with TestClient(app) as client: + file_res = client.post( + "/api/files/create", + json={"root": td, "parent": td, "name": "new.md"}, + ) + dir_res = client.post( + "/api/files/create", + json={"root": td, "parent": td, "name": "sub", "is_dir": True}, + ) + assert file_res.status_code == 200, file_res.text + assert dir_res.status_code == 200, dir_res.text + assert os.path.isfile(os.path.join(td, "new.md")) + assert os.path.isdir(os.path.join(td, "sub")) + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_create_refuses_existing_entry(): + td = _project() + try: + with TestClient(app) as client: + response = client.post( + "/api/files/create", + json={"root": td, "parent": td, "name": "docs", "is_dir": True}, + ) + assert response.status_code == 409 + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_names_that_are_paths_are_rejected(): + # A name like "../escape.md" would otherwise create a file outside the + # folder the user right-clicked. + td = _project() + try: + with TestClient(app) as client: + for name in ("", " ", ".", "..", "../escape.md", "a/b.md", "a\b.md"): + response = client.post( + "/api/files/create", + json={"root": td, "parent": td, "name": name}, + ) + assert response.status_code == 400, name + assert not os.path.exists(os.path.join(os.path.dirname(td), "escape.md")) + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_rename_file_keeps_it_in_the_same_folder(): + td = _project() + old = os.path.join(td, "docs", "a.md") + try: + with TestClient(app) as client: + response = client.post( + "/api/files/rename", + json={"root": td, "path": old, "new_name": "b.md"}, + ) + assert response.status_code == 200, response.text + assert not os.path.exists(old) + assert os.path.isfile(os.path.join(td, "docs", "b.md")) + assert os.path.samefile( + response.json()["path"], os.path.join(td, "docs", "b.md") + ) + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_rename_refuses_to_overwrite_a_sibling(): + td = _project() + sibling = os.path.join(td, "docs", "b.md") + with open(sibling, "w", encoding="utf-8") as f: + f.write("keep me") + try: + with TestClient(app) as client: + response = client.post( + "/api/files/rename", + json={ + "root": td, + "path": os.path.join(td, "docs", "a.md"), + "new_name": "b.md", + }, + ) + assert response.status_code == 409 + with open(sibling, encoding="utf-8") as f: + assert f.read() == "keep me" + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_delete_moves_entry_to_trash(monkeypatch): + from backend.routers import files + + trashed: list[str] = [] + monkeypatch.setattr(files, "_move_to_trash", lambda path: trashed.append(str(path))) + td = _project() + target = os.path.join(td, "docs") + try: + with TestClient(app) as client: + response = client.post( + "/api/files/delete", json={"root": td, "path": target} + ) + assert response.status_code == 200, response.text + assert len(trashed) == 1 + assert os.path.samefile(trashed[0], target) + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_operations_outside_the_project_are_refused(monkeypatch): + from backend.routers import files + + monkeypatch.setattr(files, "_move_to_trash", lambda path: None) + td = _project() + outside = tempfile.mkdtemp() + outside_file = os.path.join(outside, "secret.md") + with open(outside_file, "w", encoding="utf-8") as f: + f.write("x") + try: + with TestClient(app) as client: + responses = [ + client.post( + "/api/files/delete", json={"root": td, "path": outside_file} + ), + client.post( + "/api/files/delete", + json={"root": td, "path": os.path.join(td, "..", "x")}, + ), + client.post( + "/api/files/rename", + json={"root": td, "path": outside_file, "new_name": "y.md"}, + ), + client.post( + "/api/files/create", + json={"root": td, "parent": outside, "name": "z.md"}, + ), + # The project folder itself cannot be deleted or renamed. + client.post("/api/files/delete", json={"root": td, "path": td}), + client.post( + "/api/files/rename", + json={"root": td, "path": td, "new_name": "renamed"}, + ), + ] + assert [r.status_code for r in responses] == [403] * len(responses) + assert os.path.isfile(outside_file) + assert not os.path.exists(os.path.join(outside, "z.md")) + finally: + shutil.rmtree(td, ignore_errors=True) + shutil.rmtree(outside, ignore_errors=True) + + +# ── Project explorer: cut / copy / paste ─────────────────────────────────── + + +def _transfer(client, action: str, root: str, path: str, dest: str): + return client.post( + f"/api/files/{action}", json={"root": root, "path": path, "dest_parent": dest} + ) + + +def test_copy_keeps_the_name_in_another_folder(): + td = _project() + try: + with TestClient(app) as client: + response = _transfer( + client, "copy", td, os.path.join(td, "docs", "a.md"), td + ) + assert response.status_code == 200, response.text + assert os.path.samefile(response.json()["path"], os.path.join(td, "a.md")) + assert os.path.isfile(os.path.join(td, "docs", "a.md")), "source is kept" + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_copy_into_the_same_folder_picks_a_copy_name(): + td = _project() + source = os.path.join(td, "docs", "a.md") + try: + with TestClient(app) as client: + first = _transfer(client, "copy", td, source, os.path.join(td, "docs")) + second = _transfer(client, "copy", td, source, os.path.join(td, "docs")) + folder = _transfer(client, "copy", td, os.path.join(td, "docs"), td) + names = [os.path.basename(r.json()["path"]) for r in (first, second, folder)] + assert names == ["a copy.md", "a copy 2.md", "docs copy"] + with open(os.path.join(td, "docs copy", "a.md"), encoding="utf-8") as f: + assert f.read() == "# A\n", "folders are copied with their contents" + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_move_puts_the_entry_in_the_destination(): + td = _project() + os.makedirs(os.path.join(td, "archive")) + try: + with TestClient(app) as client: + response = _transfer( + client, + "move", + td, + os.path.join(td, "docs"), + os.path.join(td, "archive"), + ) + assert response.status_code == 200, response.text + assert not os.path.exists(os.path.join(td, "docs")) + assert os.path.isfile(os.path.join(td, "archive", "docs", "a.md")) + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_move_refuses_to_overwrite_and_pasting_in_place_is_a_no_op(): + td = _project() + with open(os.path.join(td, "a.md"), "w", encoding="utf-8") as f: + f.write("keep me") + source = os.path.join(td, "docs", "a.md") + try: + with TestClient(app) as client: + clash = _transfer(client, "move", td, source, td) + in_place = _transfer(client, "move", td, source, os.path.join(td, "docs")) + assert clash.status_code == 409 + with open(os.path.join(td, "a.md"), encoding="utf-8") as f: + assert f.read() == "keep me" + assert in_place.status_code == 200, in_place.text + assert os.path.isfile(source) + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_a_folder_cannot_be_pasted_into_itself(): + td = _project() + docs = os.path.join(td, "docs") + os.makedirs(os.path.join(docs, "inner")) + try: + with TestClient(app) as client: + codes = [ + _transfer(client, action, td, docs, dest).status_code + for action in ("copy", "move") + for dest in (docs, os.path.join(docs, "inner")) + ] + assert codes == [400] * 4 + assert sorted(os.listdir(docs)) == ["a.md", "inner"] + finally: + shutil.rmtree(td, ignore_errors=True) + + +def test_copy_and_move_outside_the_project_are_refused(): + td = _project() + outside = tempfile.mkdtemp() + outside_file = os.path.join(outside, "secret.md") + with open(outside_file, "w", encoding="utf-8") as f: + f.write("x") + source = os.path.join(td, "docs", "a.md") + try: + with TestClient(app) as client: + codes = [ + _transfer(client, "copy", td, source, outside).status_code, + _transfer(client, "move", td, source, outside).status_code, + _transfer(client, "copy", td, outside_file, td).status_code, + _transfer(client, "move", td, outside_file, td).status_code, + # The project folder itself cannot be cut or copied. + _transfer(client, "move", td, td, os.path.join(td, "docs")).status_code, + ] + assert codes == [403] * 5 + assert os.listdir(outside) == ["secret.md"] + assert os.path.isfile(source) + finally: + shutil.rmtree(td, ignore_errors=True) + shutil.rmtree(outside, ignore_errors=True) diff --git a/uv.lock b/uv.lock index 3c293e20..9a0d024d 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,6 +1320,7 @@ dependencies = [ { name = "pypdf" }, { name = "python-docx" }, { name = "requests" }, + { name = "send2trash" }, { name = "uvicorn", extra = ["standard"] }, { name = "weasyprint" }, { name = "ziamath" }, @@ -1369,6 +1370,7 @@ requires-dist = [ { name = "python-docx", specifier = ">=1.1.0" }, { name = "requests", specifier = ">=2.28.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "send2trash", specifier = ">=1.8" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.29" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" }, { name = "weasyprint", specifier = ">=62.0" }, @@ -3150,6 +3152,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/95/12d226ee4d207cb1f77a216baa7e1a8bae2639733c140abe8d0316d23a18/semchunk-3.2.5-py3-none-any.whl", hash = "sha256:fd09cc5f380bd010b8ca773bd81893f7eaf11d37dd8362a83d46cedaf5dae076", size = 13048, upload-time = "2025-10-28T02:12:36.724Z" }, ] +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + [[package]] name = "setuptools" version = "81.0.0"