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"