diff --git a/docs/superpowers/plans/2026-03-26-quick-open-by-path.md b/docs/superpowers/plans/2026-03-26-quick-open-by-path.md new file mode 100644 index 0000000..d459185 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-quick-open-by-path.md @@ -0,0 +1,846 @@ +# Quick Open by Path — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `Cmd+Shift+G` modal dialog that opens any file on disk by typing its path (with `~` expansion). + +**Architecture:** A new `QuickOpenDialog.vue` component (Teleport-based, same pattern as InsertLinkDialog) triggered via `gdown:quick-open` custom event. A new Rust command `resolve_file_path` handles `~` expansion and path validation. The keyboard shortcut is registered in both App.vue and the Tauri menu in lib.rs. + +**Tech Stack:** Vue 3, Tauri 2, Rust, Vitest + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `src/components/QuickOpenDialog.vue` | Modal UI: text input, validation, open action | +| Create | `src/__tests__/components/QuickOpenDialog.test.ts` | Component tests | +| Modify | `src/App.vue` | Add `Cmd+Shift+G` shortcut + mount QuickOpenDialog + listen for menu event | +| Modify | `src-tauri/src/commands/fs.rs` | Add `resolve_file_path` command | +| Modify | `src-tauri/src/lib.rs` | Register command + add menu item + emit event | + +--- + +### Task 1: Rust `resolve_file_path` Command + +**Files:** +- Modify: `src-tauri/src/commands/fs.rs` +- Modify: `src-tauri/src/lib.rs:8` (add to use statement) +- Modify: `src-tauri/src/lib.rs:171-194` (register in invoke_handler) + +- [ ] **Step 1: Add the `resolve_file_path` command to `src-tauri/src/commands/fs.rs`** + +Add at the end of the file, before the `#[cfg(test)]` block: + +```rust +/// Result of resolving a file path. +#[derive(Debug, Clone, Serialize)] +pub struct ResolveResult { + /// The canonicalized absolute path (after ~ expansion). + pub canonical_path: String, + /// Whether the path exists on disk. + pub exists: bool, + /// Whether the path points to a file (not a directory). + pub is_file: bool, +} + +/// Tauri command: Resolves a file path with ~ expansion and validates existence. +/// +/// # Arguments +/// * `path` - The path to resolve. Supports `~` for home directory. +/// * `base_dir` - Optional base directory for resolving relative paths. Falls back to home dir. +#[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()); + } + + // Expand ~ to home directory + let expanded = if path.starts_with('~') { + let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?; + if path == "~" { + home + } else { + // ~/foo/bar → /Users/name/foo/bar + home.join(&path[2..]) + } + } else { + let p = PathBuf::from(&path); + if p.is_absolute() { + p + } else { + // Resolve relative path against base_dir or home + 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(); + + // Use canonicalize if the path exists, otherwise return the expanded path + let canonical = if exists { + expanded + .canonicalize() + .unwrap_or(expanded) + } else { + expanded + }; + + Ok(ResolveResult { + canonical_path: canonical.to_string_lossy().to_string(), + exists, + is_file, + }) +} +``` + +- [ ] **Step 2: Add `dirs` crate to Cargo.toml** + +In `src-tauri/Cargo.toml`, add under `[dependencies]`: + +```toml +dirs = "6" +``` + +- [ ] **Step 3: Export the new command from `fs.rs` and register in `lib.rs`** + +In `src-tauri/src/lib.rs`, update the `use` statement at line 8: + +```rust +use commands::fs::{ + copy_image_to_assets, get_file_modified_time, read_directory_shallow, read_directory_tree, + read_file, resolve_file_path, write_file, write_image_to_assets, +}; +``` + +In the `invoke_handler` macro (around line 171), add `resolve_file_path` to the list: + +```rust +.invoke_handler(tauri::generate_handler![ + greet, + read_directory_tree, + read_directory_shallow, + read_file, + write_file, + get_file_modified_time, + open_folder_dialog, + open_file_dialog, + save_file_dialog, + get_pending_open_files, + save_session_state, + load_session_state, + copy_image_to_assets, + write_image_to_assets, + resolve_file_path, + check_pandoc, + get_export_formats, + get_export_config, + export_document, + export_save_dialog, + find_claude_project_dir, + list_files_with_mtime, + find_instruction_files, +]) +``` + +- [ ] **Step 4: Add Rust tests for `resolve_file_path`** + +Add to the existing `#[cfg(test)] mod tests` block in `fs.rs`: + +```rust +#[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()); +} + +#[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() { + // ~ should resolve to home directory (which exists) + let result = resolve_file_path("~".to_string(), None).unwrap(); + assert!(result.exists); + assert!(!result.is_file); // home dir is a directory +} + +#[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); +} +``` + +- [ ] **Step 5: Verify Rust compiles and tests pass** + +Run: +```bash +cd src-tauri && cargo test -- --test-threads=1 +``` +Expected: all tests pass, including the new `resolve_file_path` tests. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/commands/fs.rs src-tauri/src/lib.rs src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "feat: add resolve_file_path Tauri command with ~ expansion" +``` + +--- + +### Task 2: Tauri Menu Item for "Open by Path..." + +**Files:** +- Modify: `src-tauri/src/lib.rs:196-278` (menu setup) +- Modify: `src-tauri/src/lib.rs:386-484` (menu event handler) + +- [ ] **Step 1: Add the menu item definition** + +In `lib.rs`, after the `open_folder` menu item definition (around line 204), add: + +```rust +let open_by_path = MenuItemBuilder::with_id("open_by_path", "Open by Path...") + .accelerator("CmdOrCtrl+Shift+G") + .build(app)?; +``` + +- [ ] **Step 2: Add the menu item to the File menu** + +In the `file_menu` builder (around line 260-278), add `&open_by_path` after `&open_folder`: + +```rust +let file_menu = SubmenuBuilder::new(app, "File") + .item(&new_file) + .item(&open_file) + .item(&open_folder) + .item(&open_by_path) + .item(&open_recent_menu) + .separator() + // ... rest unchanged +``` + +- [ ] **Step 3: Add the menu event handler** + +In the `on_menu_event` match block, after the `"open_folder"` arm (around line 403), add: + +```rust +"open_by_path" => { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.emit("menu-open-by-path", ()); + } +} +``` + +- [ ] **Step 4: Verify Rust compiles** + +Run: +```bash +cd src-tauri && cargo check +``` +Expected: compiles with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/lib.rs +git commit -m "feat: add 'Open by Path...' menu item with Cmd+Shift+G accelerator" +``` + +--- + +### Task 3: QuickOpenDialog Vue Component + +**Files:** +- Create: `src/components/QuickOpenDialog.vue` + +- [ ] **Step 1: Create the component** + +Create `src/components/QuickOpenDialog.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Verify the file was created** + +Run: +```bash +ls -la src/components/QuickOpenDialog.vue +``` +Expected: file exists. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/QuickOpenDialog.vue +git commit -m "feat: add QuickOpenDialog component for open-by-path" +``` + +--- + +### Task 4: Wire QuickOpenDialog into App.vue + +**Files:** +- Modify: `src/App.vue:34-39` (template — add component) +- Modify: `src/App.vue:42-50` (script — add import) +- Modify: `src/App.vue:100-115` (script — add unlisten variable) +- Modify: `src/App.vue:117-267` (script — add keyboard shortcut) +- Modify: `src/App.vue:275-299` (script — add menu event listener in onMounted) + +- [ ] **Step 1: Add the component to the template** + +In `src/App.vue` template, after the `` line (around line 38), add: + +```html + +``` + +- [ ] **Step 2: Add the import** + +In the `