diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index baffa35..7fc7a2e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -712,34 +712,13 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -750,7 +729,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -1919,7 +1898,7 @@ dependencies = [ name = "leaf" version = "0.1.0" dependencies = [ - "dirs 5.0.1", + "dirs", "serde", "serde_json", "tauri", @@ -2870,17 +2849,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -3561,7 +3529,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3612,7 +3580,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs", "glob", "heck 0.5.0", "json-patch", @@ -4181,7 +4149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs", "libappindicator", "muda", "objc2", @@ -4901,15 +4869,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -4952,21 +4911,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -5024,12 +4968,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5048,12 +4986,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5072,12 +5004,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5108,12 +5034,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5132,12 +5052,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5156,12 +5070,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5180,12 +5088,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5330,7 +5232,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs", "dpi", "dunce", "gdkx11", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6dfab0d..c59b145 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" url = "2" tauri-plugin-drag = "2.1.0" -dirs = "5" +dirs = "6" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands/ai_files.rs b/src-tauri/src/commands/ai_files.rs index 20a036b..8fd349b 100644 --- a/src-tauri/src/commands/ai_files.rs +++ b/src-tauri/src/commands/ai_files.rs @@ -132,9 +132,7 @@ fn walk_for_instruction_files(dir: &Path, results: &mut Vec) -> Result<( continue; } walk_for_instruction_files(&path, results)?; - } else if metadata.is_file() - && (file_name == "CLAUDE.md" || file_name == "AGENTS.md") - { + } else if metadata.is_file() && (file_name == "CLAUDE.md" || file_name == "AGENTS.md") { results.push(path.to_string_lossy().to_string()); } } @@ -236,8 +234,12 @@ mod tests { .collect(); assert_eq!(project_files.len(), 3); - assert!(project_files.iter().any(|p| p.ends_with("CLAUDE.md") && !p.contains("src"))); + assert!(project_files + .iter() + .any(|p| p.ends_with("CLAUDE.md") && !p.contains("src"))); assert!(project_files.iter().any(|p| p.ends_with("AGENTS.md"))); - assert!(project_files.iter().any(|p| p.contains("src") && p.ends_with("CLAUDE.md"))); + assert!(project_files + .iter() + .any(|p| p.contains("src") && p.ends_with("CLAUDE.md"))); } } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index f4bc476..f0d8ac8 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,7 +1,7 @@ use serde::Serialize; use std::fs; use std::io::Write as IoWrite; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::SystemTime; /// Tauri command: Reads a file's contents as a UTF-8 string. @@ -486,6 +486,56 @@ pub fn write_image_to_assets( Ok(format!("assets/{}", target_name)) } +/// Result of resolving a file path. +#[derive(Debug, Clone, Serialize)] +pub struct ResolveResult { + pub canonical_path: String, + pub exists: bool, + pub is_file: bool, +} + +#[tauri::command] +pub fn resolve_file_path(path: String, base_dir: Option) -> Result { + if path.trim().is_empty() { + return Err("Path cannot be empty".to_string()); + } + + let expanded = if path.starts_with('~') { + let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?; + if path == "~" { + home + } else { + home.join(&path[2..]) + } + } else { + let p = PathBuf::from(&path); + if p.is_absolute() { + p + } else { + let base = base_dir + .map(PathBuf::from) + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from("/")); + base.join(&path) + } + }; + + let exists = expanded.exists(); + let is_file = expanded.is_file(); + + let canonical = if exists { + expanded.canonicalize().unwrap_or(expanded) + } else { + expanded + }; + + Ok(ResolveResult { + canonical_path: canonical.to_string_lossy().to_string(), + exists, + is_file, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -522,7 +572,7 @@ mod tests { .write_all(b"png") .unwrap(); - let result = read_directory_tree(root.to_string_lossy().to_string()).unwrap(); + let result = read_directory_tree(root.to_string_lossy().to_string(), None).unwrap(); assert!(result.is_dir); let children = result.children.unwrap(); @@ -542,7 +592,7 @@ mod tests { #[test] fn test_read_directory_tree_nonexistent() { - let result = read_directory_tree("/nonexistent/path/12345".to_string()); + let result = read_directory_tree("/nonexistent/path/12345".to_string(), None); assert!(result.is_err()); assert!(result.unwrap_err().contains("does not exist")); } @@ -553,7 +603,7 @@ mod tests { let file_path = dir.path().join("test.md"); fs::File::create(&file_path).unwrap(); - let result = read_directory_tree(file_path.to_string_lossy().to_string()); + let result = read_directory_tree(file_path.to_string_lossy().to_string(), None); assert!(result.is_err()); assert!(result.unwrap_err().contains("not a directory")); } @@ -585,7 +635,7 @@ mod tests { fs::File::create(root.join("apple.md")).unwrap(); fs::create_dir(root.join("beta")).unwrap(); - let result = read_directory_tree(root.to_string_lossy().to_string()).unwrap(); + let result = read_directory_tree(root.to_string_lossy().to_string(), None).unwrap(); let children = result.children.unwrap(); // Dirs first (alpha, beta), then files (apple.md, zebra.md) @@ -804,4 +854,65 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("not a file")); } + + // --- resolve_file_path tests --- + + #[test] + fn test_resolve_file_path_absolute() { + let dir = tempdir().unwrap(); + let file = dir.path().join("test.md"); + fs::File::create(&file).unwrap(); + + let result = resolve_file_path(file.to_string_lossy().to_string(), None).unwrap(); + assert!(result.exists); + assert!(result.is_file); + assert_eq!( + result.canonical_path, + file.canonicalize().unwrap().to_string_lossy().to_string() + ); + } + + #[test] + fn test_resolve_file_path_nonexistent() { + let result = resolve_file_path("/nonexistent/file.md".to_string(), None).unwrap(); + assert!(!result.exists); + assert!(!result.is_file); + } + + #[test] + fn test_resolve_file_path_directory() { + let dir = tempdir().unwrap(); + let result = resolve_file_path(dir.path().to_string_lossy().to_string(), None).unwrap(); + assert!(result.exists); + assert!(!result.is_file); + } + + #[test] + fn test_resolve_file_path_empty() { + let result = resolve_file_path("".to_string(), None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("empty")); + } + + #[test] + fn test_resolve_file_path_tilde() { + let result = resolve_file_path("~".to_string(), None).unwrap(); + assert!(result.exists); + assert!(!result.is_file); + } + + #[test] + fn test_resolve_file_path_relative_with_base() { + let dir = tempdir().unwrap(); + let file = dir.path().join("doc.md"); + fs::File::create(&file).unwrap(); + + let result = resolve_file_path( + "doc.md".to_string(), + Some(dir.path().to_string_lossy().to_string()), + ) + .unwrap(); + assert!(result.exists); + assert!(result.is_file); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 37fc894..5c0d5a4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,12 @@ mod commands; +use commands::ai_files::{find_claude_project_dir, find_instruction_files, list_files_with_mtime}; use commands::export::{ check_pandoc, export_document, export_save_dialog, get_export_config, get_export_formats, }; -use commands::ai_files::{find_claude_project_dir, find_instruction_files, list_files_with_mtime}; use commands::fs::{ copy_image_to_assets, get_file_modified_time, read_directory_shallow, read_directory_tree, - read_file, write_file, write_image_to_assets, + read_file, resolve_file_path, write_file, write_image_to_assets, }; use commands::session::{load_session_state, save_session_state}; use std::path::PathBuf; @@ -183,6 +183,7 @@ pub fn run() { load_session_state, copy_image_to_assets, write_image_to_assets, + resolve_file_path, check_pandoc, get_export_formats, get_export_config, @@ -202,6 +203,9 @@ pub fn run() { let open_folder = MenuItemBuilder::with_id("open_folder", "Open Folder...") .accelerator("CmdOrCtrl+Shift+O") .build(app)?; + let open_by_path = MenuItemBuilder::with_id("open_by_path", "Open by Path...") + .accelerator("CmdOrCtrl+Shift+G") + .build(app)?; let save_file = MenuItemBuilder::with_id("save_file", "Save") .accelerator("CmdOrCtrl+S") .build(app)?; @@ -214,10 +218,9 @@ pub fn run() { let export_html = MenuItemBuilder::with_id("export_html", "Export as HTML...") .accelerator("CmdOrCtrl+Shift+H") .build(app)?; - let copy_rich_text = - MenuItemBuilder::with_id("copy_rich_text", "Copy as Rich Text") - .accelerator("CmdOrCtrl+Shift+C") - .build(app)?; + let copy_rich_text = MenuItemBuilder::with_id("copy_rich_text", "Copy as Rich Text") + .accelerator("CmdOrCtrl+Shift+C") + .build(app)?; let print_pdf = MenuItemBuilder::with_id("print_pdf", "Print / Export PDF...") .accelerator("CmdOrCtrl+P") .build(app)?; @@ -261,6 +264,7 @@ pub fn run() { .item(&new_file) .item(&open_file) .item(&open_folder) + .item(&open_by_path) .item(&open_recent_menu) .separator() .item(&close_tab) @@ -283,10 +287,9 @@ pub fn run() { let theme_dark = CheckMenuItemBuilder::with_id("theme-dark", "Dark") .checked(false) .build(app)?; - let theme_system = - CheckMenuItemBuilder::with_id("theme-system", "System (Auto)") - .checked(false) - .build(app)?; + let theme_system = CheckMenuItemBuilder::with_id("theme-system", "System (Auto)") + .checked(false) + .build(app)?; let theme_solarized_light = CheckMenuItemBuilder::with_id("theme-solarized-light", "Solarized Light") .checked(false) @@ -295,10 +298,9 @@ pub fn run() { CheckMenuItemBuilder::with_id("theme-solarized-dark", "Solarized Dark") .checked(false) .build(app)?; - let theme_github = - CheckMenuItemBuilder::with_id("theme-github", "GitHub") - .checked(false) - .build(app)?; + let theme_github = CheckMenuItemBuilder::with_id("theme-github", "GitHub") + .checked(false) + .build(app)?; let themes_menu = SubmenuBuilder::new(app, "Themes") .item(&theme_light) @@ -375,9 +377,7 @@ pub fn run() { app.listen("sync-theme-menu", move |event: tauri::Event| { let payload = event.payload(); // Payload arrives as JSON string e.g. "\"theme-system\"" - let selected_id = payload - .trim_matches('"') - .trim(); + let selected_id = payload.trim_matches('"').trim(); for item in &theme_items_sync { let _ = item.set_checked(item.id().0.as_str() == selected_id); } @@ -401,6 +401,11 @@ pub fn run() { let _ = window.emit("menu-open-folder", ()); } } + "open_by_path" => { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.emit("menu-open-by-path", ()); + } + } "save_file" => { if let Some(window) = app_handle.get_webview_window("main") { let _ = window.emit("menu-save-file", ()); diff --git a/src/App.vue b/src/App.vue index 26fbe85..d0c8549 100644 --- a/src/App.vue +++ b/src/App.vue @@ -36,6 +36,7 @@ + @@ -56,6 +57,7 @@ import SaveNotification from './components/SaveNotification.vue' import PreferencesWindow from './components/preferences/PreferencesWindow.vue' import ExportDialog from './components/ExportDialog.vue' import ExportToast from './components/ExportToast.vue' +import QuickOpenDialog from './components/QuickOpenDialog.vue' import { useTabsStore } from './stores/tabs' import { useSidebarStore } from './stores/sidebar' import { useRecentFilesStore } from './stores/recentFiles' @@ -113,6 +115,7 @@ let unlistenPrevTab: UnlistenFn | null = null let unlistenExportHtml: UnlistenFn | null = null let unlistenCopyRichText: UnlistenFn | null = null let unlistenPrintPdf: UnlistenFn | null = null +let unlistenOpenByPath: UnlistenFn | null = null /** Handle keyboard shortcuts */ function handleKeydown(e: KeyboardEvent) { @@ -144,6 +147,13 @@ function handleKeydown(e: KeyboardEvent) { return } + // Cmd+Shift+G: Open by path (macOS Finder "Go to Folder" shortcut) + if (e.metaKey && e.shiftKey && (e.key === 'g' || e.key === 'G')) { + e.preventDefault() + window.dispatchEvent(new CustomEvent('gdown:quick-open')) + return + } + // Cmd+\: Toggle sidebar visibility (Typora shortcut) if (e.metaKey && e.key === '\\') { e.preventDefault() @@ -298,6 +308,11 @@ onMounted(async () => { sidebarStore.openFolderDialog() }) + // File > Open by Path (Cmd+Shift+G) + unlistenOpenByPath = await listen('menu-open-by-path', () => { + window.dispatchEvent(new CustomEvent('gdown:quick-open')) + }) + // File > Save (Cmd+S) — save active tab to disk unlistenSaveFile = await listen('menu-save-file', () => { autoSaveStore.saveNow() @@ -510,6 +525,7 @@ onUnmounted(() => { if (unlistenExportHtml) unlistenExportHtml() if (unlistenCopyRichText) unlistenCopyRichText() if (unlistenPrintPdf) unlistenPrintPdf() + unlistenOpenByPath?.() }) diff --git a/src/__tests__/components/QuickOpenDialog.test.ts b/src/__tests__/components/QuickOpenDialog.test.ts new file mode 100644 index 0000000..03d8ef2 --- /dev/null +++ b/src/__tests__/components/QuickOpenDialog.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import QuickOpenDialog from '../../components/QuickOpenDialog.vue' + +const mockInvoke = vi.fn() +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})) + +/** + * QuickOpenDialog uses , so teleported DOM lives in + * document.body. We query document.body for teleported content. + */ +function findOverlay(): HTMLElement | null { + return document.body.querySelector('.quick-open-overlay') +} + +function findInput(): HTMLInputElement | null { + return document.body.querySelector('.quick-open-box input') +} + +function findError(): HTMLElement | null { + return document.body.querySelector('.quick-open-error') +} + +async function openDialog(wrapper: VueWrapper): Promise { + window.dispatchEvent(new CustomEvent('gdown:quick-open')) + await wrapper.vm.$nextTick() +} + +async function setInputAndSubmit(wrapper: VueWrapper, value: string): Promise { + const input = findInput()! + // Use native setter to trigger Vue's v-model via input event + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set! + nativeInputValueSetter.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + await wrapper.vm.$nextTick() + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await vi.dynamicImportSettled() + await wrapper.vm.$nextTick() + // Extra tick for any follow-up reactivity + await nextTick() +} + +describe('QuickOpenDialog', () => { + let wrapper: VueWrapper + + beforeEach(() => { + setActivePinia(createPinia()) + mockInvoke.mockReset() + }) + + afterEach(() => { + wrapper?.unmount() + }) + + it('is hidden by default', () => { + wrapper = mount(QuickOpenDialog) + expect(findOverlay()).toBeNull() + }) + + it('opens when gdown:quick-open event dispatched', async () => { + wrapper = mount(QuickOpenDialog) + + await openDialog(wrapper) + + expect(findOverlay()).not.toBeNull() + expect(findInput()).not.toBeNull() + }) + + it('closes when Escape is pressed', async () => { + wrapper = mount(QuickOpenDialog) + + await openDialog(wrapper) + expect(findOverlay()).not.toBeNull() + + const input = findInput()! + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await wrapper.vm.$nextTick() + + expect(findOverlay()).toBeNull() + }) + + it('closes when clicking overlay backdrop', async () => { + wrapper = mount(QuickOpenDialog) + + await openDialog(wrapper) + expect(findOverlay()).not.toBeNull() + + // Click the overlay itself (not children) — matches @click.self + findOverlay()!.click() + await wrapper.vm.$nextTick() + + expect(findOverlay()).toBeNull() + }) + + it('shows error when file does not exist', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'resolve_file_path') { + return Promise.resolve({ + canonical_path: '/no/such/file.md', + exists: false, + is_file: false, + }) + } + return Promise.resolve(null) + }) + + wrapper = mount(QuickOpenDialog) + await openDialog(wrapper) + await setInputAndSubmit(wrapper, '/no/such/file.md') + + const errorEl = findError() + expect(errorEl).not.toBeNull() + expect(errorEl!.textContent).toBe('File does not exist') + }) + + it('shows error when path is a directory', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'resolve_file_path') { + return Promise.resolve({ + canonical_path: '/some/dir', + exists: true, + is_file: false, + }) + } + return Promise.resolve(null) + }) + + wrapper = mount(QuickOpenDialog) + await openDialog(wrapper) + await setInputAndSubmit(wrapper, '/some/dir') + + const errorEl = findError() + expect(errorEl).not.toBeNull() + expect(errorEl!.textContent).toBe('Path is a directory, not a file') + }) + + it('calls openFile and closes on valid path', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'resolve_file_path') { + return Promise.resolve({ + canonical_path: '/home/user/doc.md', + exists: true, + is_file: true, + }) + } + if (cmd === 'read_file') { + return Promise.resolve('# Hello') + } + return Promise.resolve(null) + }) + + wrapper = mount(QuickOpenDialog) + await openDialog(wrapper) + await setInputAndSubmit(wrapper, '~/doc.md') + + expect(mockInvoke).toHaveBeenCalledWith('resolve_file_path', { + path: '~/doc.md', + baseDir: undefined, + }) + + // Dialog should close after successful open + expect(findOverlay()).toBeNull() + }) + + it('does not submit when input is empty', async () => { + wrapper = mount(QuickOpenDialog) + await openDialog(wrapper) + + const input = findInput()! + // Input is empty by default + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await vi.dynamicImportSettled() + await wrapper.vm.$nextTick() + + expect(mockInvoke).not.toHaveBeenCalled() + expect(findOverlay()).not.toBeNull() + }) + + it('clears error when typing', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'resolve_file_path') { + return Promise.resolve({ + canonical_path: '/bad', + exists: false, + is_file: false, + }) + } + return Promise.resolve(null) + }) + + wrapper = mount(QuickOpenDialog) + await openDialog(wrapper) + await setInputAndSubmit(wrapper, '/bad') + expect(findError()).not.toBeNull() + + // Type something new — the @input handler should clear the error + const input = findInput()! + input.dispatchEvent(new Event('input', { bubbles: true })) + await wrapper.vm.$nextTick() + + expect(findError()).toBeNull() + }) + + it('toggles off when event dispatched while open', async () => { + wrapper = mount(QuickOpenDialog) + + await openDialog(wrapper) + expect(findOverlay()).not.toBeNull() + + // Dispatch again — should close (toggle behavior) + window.dispatchEvent(new CustomEvent('gdown:quick-open')) + await wrapper.vm.$nextTick() + + expect(findOverlay()).toBeNull() + }) + + it('cleans up event listener on unmount', () => { + wrapper = mount(QuickOpenDialog) + wrapper.unmount() + + // Dispatching after unmount should not throw + expect(() => { + window.dispatchEvent(new CustomEvent('gdown:quick-open')) + }).not.toThrow() + }) +}) diff --git a/src/components/QuickOpenDialog.vue b/src/components/QuickOpenDialog.vue new file mode 100644 index 0000000..0bfdc59 --- /dev/null +++ b/src/components/QuickOpenDialog.vue @@ -0,0 +1,148 @@ + + + + +