diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b31917d..b980f74 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -135,6 +135,52 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } +// --- Clipboard file paths (for Cmd+V paste in file tree) --- + +#[tauri::command] +async fn read_clipboard_file_paths() -> Result, String> { + #[cfg(target_os = "macos")] + { + tokio::task::spawn_blocking(|| { + let script = concat!( + "set thePaths to \"\"\n", + "try\n", + "\tset theFiles to the clipboard as \u{00ab}class furl\u{00bb}\n", + "\tset thePaths to POSIX path of theFiles\n", + "on error\n", + "\ttry\n", + "\t\trepeat with f in (the clipboard as list)\n", + "\t\t\ttry\n", + "\t\t\t\tset thePaths to thePaths & POSIX path of (f as \u{00ab}class furl\u{00bb}) & linefeed\n", + "\t\t\tend try\n", + "\t\tend repeat\n", + "\tend try\n", + "end try\n", + "return thePaths", + ); + + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .output() + .map_err(|e| e.to_string())?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + Ok(vec![]) + } else { + Ok(stdout.lines().filter(|l| !l.is_empty()).map(|s| s.to_string()).collect()) + } + }) + .await + .map_err(|e| e.to_string())? + } + #[cfg(not(target_os = "macos"))] + { + Ok(vec![]) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Load .env file (walks up from cwd to find it) @@ -158,6 +204,7 @@ pub fn run() { create_new_window, detect_editors, open_in_editor, + read_clipboard_file_paths, latex::compile_latex, latex::synctex_edit, claude::check_claude_status, diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index 374c0b0..9e70a01 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -121,7 +121,7 @@ export function ProjectPicker() { Recent Projects -
+
{recentProjects.map((project) => (
(); + + // ─── Cmd+V paste files from OS clipboard ─── + useEffect(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.key !== "v") return; + + // Don't intercept paste in text inputs / editor (contentEditable) + const active = document.activeElement; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + (active as HTMLElement).isContentEditable) + ) + return; + + try { + const paths = await invoke("read_clipboard_file_paths"); + if (paths.length > 0) { + e.preventDefault(); + await importFiles(paths, pasteTargetFolder); + } + } catch (err) { + console.error("[paste] read clipboard failed:", err); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [importFiles, pasteTargetFolder]); + // dnd-kit drag-and-drop (uses PointerSensor — works in Tauri WKWebView) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -370,6 +403,7 @@ export function Sidebar() { }, [activeFileId]); const toggleFolder = useCallback((path: string) => { + setPasteTargetFolder(path); setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); @@ -579,7 +613,11 @@ export function Sidebar() { activeFileId={activeFileId} expandedFolders={expandedFolders} onToggleFolder={toggleFolder} - onSelectFile={setActiveFile} + onSelectFile={(id: string) => { + const parent = id.includes("/") ? id.substring(0, id.lastIndexOf("/")) : undefined; + setPasteTargetFolder(parent); + setActiveFile(id); + }} onNewFile={openNewFileDialog} onNewFolder={openNewFolderDialog} onImport={handleImport}