feat: implement clipboard file path reading and Cmd+V paste functionality in sidebar

This commit is contained in:
delibae 2026-03-05 17:33:48 +09:00
parent 6b705ee447
commit 10ac562d38
3 changed files with 87 additions and 2 deletions

View file

@ -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<Vec<String>, 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,

View file

@ -121,7 +121,7 @@ export function ProjectPicker() {
<ClockIcon className="size-4" />
<span>Recent Projects</span>
</div>
<div className="space-y-1">
<div className="max-h-64 space-y-1 overflow-y-auto">
{recentProjects.map((project) => (
<div
key={project.path}

View file

@ -298,6 +298,39 @@ export function Sidebar() {
};
}, [importFiles]);
// Track selected folder for paste target
const [pasteTargetFolder, setPasteTargetFolder] = useState<string | undefined>();
// ─── 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<string[]>("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}