diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index da94dd185..6ff83de3b 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1542,10 +1542,20 @@ pub async fn list_directory( tauri::async_runtime::spawn_blocking(move || { run_guarded("list_directory", || { let p = Path::new(&path); - if !p.exists() { - return Err(format!("Path does not exist: '{}'", path)); + let metadata = fs::symlink_metadata(p).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + format!("Path does not exist: '{}'", path) + } else { + format!("Failed to inspect path '{}': {}", path, error) + } + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Path is a symbolic link and cannot be listed: '{}'; choose its target directly", + path + )); } - if !p.is_dir() { + if !metadata.is_dir() { return Err(format!("Path is not a directory: '{}'", path)); } let nodes = build_tree(p, 0, max_depth, include_hidden)?; @@ -1566,31 +1576,52 @@ fn build_tree( return Ok(vec![]); } + let metadata = fs::symlink_metadata(dir) + .map_err(|e| format!("Failed to inspect directory '{}': {}", dir.display(), e))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Refusing to traverse symbolic link directory '{}'", + dir.display() + )); + } + if !metadata.is_dir() { + return Err(format!("Path is not a directory: '{}'", dir.display())); + } + let mut entries: Vec<_> = fs::read_dir(dir) .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))? - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry + .filter_map(|entry| { + let entry = entry.ok()?; + let visible = entry .file_name() .to_str() - .map(|n| entry_is_visible(n, include_hidden)) - .unwrap_or(false) + .map(|name| entry_is_visible(name, include_hidden)) + .unwrap_or(false); + if !visible { + return None; + } + + let metadata = fs::symlink_metadata(entry.path()).ok()?; + if metadata.file_type().is_symlink() { + return None; + } + if !metadata.is_dir() && !metadata.is_file() { + return None; + } + + Some((entry, metadata.is_dir())) }) .collect(); // Sort: directories first, then alphabetical within each group - entries.sort_by(|a, b| { - let a_is_dir = a.path().is_dir(); - let b_is_dir = b.path().is_dir(); - match (a_is_dir, b_is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), - } + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| match (a_is_dir, b_is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), }); let mut nodes = Vec::new(); - for entry in entries { + for (entry, is_dir) in entries { let entry_path = entry.path(); let name = entry.file_name().to_str().unwrap_or("").to_string(); // Always return forward-slash paths so the TS layer can compare @@ -1599,7 +1630,6 @@ fn build_tree( // prevents a whole class of bugs where TS-constructed `/` paths // fail to match Rust-returned `\` paths. let path_str = entry_path.to_string_lossy().replace('\\', "/"); - let is_dir = entry_path.is_dir(); let children = if is_dir { let kids = build_tree(&entry_path, depth + 1, max_depth, include_hidden)?; @@ -1983,6 +2013,39 @@ pub async fn file_exists(path: String) -> Result { .map_err(|e| format!("file_exists blocking task join error: {e}"))? } +/// Classify a dropped regular file or directory without following symlinks. +/// +/// Unlike `file_exists`, a metadata error is surfaced so callers can explain +/// why a dropped source could not be classified rather than silently treating +/// it as a file. +#[tauri::command] +pub async fn is_directory(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + run_guarded("is_directory", || { + let metadata = fs::symlink_metadata(&path) + .map_err(|e| format!("Failed to get metadata for '{}': {}", path, e))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Cannot import symbolic link '{}'; choose its target directly", + path + )); + } + if metadata.is_dir() { + return Ok(true); + } + if metadata.is_file() { + return Ok(false); + } + Err(format!( + "Path is neither a regular file nor a directory: '{}'; choose a regular file or directory", + path + )) + }) + }) + .await + .map_err(|e| format!("is_directory blocking task join error: {e}"))? +} + /// Get the last modified timestamp of a file in milliseconds since Unix epoch. /// Returns 0 if the file doesn't exist or metadata can't be read. #[tauri::command] @@ -2372,6 +2435,138 @@ mod tests { assert!(relative.unwrap_err().contains("requires an absolute path")); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn is_directory_distinguishes_file_directory_and_missing_path() { + let root = make_temp_dir("is-directory"); + let file = root.join("source.md"); + let missing = root.join("missing"); + fs::write(&file, "source").unwrap(); + + assert!(!is_directory(file.to_string_lossy().into_owned()) + .await + .unwrap()); + assert!(is_directory(root.to_string_lossy().into_owned()) + .await + .unwrap()); + + let error = is_directory(missing.to_string_lossy().into_owned()) + .await + .unwrap_err(); + assert!(error.contains("Failed to get metadata for"), "got: {error}"); + assert!( + error.contains(missing.to_string_lossy().as_ref()), + "got: {error}" + ); + + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn is_directory_rejects_symlinks_and_special_files() { + let root = make_temp_dir("is-directory-symlink"); + let file = root.join("source.md"); + let directory = root.join("directory"); + let file_link = root.join("file-link"); + let directory_link = root.join("directory-link"); + // Unix-domain socket paths are short (104 bytes on macOS), while the + // standard per-user temp directory can already approach that limit. + let socket = + std::path::Path::new("/tmp").join(format!("llmwiki-{}.sock", uuid::Uuid::new_v4())); + fs::write(&file, "source").unwrap(); + fs::create_dir(&directory).unwrap(); + std::os::unix::fs::symlink(&file, &file_link).unwrap(); + std::os::unix::fs::symlink(&directory, &directory_link).unwrap(); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + + for link in [&file_link, &directory_link] { + let error = is_directory(link.to_string_lossy().into_owned()) + .await + .unwrap_err(); + assert!(error.contains("symbolic link"), "got: {error}"); + assert!( + error.contains(link.to_string_lossy().as_ref()), + "got: {error}" + ); + } + + let error = is_directory(socket.to_string_lossy().into_owned()) + .await + .unwrap_err(); + assert!( + error.contains("neither a regular file nor a directory"), + "got: {error}" + ); + assert!( + error.contains(socket.to_string_lossy().as_ref()), + "got: {error}" + ); + + drop(listener); + let _ = fs::remove_file(socket); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn list_directory_rejects_symlink_root_and_omits_unsafe_nested_entries() { + // Keep the Unix-domain socket path comfortably below macOS's limit. + let root = + std::path::Path::new("/tmp").join(format!("llmwiki-tree-{}", uuid::Uuid::new_v4())); + let listed = root.join("listed"); + let nested = listed.join("nested"); + let external = root.join("external"); + let listed_link = root.join("listed-link"); + let socket = nested.join("special.md"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir(&external).unwrap(); + fs::write(nested.join("regular.md"), "regular").unwrap(); + fs::write(external.join("must-not-appear.md"), "external").unwrap(); + std::os::unix::fs::symlink(&external, nested.join("nested-link")).unwrap(); + std::os::unix::fs::symlink(&listed, &listed_link).unwrap(); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + + let nodes = list_directory(listed.to_string_lossy().into_owned(), Some(false), Some(30)) + .await + .unwrap(); + let nested_node = nodes.iter().find(|node| node.name == "nested").unwrap(); + let children = nested_node.children.as_ref().unwrap(); + assert!(children.iter().any(|node| node.name == "regular.md")); + assert!( + !children.iter().any(|node| node.name == "special.md"), + "special file entries must not be exposed: {children:?}" + ); + assert!( + !children.iter().any(|node| node.name == "nested-link"), + "symlink entries must not be exposed: {children:?}" + ); + assert!( + !children + .iter() + .any(|node| node.name == "must-not-appear.md"), + "symlink targets must not be traversed: {children:?}" + ); + + let error = list_directory( + listed_link.to_string_lossy().into_owned(), + Some(false), + Some(30), + ) + .await + .unwrap_err(); + assert!(error.contains("symbolic link"), "got: {error}"); + assert!( + error.contains(listed_link.to_string_lossy().as_ref()), + "got: {error}" + ); + + let error = build_tree(&listed_link, 0, 30, false).unwrap_err(); + assert!(error.contains("symbolic link"), "got: {error}"); + + drop(listener); + let _ = fs::remove_dir_all(root); + } + /// Ad-hoc probe: run the production PDF extraction path against every /// .pdf under a user-provided directory and print a per-file report of /// Ok / Err (library returned an error) / Panic (library panicked and diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72e059f93..b17891641 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -636,6 +636,7 @@ pub fn run() { commands::fs::find_related_wiki_pages, commands::fs::create_directory, commands::fs::file_exists, + commands::fs::is_directory, commands::fs::get_file_modified_time, commands::fs::get_file_size, commands::fs::get_file_md5, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a7bca30ad..4d2343a0c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,8 @@ "resizable": true, "fullscreen": false, "hiddenTitle": true, - "titleBarStyle": "Transparent" + "titleBarStyle": "Transparent", + "dragDropEnabled": true } ], "security": { diff --git a/src/commands/fs.test.ts b/src/commands/fs.test.ts index d11c3d79b..08e82adf9 100644 --- a/src/commands/fs.test.ts +++ b/src/commands/fs.test.ts @@ -8,7 +8,13 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke, })) -import { createDirectory, listDirectory, writeFile, writeFileAtomic } from "./fs" +import { + createDirectory, + isDirectory, + listDirectory, + writeFile, + writeFileAtomic, +} from "./fs" describe("fs command path guards", () => { beforeEach(() => { @@ -48,6 +54,16 @@ describe("fs command path guards", () => { }) }) + it("forwards directory classification to Tauri", async () => { + mocks.invoke.mockResolvedValue(true) + + await expect(isDirectory("/tmp/project/raw/sources")).resolves.toBe(true) + + expect(mocks.invoke).toHaveBeenCalledWith("is_directory", { + path: "/tmp/project/raw/sources", + }) + }) + it("deduplicates matching in-flight listDirectory requests only while pending", async () => { const tree = [{ name: "wiki", diff --git a/src/commands/fs.ts b/src/commands/fs.ts index f35c1c2f5..d39ef99b4 100644 --- a/src/commands/fs.ts +++ b/src/commands/fs.ts @@ -129,6 +129,10 @@ export async function fileExists(path: string): Promise { return invoke("file_exists", { path }) } +export async function isDirectory(path: string): Promise { + return invoke("is_directory", { path }) +} + export async function getFileModifiedTime(path: string): Promise { return invoke("get_file_modified_time", { path }) } diff --git a/src/components/sources/sources-view.tsx b/src/components/sources/sources-view.tsx index 135a5134d..642ee7aa4 100644 --- a/src/components/sources/sources-view.tsx +++ b/src/components/sources/sources-view.tsx @@ -1,12 +1,14 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { isTauri } from "@tauri-apps/api/core" import { open } from "@tauri-apps/plugin-dialog" +import { getCurrentWindow } from "@tauri-apps/api/window" import { Plus, FileText, RefreshCw, BookOpen, Trash2, Folder, ChevronRight, ChevronDown, Link, ExternalLink, Search, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { ScrollArea } from "@/components/ui/scroll-area" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { useWikiStore } from "@/stores/wiki-store" -import { listDirectory, openPathInProject, readFile } from "@/commands/fs" +import { isDirectory, listDirectory, openPathInProject, readFile } from "@/commands/fs" import type { FileNode } from "@/types/wiki" import { useTranslation } from "react-i18next" import { normalizePath } from "@/lib/path-utils" @@ -26,6 +28,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { importSourceUrls, parseImportUrls, type UrlImportResult } from "@/lib/url-source-import" import { listIngestedSourceIdentities } from "@/lib/ingest-cache" import { getQueue, type IngestTask } from "@/lib/ingest-queue" +import { importDroppedSourcePaths, subscribeToSourceDrops } from "@/lib/source-drop-import" const SOURCE_TREE_INITIAL_ROWS = 160 const SOURCE_TREE_LOAD_BATCH = 160 @@ -45,6 +48,9 @@ export function SourcesView() { const [ingestingPath, setIngestingPath] = useState(null) const [refreshing, setRefreshing] = useState(false) const [refreshError, setRefreshError] = useState(null) + const [dropError, setDropError] = useState(null) + const [dropBusyError, setDropBusyError] = useState(null) + const [dropListenerError, setDropListenerError] = useState(null) const [urlDialogOpen, setUrlDialogOpen] = useState(false) const [urlInput, setUrlInput] = useState("") const [urlError, setUrlError] = useState(null) @@ -64,6 +70,23 @@ export function SourcesView() { * anchored here is the right scope. */ const [pendingDeletePath, setPendingDeletePath] = useState(null) + const [isDraggingOver, setIsDraggingOver] = useState(false) + const importingRef = useRef(false) + const handleNativeDropRef = useRef<(paths: string[]) => void>(() => undefined) + + const tryStartImport = useCallback(() => { + if (importingRef.current) return false + importingRef.current = true + setDropBusyError(null) + setDropError(null) + setImporting(true) + return true + }, []) + + const finishImport = useCallback(() => { + importingRef.current = false + setImporting(false) + }, []) // Auto-disarm: 5 seconds without a second click resets the // pending state. Prevents a stale armed button from firing if @@ -82,6 +105,7 @@ export function SourcesView() { const tree = await listDirectory(`${pp}/raw/sources`, true) setSources(filterRawSourceTree(tree)) setRefreshError(null) + setDropError(null) } catch (err) { setRefreshError(String(err)) setSources([]) @@ -142,6 +166,56 @@ export function SourcesView() { const totalSourceCount = useMemo(() => countFiles(sources), [sources]) const filteredSourceCount = useMemo(() => countFiles(filteredSources), [filteredSources]) + const handleNativeDrop = useCallback(async (paths: string[]) => { + if (!project) return + if (!tryStartImport()) { + setDropBusyError(t("sources.dropImportBusy")) + return + } + + try { + const result = await importDroppedSourcePaths(paths, { + isDirectory, + importFiles: (filePaths) => + importSourceFiles(project, filePaths, llmConfig, sourceWatchConfig), + importFolder: (folderPath) => + importSourceFolder(project, folderPath, llmConfig, sourceWatchConfig), + }) + await loadSources() + + if (result.errors.length > 0) { + const error = result.errors.join("; ") + console.error("[sources] failed to import dropped items:", error) + setDropError(t("sources.dropImportFailed", { error })) + } else if (result.importedPaths.length === 0) { + setDropError(t("sources.dropImportEmpty")) + } + } catch (error) { + console.error("[sources] failed to import dropped items:", error) + setDropError(t("sources.dropImportFailed", { error: String(error) })) + } finally { + finishImport() + } + }, [finishImport, llmConfig, loadSources, project, sourceWatchConfig, t, tryStartImport]) + + handleNativeDropRef.current = (paths) => { + void handleNativeDrop(paths) + } + + useEffect(() => { + if (!isTauri()) return + + return subscribeToSourceDrops(getCurrentWindow(), { + isActive: () => useWikiStore.getState().activeView === "sources", + onDraggingChange: setIsDraggingOver, + onDrop: (paths) => handleNativeDropRef.current(paths), + onError: (error) => { + console.error("[sources] failed to listen for native drag-and-drop:", error) + setDropListenerError(t("sources.dropUnavailable", { error: String(error) })) + }, + }) + }, [t]) + async function handleRefreshSources() { if (!project || refreshing) return setRefreshing(true) @@ -158,98 +232,97 @@ export function SourcesView() { } async function handleImport() { - if (!project) return + if (!project || !tryStartImport()) return - const selected = await open({ - multiple: true, - title: t("sources.importSourceFiles"), - filters: [ - { - name: "Documents", - extensions: [ - "md", "mdx", "txt", "org", "rtf", "pdf", - "html", "htm", "xml", - "doc", "docx", "docm", "xls", "xlsx", "xlsm", "xlsb", - "ppt", "pps", "pot", "pptx", "pptm", "ppsx", "ppsm", - "odt", "ods", "odp", "epub", "mobi", "pages", "numbers", "key", - ], - }, - { - name: "Data", - extensions: ["json", "jsonl", "csv", "tsv", "yaml", "yml", "ndjson"], - }, - { - name: "Code", - extensions: [ - "py", "js", "ts", "jsx", "tsx", "rs", "go", "java", - "c", "cpp", "h", "rb", "php", "swift", "sql", "sh", - ], - }, - { - name: "Images", - extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tiff", "avif", "heic"], - }, - { - name: "Media", - extensions: ["mp4", "webm", "mov", "avi", "mkv", "mp3", "wav", "ogg", "flac", "m4a"], - }, - { name: "All Files", extensions: ["*"] }, - ], - }) + try { + const selected = await open({ + multiple: true, + title: t("sources.importSourceFiles"), + filters: [ + { + name: "Documents", + extensions: [ + "md", "mdx", "txt", "org", "rtf", "pdf", + "html", "htm", "xml", + "doc", "docx", "docm", "xls", "xlsx", "xlsm", "xlsb", + "ppt", "pps", "pot", "pptx", "pptm", "ppsx", "ppsm", + "odt", "ods", "odp", "epub", "mobi", "pages", "numbers", "key", + ], + }, + { + name: "Data", + extensions: ["json", "jsonl", "csv", "tsv", "yaml", "yml", "ndjson"], + }, + { + name: "Code", + extensions: [ + "py", "js", "ts", "jsx", "tsx", "rs", "go", "java", + "c", "cpp", "h", "rb", "php", "swift", "sql", "sh", + ], + }, + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tiff", "avif", "heic"], + }, + { + name: "Media", + extensions: ["mp4", "webm", "mov", "avi", "mkv", "mp3", "wav", "ogg", "flac", "m4a"], + }, + { name: "All Files", extensions: ["*"] }, + ], + }) - if (!selected || selected.length === 0) return + if (!selected || selected.length === 0) return - setImporting(true) - const paths = Array.isArray(selected) ? selected : [selected] - try { + const paths = Array.isArray(selected) ? selected : [selected] await importSourceFiles(project, paths, llmConfig, sourceWatchConfig) await loadSources() } finally { - setImporting(false) + finishImport() } } async function handleImportFolder() { - if (!project) return + if (!project || !tryStartImport()) return - const selected = await open({ - directory: true, - title: t("sources.importSourceFolder"), - }) + try { + const selected = await open({ + directory: true, + title: t("sources.importSourceFolder"), + }) - if (!selected || typeof selected !== "string") return + if (!selected || typeof selected !== "string") return - setImporting(true) - try { await importSourceFolder(project, selected, llmConfig, sourceWatchConfig) await loadSources() } catch (err) { console.error(`Failed to import folder:`, err) } finally { - setImporting(false) + finishImport() } } async function handleImportUrls() { - if (!project || importing) return - let urls: string[] - try { - urls = parseImportUrls(urlInput) - if (urls.length === 0) throw new Error(t("sources.urlImport.empty")) - } catch (error) { - setUrlError(error instanceof Error ? error.message : String(error)) - return - } - setImporting(true) - setUrlError(null) - setUrlResults([]) + if (!project || !tryStartImport()) return + try { + let urls: string[] + try { + urls = parseImportUrls(urlInput) + if (urls.length === 0) throw new Error(t("sources.urlImport.empty")) + } catch (error) { + setUrlError(error instanceof Error ? error.message : String(error)) + return + } + + setUrlError(null) + setUrlResults([]) const results = await importSourceUrls(project, urls, llmConfig, sourceWatchConfig) setUrlResults(results) await loadSources() if (results.every((result) => result.path && !result.error)) setUrlInput("") } finally { - setImporting(false) + finishImport() } } @@ -358,6 +431,8 @@ export function SourcesView() { } } + const visibleDropError = dropListenerError ?? dropBusyError ?? dropError + return (
@@ -463,7 +538,11 @@ export function SourcesView() {
)} - + {refreshError && (
{t("sources.refreshFailed", { @@ -472,16 +551,27 @@ export function SourcesView() { })}
)} + {visibleDropError && ( +
+ {visibleDropError} +
+ )} {sources.length === 0 ? (

{t("sources.noSources")}

{t("sources.importHint")}

+

+ {isDraggingOver + ? t("sources.dragDropHint") + : t("sources.dragAndDropHint", "or drag and drop files/folders here") + } +

- - diff --git a/src/i18n/en.json b/src/i18n/en.json index 6e2a5de46..8696210a5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -186,6 +186,12 @@ "refreshFolderHint": "Rescan the source folder and process new, changed, or deleted files.", "refreshFolderTooltip": "Rescan the project source folder with the same rules as automatic monitoring. New or modified allowed files are queued for ingest; deleted sources trigger the normal wiki and index cleanup; then the file tree is rebuilt.", "refreshFailed": "Failed to refresh sources: {{error}}", + "dragDropHint": "Drop files or folders here to import", + "dragAndDropHint": "or drag and drop files/folders here", + "dropImportFailed": "Could not import dropped items: {{error}}", + "dropImportEmpty": "No supported files were imported from the dropped items.", + "dropImportBusy": "Those items were not imported because another import was in progress. Please try again.", + "dropUnavailable": "Drag-and-drop is unavailable: {{error}}. You can still use the toolbar to import sources.", "openExternal": "Open in default app", "openExternalFailed": "Failed to open {{name}}: {{error}}", "ingest": "Ingest", diff --git a/src/i18n/it.json b/src/i18n/it.json index 113dedbd2..65d870fde 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -186,6 +186,12 @@ "refreshFolderHint": "Riesamina la cartella delle fonti ed elabora i file nuovi, modificati o eliminati.", "refreshFolderTooltip": "Riesamina la cartella delle fonti del progetto con le stesse regole del monitoraggio automatico. I file consentiti nuovi o modificati vengono messi in coda per l'elaborazione; le fonti eliminate avviano la normale pulizia di wiki e indice; infine l'albero dei file viene ricostruito.", "refreshFailed": "Impossibile aggiornare le fonti: {{error}}", + "dragDropHint": "Rilascia qui file o cartelle per importarli", + "dragAndDropHint": "oppure trascina qui file o cartelle", + "dropImportFailed": "Impossibile importare gli elementi trascinati: {{error}}", + "dropImportEmpty": "Nessun file supportato è stato importato dagli elementi trascinati.", + "dropImportBusy": "Questi elementi non sono stati importati perché era già in corso un'altra importazione. Riprova.", + "dropUnavailable": "Il trascinamento non è disponibile: {{error}}. Puoi comunque importare le fonti dalla barra degli strumenti.", "openExternal": "Apri nell'app predefinita", "openExternalFailed": "Impossibile aprire {{name}}: {{error}}", "ingest": "Elabora", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 5d908c04a..15a2d21a1 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -186,6 +186,12 @@ "refreshFolderHint": "Заново просканировать папку источников и обработать новые, изменённые или удалённые файлы.", "refreshFolderTooltip": "Заново просканировать папку источников проекта по тем же правилам, что и автоматический мониторинг. Новые или изменённые разрешённые файлы ставятся в очередь обработки; удалённые источники запускают обычную очистку вики и индекса; затем дерево файлов перестраивается.", "refreshFailed": "Не удалось обновить источники: {{error}}", + "dragDropHint": "Перетащите сюда файлы или папки для импорта", + "dragAndDropHint": "или перетащите сюда файлы или папки", + "dropImportFailed": "Не удалось импортировать перетащенные элементы: {{error}}", + "dropImportEmpty": "Из перетащенных элементов не импортировано ни одного поддерживаемого файла.", + "dropImportBusy": "Эти элементы не были импортированы, поскольку уже выполнялся другой импорт. Повторите попытку.", + "dropUnavailable": "Перетаскивание недоступно: {{error}}. Источники всё ещё можно импортировать с панели инструментов.", "openExternal": "Открыть в приложении по умолчанию", "openExternalFailed": "Не удалось открыть «{{name}}»: {{error}}", "ingest": "Обработать", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 7ba8a7993..39668f838 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -186,6 +186,12 @@ "refreshFolderHint": "重新扫描资料文件夹,并处理新增、修改或删除的文件。", "refreshFolderTooltip": "按自动监控相同规则重新扫描当前项目的资料文件夹。允许的新增或修改文件会加入提取队列;已删除的资料会走正常的 Wiki 与索引清理流程;随后重建文件树。", "refreshFailed": "刷新原始资料失败:{{error}}", + "dragDropHint": "拖放文件或文件夹到此处导入", + "dragAndDropHint": "或拖放文件/文件夹到此处", + "dropImportFailed": "拖放的资料导入失败:{{error}}", + "dropImportEmpty": "未从拖放项目中导入任何受支持的文件。", + "dropImportBusy": "本次拖放未导入,因为当时已有导入任务正在进行。请重试。", + "dropUnavailable": "拖放导入不可用:{{error}}。仍可使用顶部工具栏导入资料。", "openExternal": "用默认应用打开", "openExternalFailed": "无法打开「{{name}}」:{{error}}", "ingest": "提取到 Wiki", diff --git a/src/lib/source-drop-import.test.ts b/src/lib/source-drop-import.test.ts new file mode 100644 index 000000000..d8fada419 --- /dev/null +++ b/src/lib/source-drop-import.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest" +import type { EventCallback, UnlistenFn } from "@tauri-apps/api/event" +import type { DragDropEvent } from "@tauri-apps/api/webview" +import { + importDroppedSourcePaths, + subscribeToSourceDrops, +} from "./source-drop-import" + +function nativeEvent(payload: DragDropEvent): Parameters>[0] { + return { + event: `tauri://drag-${payload.type}`, + id: 1, + payload, + } +} + +describe("subscribeToSourceDrops", () => { + it("translates active native events into hover state and absolute drop paths", async () => { + let listener: EventCallback | undefined + const stop = vi.fn() + const onDraggingChange = vi.fn() + const onDrop = vi.fn() + const onError = vi.fn() + const source = { + onDragDropEvent: vi.fn((handler: EventCallback) => { + listener = handler + return Promise.resolve(stop) + }), + } + + const dispose = subscribeToSourceDrops(source, { + isActive: () => true, + onDraggingChange, + onDrop, + onError, + }) + + listener?.(nativeEvent({ + type: "enter", + paths: ["/tmp/native-drag-probe.md"], + position: { x: 10, y: 20 }, + } as DragDropEvent)) + listener?.(nativeEvent({ + type: "drop", + paths: ["/tmp/native-drag-probe.md"], + position: { x: 10, y: 20 }, + } as DragDropEvent)) + + expect(onDraggingChange).toHaveBeenNthCalledWith(1, true) + expect(onDraggingChange).toHaveBeenNthCalledWith(2, false) + expect(onDrop).toHaveBeenCalledWith(["/tmp/native-drag-probe.md"]) + expect(onError).not.toHaveBeenCalled() + + await Promise.resolve() + dispose() + expect(stop).toHaveBeenCalledOnce() + }) + + it("ignores native drag events while Sources is inactive", () => { + let listener: EventCallback | undefined + const onDraggingChange = vi.fn() + const onDrop = vi.fn() + const source = { + onDragDropEvent: vi.fn((handler: EventCallback) => { + listener = handler + return new Promise(() => undefined) + }), + } + + subscribeToSourceDrops(source, { + isActive: () => false, + onDraggingChange, + onDrop, + onError: vi.fn(), + }) + listener?.(nativeEvent({ + type: "drop", + paths: ["/tmp/ignored.md"], + position: { x: 10, y: 20 }, + } as DragDropEvent)) + + expect(onDraggingChange).not.toHaveBeenCalled() + expect(onDrop).not.toHaveBeenCalled() + }) + + it("clears hover without dropping paths when an active native drag leaves", () => { + let listener: EventCallback | undefined + const onDraggingChange = vi.fn() + const onDrop = vi.fn() + const source = { + onDragDropEvent: vi.fn((handler: EventCallback) => { + listener = handler + return new Promise(() => undefined) + }), + } + + subscribeToSourceDrops(source, { + isActive: () => true, + onDraggingChange, + onDrop, + onError: vi.fn(), + }) + listener?.(nativeEvent({ type: "leave" } as DragDropEvent)) + + expect(onDraggingChange).toHaveBeenCalledOnce() + expect(onDraggingChange).toHaveBeenCalledWith(false) + expect(onDrop).not.toHaveBeenCalled() + }) + + it("unsubscribes if the component is disposed before registration finishes", async () => { + let listener: EventCallback | undefined + let resolveRegistration: ((stop: UnlistenFn) => void) | undefined + const stop = vi.fn() + const onDraggingChange = vi.fn() + const onDrop = vi.fn() + const source = { + onDragDropEvent: vi.fn((handler: EventCallback) => new Promise((resolve) => { + listener = handler + resolveRegistration = resolve + })), + } + + const dispose = subscribeToSourceDrops(source, { + isActive: () => true, + onDraggingChange, + onDrop, + onError: vi.fn(), + }) + dispose() + listener?.(nativeEvent({ + type: "drop", + paths: ["/tmp/ignored-after-dispose.md"], + position: { x: 10, y: 20 }, + } as DragDropEvent)) + resolveRegistration?.(stop) + await Promise.resolve() + + expect(onDraggingChange).not.toHaveBeenCalled() + expect(onDrop).not.toHaveBeenCalled() + expect(stop).toHaveBeenCalledOnce() + }) + + it("reports a rejected native drag registration", async () => { + const registrationError = new Error("registration failed") + const onError = vi.fn() + const source = { + onDragDropEvent: vi.fn().mockRejectedValue(registrationError), + } + + subscribeToSourceDrops(source, { + isActive: () => true, + onDraggingChange: vi.fn(), + onDrop: vi.fn(), + onError, + }) + + await vi.waitFor(() => { + expect(onError).toHaveBeenCalledOnce() + }) + expect(onError).toHaveBeenCalledWith(registrationError) + }) + + it("ignores a registration failure after the subscription is disposed", async () => { + let rejectRegistration: ((error: unknown) => void) | undefined + const onError = vi.fn() + const source = { + onDragDropEvent: vi.fn(() => new Promise((_resolve, reject) => { + rejectRegistration = reject + })), + } + + const dispose = subscribeToSourceDrops(source, { + isActive: () => true, + onDraggingChange: vi.fn(), + onDrop: vi.fn(), + onError, + }) + + dispose() + rejectRegistration?.(new Error("late registration failure")) + await Promise.resolve() + + expect(onError).not.toHaveBeenCalled() + }) +}) + +describe("importDroppedSourcePaths", () => { + it("passes a native drop's absolute file path to the file importer", async () => { + const importFiles = vi.fn().mockResolvedValue([ + "/project/raw/sources/native-drag-probe.md", + ]) + const importFolder = vi.fn() + + const result = await importDroppedSourcePaths( + ["/tmp/native-drag-probe.md"], + { + isDirectory: vi.fn().mockResolvedValue(false), + importFiles, + importFolder, + }, + ) + + expect(importFiles).toHaveBeenCalledWith(["/tmp/native-drag-probe.md"]) + expect(importFiles).not.toHaveBeenCalledWith(["native-drag-probe.md"]) + expect(importFolder).not.toHaveBeenCalled() + expect(result).toEqual({ + importedPaths: ["/project/raw/sources/native-drag-probe.md"], + errors: [], + }) + }) + + it("routes a mixed native drop to the existing file and folder importers", async () => { + const importFiles = vi.fn().mockResolvedValue(["/project/raw/sources/notes.md"]) + const importFolder = vi.fn().mockResolvedValue([ + "/project/raw/sources/reference/chapter.md", + ]) + + const result = await importDroppedSourcePaths( + ["/tmp/notes.md", "/tmp/reference"], + { + isDirectory: vi.fn((path: string) => Promise.resolve(path.endsWith("reference"))), + importFiles, + importFolder, + }, + ) + + expect(importFiles).toHaveBeenCalledWith(["/tmp/notes.md"]) + expect(importFolder).toHaveBeenCalledWith("/tmp/reference") + expect(result).toEqual({ + importedPaths: [ + "/project/raw/sources/notes.md", + "/project/raw/sources/reference/chapter.md", + ], + errors: [], + }) + }) + + it("reports a rejected file import while still importing a valid folder", async () => { + const importFiles = vi.fn().mockRejectedValue(new Error("file import failed")) + const importFolder = vi.fn().mockResolvedValue([ + "/project/raw/sources/reference/chapter.md", + ]) + + const result = await importDroppedSourcePaths( + ["/tmp/notes.md", "/tmp/reference"], + { + isDirectory: vi.fn((path: string) => Promise.resolve(path.endsWith("reference"))), + importFiles, + importFolder, + }, + ) + + expect(importFiles).toHaveBeenCalledWith(["/tmp/notes.md"]) + expect(importFolder).toHaveBeenCalledWith("/tmp/reference") + expect(result).toEqual({ + importedPaths: ["/project/raw/sources/reference/chapter.md"], + errors: ["file import failed"], + }) + }) + + it("reports a failed folder with its path and continues with later folders", async () => { + const importFolder = vi.fn() + .mockRejectedValueOnce(new Error("folder import failed")) + .mockResolvedValueOnce(["/project/raw/sources/later/chapter.md"]) + + const result = await importDroppedSourcePaths( + ["/tmp/broken", "/tmp/later"], + { + isDirectory: vi.fn().mockResolvedValue(true), + importFiles: vi.fn(), + importFolder, + }, + ) + + expect(importFolder).toHaveBeenNthCalledWith(1, "/tmp/broken") + expect(importFolder).toHaveBeenNthCalledWith(2, "/tmp/later") + expect(result).toEqual({ + importedPaths: ["/project/raw/sources/later/chapter.md"], + errors: ["/tmp/broken: folder import failed"], + }) + }) + + it("reports an item that cannot be classified while still importing valid paths", async () => { + const importFiles = vi.fn().mockResolvedValue(["/project/raw/sources/notes.md"]) + + const result = await importDroppedSourcePaths( + ["/tmp/notes.md", "/tmp/missing.md"], + { + isDirectory: vi.fn((path: string) => { + if (path.endsWith("missing.md")) return Promise.reject(new Error("not found")) + return Promise.resolve(false) + }), + importFiles, + importFolder: vi.fn(), + }, + ) + + expect(importFiles).toHaveBeenCalledWith(["/tmp/notes.md"]) + expect(result).toEqual({ + importedPaths: ["/project/raw/sources/notes.md"], + errors: ["/tmp/missing.md: not found"], + }) + }) +}) diff --git a/src/lib/source-drop-import.ts b/src/lib/source-drop-import.ts new file mode 100644 index 000000000..010b8c0b3 --- /dev/null +++ b/src/lib/source-drop-import.ts @@ -0,0 +1,120 @@ +import type { EventCallback, UnlistenFn } from "@tauri-apps/api/event" +import type { DragDropEvent } from "@tauri-apps/api/webview" + +export interface NativeDragDropEventSource { + onDragDropEvent: (handler: EventCallback) => Promise +} + +export interface SourceDropSubscriptionHandlers { + isActive: () => boolean + onDraggingChange: (dragging: boolean) => void + onDrop: (paths: string[]) => void + onError: (error: unknown) => void +} + +/** + * Subscribe to Tauri's native drag-and-drop boundary and translate its four + * event variants into the state changes needed by the Sources view. + */ +export function subscribeToSourceDrops( + source: NativeDragDropEventSource, + handlers: SourceDropSubscriptionHandlers, +): UnlistenFn { + let disposed = false + let unlisten: UnlistenFn | undefined + + void source.onDragDropEvent((event) => { + if (disposed || !handlers.isActive()) return + + switch (event.payload.type) { + case "enter": + case "over": + handlers.onDraggingChange(true) + break + case "leave": + handlers.onDraggingChange(false) + break + case "drop": + handlers.onDraggingChange(false) + handlers.onDrop(event.payload.paths) + break + } + }).then((stop) => { + if (disposed) { + stop() + } else { + unlisten = stop + } + }).catch((error) => { + if (!disposed) handlers.onError(error) + }) + + return () => { + if (disposed) return + disposed = true + unlisten?.() + } +} + +export interface DroppedSourcePathImporters { + isDirectory: (path: string) => Promise + importFiles: (paths: string[]) => Promise + importFolder: (path: string) => Promise +} + +export interface DroppedSourceImportResult { + importedPaths: string[] + errors: string[] +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Classify native Tauri drop paths, then send them through the same import + * functions used by the Sources toolbar. Native drop events provide absolute + * paths; keep them intact so filesystem commands can resolve the dropped item. + */ +export async function importDroppedSourcePaths( + paths: string[], + importers: DroppedSourcePathImporters, +): Promise { + const filePaths: string[] = [] + const folderPaths: string[] = [] + const errors: string[] = [] + + const classifications = await Promise.allSettled( + paths.map((path) => importers.isDirectory(path)), + ) + + for (const [index, classification] of classifications.entries()) { + const path = paths[index] + if (classification.status === "rejected") { + errors.push(`${path}: ${errorMessage(classification.reason)}`) + } else if (classification.value) { + folderPaths.push(path) + } else { + filePaths.push(path) + } + } + + const importedPaths: string[] = [] + if (filePaths.length > 0) { + try { + importedPaths.push(...await importers.importFiles(filePaths)) + } catch (error) { + errors.push(errorMessage(error)) + } + } + + for (const path of folderPaths) { + try { + importedPaths.push(...await importers.importFolder(path)) + } catch (error) { + errors.push(`${path}: ${errorMessage(error)}`) + } + } + + return { importedPaths, errors } +}