From cda87ee6c03052393013066a43afc5ab9ef884f8 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 22 Apr 2026 17:16:56 +0200 Subject: [PATCH] feat: add sidebar folder rename and delete actions --- docs/ABSTRACTIONS.md | 7 + docs/ARCHITECTURE.md | 7 +- src-tauri/src/commands/folders.rs | 23 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 2 + src-tauri/src/vault/folders.rs | 183 +++++++++++ src-tauri/src/vault/mod.rs | 2 + src/App.tsx | 30 +- src/components/FolderTree.test.tsx | 94 ++++-- src/components/FolderTree.tsx | 236 +++++++-------- src/components/Sidebar.tsx | 119 +++----- .../folder-tree/FolderContextMenu.tsx | 54 ++++ .../folder-tree/FolderNameInput.tsx | 72 +++++ src/components/folder-tree/FolderTreeRow.tsx | 283 ++++++++++++++++++ src/components/folder-tree/folderTreeUtils.ts | 18 ++ .../folder-tree/useFolderContextMenu.ts | 65 ++++ .../folder-tree/useFolderTreeDisclosure.ts | 66 ++++ .../sidebar/useSidebarTypeInteractions.ts | 154 ++++++++++ src/hooks/commands/navigationCommands.ts | 81 ++++- src/hooks/folder-actions/folderActionUtils.ts | 204 +++++++++++++ src/hooks/folder-actions/useFolderDelete.ts | 92 ++++++ src/hooks/folder-actions/useFolderRename.ts | 86 ++++++ src/hooks/useAppCommands.ts | 6 + src/hooks/useCommandRegistry.test.ts | 41 +++ src/hooks/useCommandRegistry.ts | 20 +- src/hooks/useFolderActions.test.ts | 149 +++++++++ src/hooks/useFolderActions.ts | 59 ++++ 27 files changed, 1915 insertions(+), 240 deletions(-) create mode 100644 src-tauri/src/commands/folders.rs create mode 100644 src-tauri/src/vault/folders.rs create mode 100644 src/components/folder-tree/FolderContextMenu.tsx create mode 100644 src/components/folder-tree/FolderNameInput.tsx create mode 100644 src/components/folder-tree/FolderTreeRow.tsx create mode 100644 src/components/folder-tree/folderTreeUtils.ts create mode 100644 src/components/folder-tree/useFolderContextMenu.ts create mode 100644 src/components/folder-tree/useFolderTreeDisclosure.ts create mode 100644 src/components/sidebar/useSidebarTypeInteractions.ts create mode 100644 src/hooks/folder-actions/folderActionUtils.ts create mode 100644 src/hooks/folder-actions/useFolderDelete.ts create mode 100644 src/hooks/folder-actions/useFolderRename.ts create mode 100644 src/hooks/useFolderActions.test.ts create mode 100644 src/hooks/useFolderActions.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 1a5c8bc5..a464ed1f 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -266,6 +266,13 @@ type SidebarSelection = | { kind: 'view'; filename: string } ``` +`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight. + +- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. +- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry. +- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected. +- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection. + ### Neighborhood Mode `SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31ecfa30..6f46cadf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -176,7 +176,7 @@ flowchart TD └──────────────────────────────────────────────────────────────┘ ``` -- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. +- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. - **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs. - **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). @@ -617,6 +617,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `save_note_content` | Write note content to disk | | `delete_note` | Permanently delete note from disk (with confirm dialog) | | `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks | +| `create_vault_folder` | Create a folder relative to the active vault root | +| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths | +| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root | | `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow | | `batch_archive_notes` | Archive multiple notes | | `batch_delete_notes` | Permanently delete notes from disk | @@ -761,7 +764,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c | Cmd+[ / Cmd+] | Navigate back / forward | | `[[` in editor | Open wikilink suggestion menu | -Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. +Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Shortcut routing is explicit: diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs new file mode 100644 index 00000000..ff240eff --- /dev/null +++ b/src-tauri/src/commands/folders.rs @@ -0,0 +1,23 @@ +use crate::vault::{self, FolderRenameResult}; + +use super::expand_tilde; + +#[tauri::command] +pub fn rename_vault_folder( + vault_path: String, + folder_path: String, + new_name: String, +) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::rename_folder( + std::path::Path::new(vault_path.as_ref()), + &folder_path, + &new_name, + ) +} + +#[tauri::command] +pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6d42dc9a..2ec88bad 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod ai; mod delete; +mod folders; mod git; mod git_connect; mod system; @@ -10,6 +11,7 @@ use std::borrow::Cow; pub use ai::*; pub use delete::*; +pub use folders::*; pub use git::*; pub use git_connect::*; pub use system::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c7e2916..bcb64987 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -198,6 +198,8 @@ macro_rules! app_invoke_handler { commands::batch_delete_notes_async, commands::migrate_is_a_to_type, commands::create_vault_folder, + commands::rename_vault_folder, + commands::delete_vault_folder, commands::batch_archive_notes, commands::get_settings, commands::check_for_app_update, diff --git a/src-tauri/src/vault/folders.rs b/src-tauri/src/vault/folders.rs new file mode 100644 index 00000000..8f1cc649 --- /dev/null +++ b/src-tauri/src/vault/folders.rs @@ -0,0 +1,183 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FolderRenameResult { + pub old_path: String, + pub new_path: String, +} + +fn normalize_folder_name(next_name: &str) -> Result { + let trimmed = next_name.trim(); + if trimmed.is_empty() { + return Err("Folder name cannot be empty".to_string()); + } + if trimmed == "." || trimmed == ".." || trimmed.contains('/') || trimmed.contains('\\') { + return Err("Invalid folder name".to_string()); + } + Ok(trimmed.to_string()) +} + +fn ensure_relative_folder_path(folder_path: &str) -> Result { + let trimmed = folder_path.trim(); + if trimmed.is_empty() { + return Err("Folder path cannot be empty".to_string()); + } + + let relative = Path::new(trimmed); + if relative.is_absolute() { + return Err("Folder path must be relative to the vault root".to_string()); + } + if relative + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("Folder path cannot escape the vault root".to_string()); + } + + Ok(relative.to_path_buf()) +} + +fn display_relative_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +pub fn rename_folder( + vault_path: &Path, + folder_path: &str, + next_name: &str, +) -> Result { + let relative_path = ensure_relative_folder_path(folder_path)?; + let normalized_name = normalize_folder_name(next_name)?; + let source_path = vault_path.join(&relative_path); + + if !source_path.exists() { + return Err(format!("Folder does not exist: {}", folder_path)); + } + if !source_path.is_dir() { + return Err(format!("Not a folder: {}", folder_path)); + } + + let current_name = source_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .ok_or_else(|| "Folder path cannot target the vault root".to_string())?; + + if current_name == normalized_name { + return Ok(FolderRenameResult { + old_path: display_relative_path(&relative_path), + new_path: display_relative_path(&relative_path), + }); + } + + let parent_relative = relative_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + let destination_relative = parent_relative.join(&normalized_name); + let destination_path = vault_path.join(&destination_relative); + + if destination_path.exists() { + return Err(format!( + "Folder '{}' already exists", + display_relative_path(&destination_relative) + )); + } + + fs::rename(&source_path, &destination_path) + .map_err(|error| format!("Failed to rename folder: {}", error))?; + + Ok(FolderRenameResult { + old_path: display_relative_path(&relative_path), + new_path: display_relative_path(&destination_relative), + }) +} + +pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result { + let relative_path = ensure_relative_folder_path(folder_path)?; + let target_path = vault_path.join(&relative_path); + + if !target_path.exists() { + return Err(format!("Folder does not exist: {}", folder_path)); + } + if !target_path.is_dir() { + return Err(format!("Not a folder: {}", folder_path)); + } + + fs::remove_dir_all(&target_path) + .map_err(|error| format!("Failed to delete folder: {}", error))?; + Ok(display_relative_path(&relative_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_folder(dir: &TempDir, relative: &str) -> PathBuf { + let path = dir.path().join(relative); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn rename_folder_updates_relative_destination() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects/laputa"); + + let result = rename_folder(dir.path(), "projects", "work").unwrap(); + + assert_eq!( + result, + FolderRenameResult { + old_path: "projects".to_string(), + new_path: "work".to_string(), + } + ); + assert!(dir.path().join("work/laputa").is_dir()); + assert!(!dir.path().join("projects").exists()); + } + + #[test] + fn rename_folder_rejects_duplicate_sibling() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects"); + make_folder(&dir, "areas"); + + let error = rename_folder(dir.path(), "projects", "areas").unwrap_err(); + + assert_eq!(error, "Folder 'areas' already exists"); + } + + #[test] + fn rename_folder_rejects_invalid_names() { + let dir = TempDir::new().unwrap(); + make_folder(&dir, "projects"); + + let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err(); + + assert_eq!(error, "Invalid folder name"); + } + + #[test] + fn delete_folder_removes_nested_contents() { + let dir = TempDir::new().unwrap(); + let nested = make_folder(&dir, "projects/laputa"); + fs::write(nested.join("note.md"), "# Note\n").unwrap(); + + let deleted_path = delete_folder(dir.path(), "projects").unwrap(); + + assert_eq!(deleted_path, "projects"); + assert!(!dir.path().join("projects").exists()); + } + + #[test] + fn delete_folder_rejects_missing_folder() { + let dir = TempDir::new().unwrap(); + + let error = delete_folder(dir.path(), "projects").unwrap_err(); + + assert_eq!(error, "Folder does not exist: projects"); + } +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 7d007aca..aef45784 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -2,6 +2,7 @@ mod cache; mod config_seed; mod entry; mod file; +mod folders; mod frontmatter; mod getting_started; mod image; @@ -19,6 +20,7 @@ pub use config_seed::{ }; pub use entry::{FolderNode, VaultEntry}; pub use file::{get_note_content, save_note_content}; +pub use folders::{delete_folder, rename_folder, FolderRenameResult}; pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; diff --git a/src/App.tsx b/src/App.tsx index eeea11ce..29f94aea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ import { import { useAiActivity } from './hooks/useAiActivity' import { useBulkActions } from './hooks/useBulkActions' import { useDeleteActions } from './hooks/useDeleteActions' +import { useFolderActions } from './hooks/useFolderActions' import { useLayoutPanels } from './hooks/useLayoutPanels' import { useConflictFlow } from './hooks/useConflictFlow' import { useAppSave } from './hooks/useAppSave' @@ -714,11 +715,26 @@ function App() { } await vault.reloadFolders() setToastMessage(`Created folder "${name}"`) + return true } catch (e) { setToastMessage(`Failed to create folder: ${e}`) + return false } }, [resolvedPath, vault, setToastMessage]) + const folderActions = useFolderActions({ + vaultPath: resolvedPath, + selection: effectiveSelection, + setSelection: handleSetSelection, + setTabs: notes.setTabs, + activeTabPathRef: notes.activeTabPathRef, + handleSwitchTab: notes.handleSwitchTab, + closeAllTabs: notes.closeAllTabs, + reloadVault: vault.reloadVault, + reloadFolders: vault.reloadFolders, + setToastMessage, + }) + const handleRemoveNoteIconCommand = useCallback(() => { if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath) }, [notes.activeTabPath, handleRemoveNoteIcon]) @@ -1135,6 +1151,8 @@ function App() { onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, onSelect: handleSetSelection, + onRenameFolder: folderActions.renameSelectedFolder, + onDeleteFolder: folderActions.deleteSelectedFolder, showInbox: explicitOrganizationEnabled, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, @@ -1275,7 +1293,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -1398,6 +1416,16 @@ function App() { onCancel={() => deleteActions.setConfirmDelete(null)} /> )} + {folderActions.confirmDeleteFolder && ( + + )} ) } diff --git a/src/components/FolderTree.test.tsx b/src/components/FolderTree.test.tsx index 434788d7..1ab87d5d 100644 --- a/src/components/FolderTree.test.tsx +++ b/src/components/FolderTree.test.tsx @@ -34,37 +34,91 @@ describe('FolderTree', () => { expect(screen.getByText('journal')).toBeInTheDocument() }) - it('does not show children initially', () => { + it('expands children when clicking the folder chevron', () => { render() expect(screen.queryByText('laputa')).not.toBeInTheDocument() - }) - - it('calls onSelect with folder kind when clicking a folder', () => { - const onSelect = vi.fn() - render() - fireEvent.click(screen.getByText('projects')) - expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' }) - }) - - it('expands children when clicking a folder with children', () => { - const onSelect = vi.fn() - render() - fireEvent.click(screen.getByText('projects')) + fireEvent.click(screen.getByLabelText('Expand projects')) expect(screen.getByText('laputa')).toBeInTheDocument() expect(screen.getByText('portfolio')).toBeInTheDocument() }) - it('collapses section when clicking FOLDERS header', () => { + it('calls onSelect with folder kind when clicking a folder row', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByTestId('folder-row:projects')) + expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' }) + }) + + it('collapses section when clicking the FOLDERS header', () => { render() expect(screen.getByText('projects')).toBeInTheDocument() fireEvent.click(screen.getByText('FOLDERS')) expect(screen.queryByText('projects')).not.toBeInTheDocument() }) - it('highlights selected folder', () => { - const sel: SidebarSelection = { kind: 'folder', path: 'areas' } - render() - const btn = screen.getByText('areas').closest('button')! - expect(btn.className).toContain('text-primary') + it('highlights the selected folder row', () => { + const selection: SidebarSelection = { kind: 'folder', path: 'areas' } + render() + expect(screen.getByTestId('folder-row:areas').className).toContain('text-primary') + }) + + it('opens the create-folder input from the header action', () => { + render( + , + ) + fireEvent.click(screen.getByTestId('create-folder-btn')) + expect(screen.getByTestId('new-folder-input')).toBeInTheDocument() + }) + + it('starts rename on folder double-click', () => { + const onStartRenameFolder = vi.fn() + render( + , + ) + fireEvent.doubleClick(screen.getByTestId('folder-row:areas')) + expect(onStartRenameFolder).toHaveBeenCalledWith('areas') + }) + + it('shows the rename input when a folder is being renamed', () => { + render( + , + ) + expect(screen.getByTestId('rename-folder-input')).toBeInTheDocument() + }) + + it('opens a context menu with a delete action on right-click', () => { + const onDeleteFolder = vi.fn() + render( + , + ) + fireEvent.contextMenu(screen.getByText('projects')) + expect(screen.getByTestId('folder-context-menu')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('delete-folder-menu-item')) + expect(onDeleteFolder).toHaveBeenCalledWith('projects') }) }) diff --git a/src/components/FolderTree.tsx b/src/components/FolderTree.tsx index b092fa1a..90c5c6b4 100644 --- a/src/components/FolderTree.tsx +++ b/src/components/FolderTree.tsx @@ -1,162 +1,148 @@ -import { useState, useCallback, useRef, useEffect, memo } from 'react' -import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react' +import { + memo, + useCallback, +} from 'react' +import { + Plus, +} from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' import type { FolderNode, SidebarSelection } from '../types' -import { cn } from '@/lib/utils' +import { FolderContextMenu } from './folder-tree/FolderContextMenu' +import { FolderNameInput } from './folder-tree/FolderNameInput' +import { FolderTreeRow } from './folder-tree/FolderTreeRow' +import { useFolderContextMenu } from './folder-tree/useFolderContextMenu' +import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure' +import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader' interface FolderTreeProps { folders: FolderNode[] selection: SidebarSelection onSelect: (selection: SidebarSelection) => void - onCreateFolder?: (name: string) => void + onCreateFolder?: (name: string) => Promise | boolean + onRenameFolder?: (folderPath: string, nextName: string) => Promise | boolean + onDeleteFolder?: (folderPath: string) => void + renamingFolderPath?: string | null + onStartRenameFolder?: (folderPath: string) => void + onCancelRenameFolder?: () => void collapsed?: boolean onToggle?: () => void } -function FolderItem({ - node, depth, selection, expanded, onToggle, onSelect, -}: { - node: FolderNode - depth: number - selection: SidebarSelection - expanded: Record - onToggle: (path: string) => void - onSelect: (selection: SidebarSelection) => void -}) { - const isSelected = selection.kind === 'folder' && selection.path === node.path - const isExpanded = expanded[node.path] ?? false - const hasChildren = node.children.length > 0 +export const FolderTree = memo(function FolderTree({ + folders, + selection, + onSelect, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + renamingFolderPath, + onStartRenameFolder, + onCancelRenameFolder, + collapsed: externalCollapsed, + onToggle, +}: FolderTreeProps) { + const { + closeCreateForm, + expanded, + handleToggleSection, + isCreating, + openCreateForm, + sectionCollapsed, + toggleFolder, + } = useFolderTreeDisclosure({ + collapsed: externalCollapsed, + onToggle, + renamingFolderPath, + selection, + }) + const { + closeContextMenu, + contextMenu, + handleDeleteFromMenu, + handleOpenMenu, + handleRenameFromMenu, + menuRef, + } = useFolderContextMenu({ + onDeleteFolder, + onStartRenameFolder, + }) - const handleClick = () => { - onSelect({ kind: 'folder', path: node.path }) - if (hasChildren) onToggle(node.path) - } - - return ( - <> - - {isExpanded && hasChildren && ( -
-
- {node.children.map((child) => ( - - ))} -
- )} - - ) -} - -export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder, collapsed: externalCollapsed, onToggle }: FolderTreeProps) { - const [internalCollapsed, setInternalCollapsed] = useState(false) - const sectionCollapsed = externalCollapsed ?? internalCollapsed - const [expanded, setExpanded] = useState>({}) - const [isCreating, setIsCreating] = useState(false) - const [newFolderName, setNewFolderName] = useState('') - const inputRef = useRef(null) - - const toggleFolder = useCallback((path: string) => { - setExpanded((prev) => ({ ...prev, [path]: !prev[path] })) - }, []) - - useEffect(() => { - if (isCreating) inputRef.current?.focus() - }, [isCreating]) - - const handleCreateFolder = () => { - const name = newFolderName.trim() - if (name && onCreateFolder) { - onCreateFolder(name) + const handleCreateFolderSubmit = useCallback(async (value: string) => { + const nextName = value.trim() + if (!nextName || !onCreateFolder) { + closeCreateForm() + return true } - setIsCreating(false) - setNewFolderName('') - } + + const created = await onCreateFolder(nextName) + if (created) closeCreateForm() + return created + }, [closeCreateForm, onCreateFolder]) if (folders.length === 0 && !isCreating) return null return ( -
- {/* Header */} - )} - - - {/* Tree */} + {!sectionCollapsed && ( -
+
{folders.map((node) => ( - ))} {isCreating && ( -
- - setNewFolderName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleCreateFolder() - if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') } - }} - onBlur={handleCreateFolder} +
+
)}
)} +
) }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4ef64cef..4e07aaaa 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback, memo } from 'react' +import { useCallback, memo } from 'react' import type { VaultEntry, FolderNode, SidebarSelection, ViewFile } from '../types' import { KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, @@ -6,10 +6,8 @@ import { import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { FolderTree } from './FolderTree' import { - applyCustomization, computeReorder, useEntryCounts, - useOutsideClick, useSidebarCollapsed, useSidebarSections, } from './sidebar/sidebarHooks' @@ -23,6 +21,7 @@ import { TypesSection, ViewsSection, } from './sidebar/SidebarSections' +import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions' interface SidebarProps { entries: VaultEntry[] @@ -43,7 +42,12 @@ interface SidebarProps { onEditView?: (filename: string) => void onDeleteView?: (filename: string) => void folders?: FolderNode[] - onCreateFolder?: (name: string) => void + onCreateFolder?: (name: string) => Promise | boolean + onRenameFolder?: (folderPath: string, nextName: string) => Promise | boolean + onDeleteFolder?: (folderPath: string) => void + renamingFolderPath?: string | null + onStartRenameFolder?: (folderPath: string) => void + onCancelRenameFolder?: () => void showInbox?: boolean inboxCount?: number onCollapse?: () => void @@ -66,40 +70,30 @@ export const Sidebar = memo(function Sidebar({ onDeleteView, folders = [], onCreateFolder, + onRenameFolder, + onDeleteFolder, + renamingFolderPath, + onStartRenameFolder, + onCancelRenameFolder, showInbox = true, inboxCount = 0, onCollapse, onCreateNewType, }: SidebarProps) { - const [customizeTarget, setCustomizeTarget] = useState(null) - const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) - const [contextMenuType, setContextMenuType] = useState(null) - const [renamingType, setRenamingType] = useState(null) - const [renameInitialValue, setRenameInitialValue] = useState('') - const [showCustomize, setShowCustomize] = useState(false) - - const contextMenuRef = useRef(null) - const popoverRef = useRef(null) - const customizeRef = useRef(null) - const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries) const { activeCount, archivedCount } = useEntryCounts(entries) const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed() + const typeInteractions = useSidebarTypeInteractions({ + allSectionGroups, + typeEntryMap, + onCustomizeType, + onUpdateTypeTemplate, + onRenameSection, + }) const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap]) const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility]) - const closeContextMenu = useCallback(() => { - setContextMenuPos(null) - setContextMenuType(null) - }, []) - const closeCustomize = useCallback(() => setShowCustomize(false), []) - const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), []) - - useOutsideClick(customizeRef, showCustomize, closeCustomize) - useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu) - useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget) - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -112,44 +106,15 @@ export const Sidebar = memo(function Sidebar({ if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order }))) }, [sectionIds, onReorderSections]) - const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => { - event.preventDefault() - event.stopPropagation() - setContextMenuPos({ x: event.clientX, y: event.clientY }) - setContextMenuType(type) - }, []) - - const cancelRename = useCallback(() => setRenamingType(null), []) - - const handleStartRename = useCallback((type: string) => { - closeContextMenu() - const group = allSectionGroups.find((sectionGroup) => sectionGroup.type === type) - setRenameInitialValue(group?.label ?? type) - setRenamingType(type) - }, [allSectionGroups, closeContextMenu]) - - const handleRenameSubmit = useCallback((value: string) => { - if (renamingType) onRenameSection?.(renamingType, value) - setRenamingType(null) - }, [renamingType, onRenameSection]) - - const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { - applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value) - }, [customizeTarget, typeEntryMap, onCustomizeType]) - - const handleChangeTemplate = useCallback((template: string) => { - if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template) - }, [customizeTarget, onUpdateTypeTemplate]) - const sectionProps: SidebarSectionProps = { entries, selection, onSelect, - onContextMenu: handleContextMenu, - renamingType, - renameInitialValue, - onRenameSubmit: handleRenameSubmit, - onRenameCancel: cancelRename, + onContextMenu: typeInteractions.handleContextMenu, + renamingType: typeInteractions.renamingType, + renameInitialValue: typeInteractions.renameInitialValue, + onRenameSubmit: typeInteractions.handleRenameSubmit, + onRenameCancel: typeInteractions.cancelRename, } const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived) @@ -202,39 +167,41 @@ export const Sidebar = memo(function Sidebar({ sectionProps={sectionProps} collapsed={groupCollapsed.sections} onToggle={() => toggleGroup('sections')} - showCustomize={showCustomize} - setShowCustomize={setShowCustomize} + showCustomize={typeInteractions.showCustomize} + setShowCustomize={typeInteractions.setShowCustomize} isSectionVisible={isSectionVisible} toggleVisibility={toggleVisibility} onCreateNewType={onCreateNewType} - customizeRef={customizeRef} + customizeRef={typeInteractions.customizeRef} /> toggleGroup('folders')} /> { - closeContextMenu() - setCustomizeTarget(type) - }} - onStartRename={handleStartRename} + pos={typeInteractions.contextMenuPos} + type={typeInteractions.contextMenuType} + innerRef={typeInteractions.contextMenuRef} + onOpenCustomize={typeInteractions.openCustomizeTarget} + onStartRename={typeInteractions.handleStartRename} /> ) diff --git a/src/components/folder-tree/FolderContextMenu.tsx b/src/components/folder-tree/FolderContextMenu.tsx new file mode 100644 index 00000000..bf07736a --- /dev/null +++ b/src/components/folder-tree/FolderContextMenu.tsx @@ -0,0 +1,54 @@ +import type { RefObject } from 'react' +import { PencilSimple, Trash } from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' + +export interface FolderContextMenuState { + path: string + x: number + y: number +} + +interface FolderContextMenuProps { + menu: FolderContextMenuState | null + menuRef: RefObject + onDelete?: (folderPath: string) => void + onRename: (folderPath: string) => void +} + +export function FolderContextMenu({ + menu, + menuRef, + onDelete, + onRename, +}: FolderContextMenuProps) { + if (!menu) return null + + return ( +
+ + +
+ ) +} diff --git a/src/components/folder-tree/FolderNameInput.tsx b/src/components/folder-tree/FolderNameInput.tsx new file mode 100644 index 00000000..14feff40 --- /dev/null +++ b/src/components/folder-tree/FolderNameInput.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Folder } from '@phosphor-icons/react' +import { Input } from '@/components/ui/input' + +interface FolderNameInputProps { + ariaLabel: string + initialValue: string + placeholder: string + selectTextOnFocus?: boolean + submitOnBlur?: boolean + testId: string + onCancel: () => void + onSubmit: (value: string) => Promise | boolean +} + +export function FolderNameInput({ + ariaLabel, + initialValue, + placeholder, + selectTextOnFocus = false, + submitOnBlur = false, + testId, + onCancel, + onSubmit, +}: FolderNameInputProps) { + const [value, setValue] = useState(initialValue) + const inputRef = useRef(null) + const submittingRef = useRef(false) + + useEffect(() => { + const input = inputRef.current + if (!input) return + input.focus() + if (selectTextOnFocus) input.select() + }, [selectTextOnFocus]) + + const handleSubmit = useCallback(async () => { + if (submittingRef.current) return false + submittingRef.current = true + try { + return await onSubmit(value) + } finally { + submittingRef.current = false + } + }, [onSubmit, value]) + + return ( +
+ + setValue(event.target.value)} + onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleSubmit() + } + if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + }} + placeholder={placeholder} + data-testid={testId} + /> +
+ ) +} diff --git a/src/components/folder-tree/FolderTreeRow.tsx b/src/components/folder-tree/FolderTreeRow.tsx new file mode 100644 index 00000000..18b503e5 --- /dev/null +++ b/src/components/folder-tree/FolderTreeRow.tsx @@ -0,0 +1,283 @@ +import { memo, useCallback, type MouseEvent as ReactMouseEvent } from 'react' +import { + CaretDown, + CaretRight, + Folder, + FolderOpen, +} from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import type { FolderNode, SidebarSelection } from '../../types' +import { FolderNameInput } from './FolderNameInput' + +interface FolderTreeRowProps { + depth: number + expanded: Record + node: FolderNode + onDeleteFolder?: (folderPath: string) => void + onOpenMenu: (node: FolderNode, event: ReactMouseEvent) => void + onRenameFolder?: (folderPath: string, nextName: string) => Promise | boolean + onSelect: (selection: SidebarSelection) => void + onStartRenameFolder?: (folderPath: string) => void + onToggle: (path: string) => void + onCancelRenameFolder?: () => void + renamingFolderPath?: string | null + selection: SidebarSelection +} + +function FolderRenameRow({ + indentation, + node, + onCancelRenameFolder, + onRenameFolder, +}: { + indentation: number + node: FolderNode + onCancelRenameFolder: () => void + onRenameFolder: (folderPath: string, nextName: string) => Promise | boolean +}) { + return ( +
+ onRenameFolder(node.path, nextName)} + /> +
+ ) +} + +function FolderItemRow({ + indentation, + isExpanded, + isSelected, + node, + onOpenMenu, + onSelect, + onStartRenameFolder, + onToggle, +}: { + indentation: number + isExpanded: boolean + isSelected: boolean + node: FolderNode + onOpenMenu: FolderTreeRowProps['onOpenMenu'] + onSelect: () => void + onStartRenameFolder?: (folderPath: string) => void + onToggle: (path: string) => void +}) { + const hasChildren = node.children.length > 0 + const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}` + + return ( +
{ + onSelect() + onOpenMenu(node, event) + }} + > + onToggle(node.path)} + /> + +
+ ) +} + +function FolderToggleButton({ + expandLabel, + hasChildren, + isExpanded, + onToggle, +}: { + expandLabel: string + hasChildren: boolean + isExpanded: boolean + onToggle: () => void +}) { + return ( + + ) +} + +function FolderSelectButton({ + isExpanded, + isSelected, + node, + onSelect, + onStartRenameFolder, +}: { + isExpanded: boolean + isSelected: boolean + node: FolderNode + onSelect: () => void + onStartRenameFolder?: (folderPath: string) => void +}) { + return ( + + ) +} + +function FolderChildren({ + depth, + expanded, + node, + onDeleteFolder, + onOpenMenu, + onRenameFolder, + onSelect, + onStartRenameFolder, + onToggle, + onCancelRenameFolder, + renamingFolderPath, + selection, +}: FolderTreeRowProps) { + const isExpanded = expanded[node.path] ?? false + const hasChildren = node.children.length > 0 + if (!isExpanded || !hasChildren) return null + + return ( +
+
+ {node.children.map((child) => ( + + ))} +
+ ) +} + +export const FolderTreeRow = memo(function FolderTreeRow({ + depth, + expanded, + node, + onDeleteFolder, + onOpenMenu, + onRenameFolder, + onSelect, + onStartRenameFolder, + onToggle, + onCancelRenameFolder, + renamingFolderPath, + selection, +}: FolderTreeRowProps) { + const isExpanded = expanded[node.path] ?? false + const isRenaming = renamingFolderPath === node.path + const isSelected = selection.kind === 'folder' && selection.path === node.path + const indentation = 8 + depth * 16 + const selectFolder = useCallback(() => { + onSelect({ kind: 'folder', path: node.path }) + }, [node.path, onSelect]) + + return ( + <> + {isRenaming && onRenameFolder && onCancelRenameFolder ? ( + + ) : ( + + )} + + + ) +}) diff --git a/src/components/folder-tree/folderTreeUtils.ts b/src/components/folder-tree/folderTreeUtils.ts new file mode 100644 index 00000000..82375106 --- /dev/null +++ b/src/components/folder-tree/folderTreeUtils.ts @@ -0,0 +1,18 @@ +export function expandedTreePaths(path: string): string[] { + const segments = path.split('/').filter(Boolean) + return segments.map((_, index) => segments.slice(0, index + 1).join('/')) +} + +export function mergeExpandedPaths( + current: Record, + paths: string[], +): Record { + let changed = false + const nextExpanded = { ...current } + for (const path of paths) { + if (nextExpanded[path]) continue + nextExpanded[path] = true + changed = true + } + return changed ? nextExpanded : current +} diff --git a/src/components/folder-tree/useFolderContextMenu.ts b/src/components/folder-tree/useFolderContextMenu.ts new file mode 100644 index 00000000..b2163589 --- /dev/null +++ b/src/components/folder-tree/useFolderContextMenu.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import type { FolderNode } from '../../types' +import type { FolderContextMenuState } from './FolderContextMenu' + +interface UseFolderContextMenuInput { + onDeleteFolder?: (folderPath: string) => void + onStartRenameFolder?: (folderPath: string) => void +} + +export function useFolderContextMenu({ + onDeleteFolder, + onStartRenameFolder, +}: UseFolderContextMenuInput) { + const [contextMenu, setContextMenu] = useState(null) + const menuRef = useRef(null) + + const closeContextMenu = useCallback(() => setContextMenu(null), []) + + useEffect(() => { + if (!contextMenu) return + + const handleOutsideClick = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) closeContextMenu() + } + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeContextMenu() + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleEscape) + } + }, [closeContextMenu, contextMenu]) + + const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent) => { + event.preventDefault() + event.stopPropagation() + setContextMenu({ + path: node.path, + x: event.clientX, + y: event.clientY, + }) + }, []) + + const handleRenameFromMenu = useCallback((folderPath: string) => { + closeContextMenu() + onStartRenameFolder?.(folderPath) + }, [closeContextMenu, onStartRenameFolder]) + + const handleDeleteFromMenu = useCallback((folderPath: string) => { + closeContextMenu() + onDeleteFolder?.(folderPath) + }, [closeContextMenu, onDeleteFolder]) + + return { + closeContextMenu, + contextMenu, + handleDeleteFromMenu, + handleOpenMenu, + handleRenameFromMenu, + menuRef, + } +} diff --git a/src/components/folder-tree/useFolderTreeDisclosure.ts b/src/components/folder-tree/useFolderTreeDisclosure.ts new file mode 100644 index 00000000..d73d2f06 --- /dev/null +++ b/src/components/folder-tree/useFolderTreeDisclosure.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo, useState } from 'react' +import type { SidebarSelection } from '../../types' +import { expandedTreePaths, mergeExpandedPaths } from './folderTreeUtils' + +interface UseFolderTreeDisclosureInput { + collapsed?: boolean + onToggle?: () => void + renamingFolderPath?: string | null + selection: SidebarSelection +} + +export function useFolderTreeDisclosure({ + collapsed: externalCollapsed, + onToggle, + renamingFolderPath, + selection, +}: UseFolderTreeDisclosureInput) { + const [internalCollapsed, setInternalCollapsed] = useState(false) + const [manualExpanded, setManualExpanded] = useState>({}) + const [isCreating, setIsCreating] = useState(false) + + const baseSectionCollapsed = externalCollapsed ?? internalCollapsed + const sectionCollapsed = !isCreating && !renamingFolderPath && baseSectionCollapsed + const requiredExpandedPaths = useMemo(() => { + const nextPaths: string[] = [] + if (selection.kind === 'folder') nextPaths.push(...expandedTreePaths(selection.path)) + if (renamingFolderPath) nextPaths.push(...expandedTreePaths(renamingFolderPath)) + return [...new Set(nextPaths)] + }, [renamingFolderPath, selection]) + + const expanded = useMemo( + () => mergeExpandedPaths(manualExpanded, requiredExpandedPaths), + [manualExpanded, requiredExpandedPaths], + ) + + const handleToggleSection = useCallback(() => { + if (onToggle) { + onToggle() + return + } + setInternalCollapsed((current) => !current) + }, [onToggle]) + + const openCreateForm = useCallback(() => { + if (baseSectionCollapsed) { + if (onToggle) onToggle() + else setInternalCollapsed(false) + } + setIsCreating(true) + }, [baseSectionCollapsed, onToggle]) + + const closeCreateForm = useCallback(() => setIsCreating(false), []) + const toggleFolder = useCallback((path: string) => { + setManualExpanded((current) => ({ ...current, [path]: !current[path] })) + }, []) + + return { + closeCreateForm, + expanded, + handleToggleSection, + isCreating, + openCreateForm, + sectionCollapsed, + toggleFolder, + } +} diff --git a/src/components/sidebar/useSidebarTypeInteractions.ts b/src/components/sidebar/useSidebarTypeInteractions.ts new file mode 100644 index 00000000..b3cd890e --- /dev/null +++ b/src/components/sidebar/useSidebarTypeInteractions.ts @@ -0,0 +1,154 @@ +import { useCallback, useRef, useState } from 'react' +import type { VaultEntry } from '../../types' +import { applyCustomization, useOutsideClick } from './sidebarHooks' + +interface SidebarTypeGroup { + type: string + label: string +} + +interface SidebarTypeInteractionsInput { + allSectionGroups: SidebarTypeGroup[] + typeEntryMap: Record + onCustomizeType?: (typeName: string, icon: string, color: string) => void + onUpdateTypeTemplate?: (typeName: string, template: string) => void + onRenameSection?: (typeName: string, label: string) => void +} + +function useSidebarTypeState() { + const [customizeTarget, setCustomizeTarget] = useState(null) + const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) + const [contextMenuType, setContextMenuType] = useState(null) + const [renamingType, setRenamingType] = useState(null) + const [renameInitialValue, setRenameInitialValue] = useState('') + const [showCustomize, setShowCustomize] = useState(false) + + const contextMenuRef = useRef(null) + const popoverRef = useRef(null) + const customizeRef = useRef(null) + + const closeContextMenu = useCallback(() => { + setContextMenuPos(null) + setContextMenuType(null) + }, []) + + const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), []) + const closeCustomize = useCallback(() => setShowCustomize(false), []) + const cancelRename = useCallback(() => setRenamingType(null), []) + + useOutsideClick(customizeRef, showCustomize, closeCustomize) + useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu) + useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget) + + return { + cancelRename, + closeContextMenu, + closeCustomizeTarget, + contextMenuPos, + contextMenuRef, + contextMenuType, + customizeRef, + customizeTarget, + popoverRef, + renameInitialValue, + renamingType, + setContextMenuPos, + setContextMenuType, + setCustomizeTarget, + setRenameInitialValue, + setRenamingType, + setShowCustomize, + showCustomize, + } +} + +function useSidebarRenameCallbacks(params: { + allSectionGroups: SidebarTypeGroup[] + closeContextMenu: () => void + onRenameSection?: (typeName: string, label: string) => void + renamingType: string | null + setRenameInitialValue: React.Dispatch> + setRenamingType: React.Dispatch> +}) { + const { + allSectionGroups, + closeContextMenu, + onRenameSection, + renamingType, + setRenameInitialValue, + setRenamingType, + } = params + + const handleStartRename = useCallback((type: string) => { + closeContextMenu() + const group = allSectionGroups.find((sectionGroup) => sectionGroup.type === type) + setRenameInitialValue(group?.label ?? type) + setRenamingType(type) + }, [allSectionGroups, closeContextMenu, setRenameInitialValue, setRenamingType]) + + const handleRenameSubmit = useCallback((value: string) => { + if (renamingType) onRenameSection?.(renamingType, value) + setRenamingType(null) + }, [onRenameSection, renamingType, setRenamingType]) + + return { handleRenameSubmit, handleStartRename } +} + +export function useSidebarTypeInteractions({ + allSectionGroups, + typeEntryMap, + onCustomizeType, + onUpdateTypeTemplate, + onRenameSection, +}: SidebarTypeInteractionsInput) { + const state = useSidebarTypeState() + const renameCallbacks = useSidebarRenameCallbacks({ + allSectionGroups, + closeContextMenu: state.closeContextMenu, + onRenameSection, + renamingType: state.renamingType, + setRenameInitialValue: state.setRenameInitialValue, + setRenamingType: state.setRenamingType, + }) + + const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => { + event.preventDefault() + event.stopPropagation() + state.setContextMenuPos({ x: event.clientX, y: event.clientY }) + state.setContextMenuType(type) + }, [state]) + + const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { + applyCustomization(state.customizeTarget, typeEntryMap, onCustomizeType, prop, value) + }, [onCustomizeType, state.customizeTarget, typeEntryMap]) + + const handleChangeTemplate = useCallback((template: string) => { + if (state.customizeTarget) onUpdateTypeTemplate?.(state.customizeTarget, template) + }, [onUpdateTypeTemplate, state.customizeTarget]) + + const openCustomizeTarget = useCallback((type: string) => { + state.closeContextMenu() + state.setCustomizeTarget(type) + }, [state]) + + return { + closeCustomizeTarget: state.closeCustomizeTarget, + contextMenuPos: state.contextMenuPos, + contextMenuRef: state.contextMenuRef, + contextMenuType: state.contextMenuType, + customizeRef: state.customizeRef, + customizeTarget: state.customizeTarget, + handleChangeTemplate, + handleContextMenu, + handleCustomize, + handleRenameSubmit: renameCallbacks.handleRenameSubmit, + handleStartRename: renameCallbacks.handleStartRename, + openCustomizeTarget, + popoverRef: state.popoverRef, + cancelRename: state.cancelRename, + renameInitialValue: state.renameInitialValue, + renamingType: state.renamingType, + setShowCustomize: state.setShowCustomize, + showCustomize: state.showCustomize, + } +} diff --git a/src/hooks/commands/navigationCommands.ts b/src/hooks/commands/navigationCommands.ts index dce4851f..7d269f01 100644 --- a/src/hooks/commands/navigationCommands.ts +++ b/src/hooks/commands/navigationCommands.ts @@ -4,6 +4,9 @@ import type { SidebarSelection } from '../../types' interface NavigationCommandsConfig { onQuickOpen: () => void onSelect: (sel: SidebarSelection) => void + selection?: SidebarSelection + onRenameFolder?: () => void + onDeleteFolder?: () => void showInbox?: boolean onGoBack?: () => void onGoForward?: () => void @@ -11,9 +14,42 @@ interface NavigationCommandsConfig { canGoForward?: boolean } -export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] { - const { onQuickOpen, onSelect, showInbox = true, onGoBack, onGoForward, canGoBack, canGoForward } = config - const commands: CommandAction[] = [ +function buildFolderCommands( + folderSelected: boolean, + onRenameFolder?: () => void, + onDeleteFolder?: () => void, +): CommandAction[] { + return [ + { + id: 'rename-folder', + label: 'Rename Folder', + group: 'Navigation', + keywords: ['folder', 'directory', 'sidebar', 'rename'], + enabled: folderSelected && !!onRenameFolder, + execute: () => onRenameFolder?.(), + }, + { + id: 'delete-folder', + label: 'Delete Folder', + group: 'Navigation', + keywords: ['folder', 'directory', 'sidebar', 'delete', 'remove'], + enabled: folderSelected && !!onDeleteFolder, + execute: () => onDeleteFolder?.(), + }, + ] +} + +function buildBaseCommands(config: NavigationCommandsConfig): CommandAction[] { + const { + onQuickOpen, + onSelect, + onGoBack, + onGoForward, + canGoBack, + canGoForward, + } = config + + return [ { id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen }, { id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) }, { id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) }, @@ -22,15 +58,34 @@ export function buildNavigationCommands(config: NavigationCommandsConfig): Comma { id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘←', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() }, { id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘→', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() }, ] - if (showInbox) { - commands.splice(5, 0, { - id: 'go-inbox', - label: 'Go to Inbox', - group: 'Navigation', - keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], - enabled: true, - execute: () => onSelect({ kind: 'filter', filter: 'inbox' }), - }) - } +} + +function insertInboxCommand(commands: CommandAction[], showInbox: boolean, onSelect: (sel: SidebarSelection) => void) { + if (!showInbox) return commands + + commands.splice(5, 0, { + id: 'go-inbox', + label: 'Go to Inbox', + group: 'Navigation', + keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], + enabled: true, + execute: () => onSelect({ kind: 'filter', filter: 'inbox' }), + }) return commands } + +export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] { + const { + onSelect, + selection, + onRenameFolder, + onDeleteFolder, + showInbox = true, + } = config + const folderSelected = selection?.kind === 'folder' + const commands = [ + ...buildBaseCommands(config), + ...buildFolderCommands(folderSelected, onRenameFolder, onDeleteFolder), + ] + return insertInboxCommand(commands, showInbox, onSelect) +} diff --git a/src/hooks/folder-actions/folderActionUtils.ts b/src/hooks/folder-actions/folderActionUtils.ts new file mode 100644 index 00000000..3ca2f668 --- /dev/null +++ b/src/hooks/folder-actions/folderActionUtils.ts @@ -0,0 +1,204 @@ +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../../mock-tauri' +import type { SidebarSelection, VaultEntry } from '../../types' + +export interface FolderRenameResult { + old_path: string + new_path: string +} + +export interface FolderTab { + entry: VaultEntry + content: string +} + +export interface ConfirmFolderDeleteState { + path: string + title: string + message: string + confirmLabel: string +} + +export const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } + +function trimTrailingSlash(path: string): string { + return path.endsWith('/') ? path.slice(0, -1) : path +} + +export function folderAbsolutePath(params: { vaultPath: string; folderPath: string }): string { + const normalizedVaultPath = trimTrailingSlash(params.vaultPath) + const normalizedFolderPath = params.folderPath.replace(/^\/+/, '') + return normalizedFolderPath ? `${normalizedVaultPath}/${normalizedFolderPath}` : normalizedVaultPath +} + +export function isWithinPrefix(params: { path: string; prefix: string }): boolean { + return params.path === params.prefix || params.path.startsWith(`${params.prefix}/`) +} + +export function replaceFolderPrefix(params: { + path: string + oldPrefix: string + newPrefix: string +}): string { + if (!isWithinPrefix({ path: params.path, prefix: params.oldPrefix })) return params.path + return `${params.newPrefix}${params.path.slice(params.oldPrefix.length)}` +} + +export function replaceRelativeFolderPrefix(params: { + path: string + oldPrefix: string + newPrefix: string +}): string { + if (!isWithinPrefix({ path: params.path, prefix: params.oldPrefix })) return params.path + return `${params.newPrefix}${params.path.slice(params.oldPrefix.length)}` +} + +export function folderLabel(params: { folderPath: string }): string { + return params.folderPath.split('/').filter(Boolean).at(-1) ?? params.folderPath +} + +export async function invokeRenameFolder(params: { + vaultPath: string + folderPath: string + newName: string +}): Promise { + if (isTauri()) { + return invoke('rename_vault_folder', params) + } + return mockInvoke('rename_vault_folder', params) +} + +export async function invokeDeleteFolder(params: { vaultPath: string; folderPath: string }): Promise { + if (isTauri()) { + return invoke('delete_vault_folder', params) + } + return mockInvoke('delete_vault_folder', params) +} + +export function updateSelectionAfterFolderRename(params: { + refreshedEntries: VaultEntry[] + renameResult: FolderRenameResult + selection: SidebarSelection + setSelection: (selection: SidebarSelection) => void + vaultPath: string +}) { + const { + refreshedEntries, + renameResult, + selection, + setSelection, + vaultPath, + } = params + + if (selection.kind === 'folder') { + if (!isWithinPrefix({ path: selection.path, prefix: renameResult.old_path })) return + setSelection({ + kind: 'folder', + path: replaceRelativeFolderPrefix({ + path: selection.path, + oldPrefix: renameResult.old_path, + newPrefix: renameResult.new_path, + }), + }) + return + } + + if (selection.kind !== 'entity') return + + const oldPrefix = folderAbsolutePath({ vaultPath, folderPath: renameResult.old_path }) + if (!isWithinPrefix({ path: selection.entry.path, prefix: oldPrefix })) return + + const renamedPath = replaceFolderPrefix({ + path: selection.entry.path, + oldPrefix, + newPrefix: folderAbsolutePath({ vaultPath, folderPath: renameResult.new_path }), + }) + const refreshedEntry = refreshedEntries.find((entry) => entry.path === renamedPath) + setSelection(refreshedEntry ? { kind: 'entity', entry: refreshedEntry } : DEFAULT_SELECTION) +} + +export function updateTabsAfterFolderRename(params: { + activeTabPathRef: React.MutableRefObject + handleSwitchTab: (path: string) => void + refreshedEntries: VaultEntry[] + renameResult: FolderRenameResult + setTabs: React.Dispatch> + vaultPath: string +}) { + const { + activeTabPathRef, + handleSwitchTab, + refreshedEntries, + renameResult, + setTabs, + vaultPath, + } = params + const oldPrefix = folderAbsolutePath({ vaultPath, folderPath: renameResult.old_path }) + const newPrefix = folderAbsolutePath({ vaultPath, folderPath: renameResult.new_path }) + + setTabs((currentTabs) => currentTabs.map((tab) => { + if (!isWithinPrefix({ path: tab.entry.path, prefix: oldPrefix })) return tab + const nextPath = replaceFolderPrefix({ path: tab.entry.path, oldPrefix, newPrefix }) + const refreshedEntry = refreshedEntries.find((entry) => entry.path === nextPath) + return refreshedEntry ? { ...tab, entry: refreshedEntry } : { ...tab, entry: { ...tab.entry, path: nextPath } } + })) + + const activeTabPath = activeTabPathRef.current + if (!activeTabPath || !isWithinPrefix({ path: activeTabPath, prefix: oldPrefix })) return + handleSwitchTab(replaceFolderPrefix({ path: activeTabPath, oldPrefix, newPrefix })) +} + +export function clearDeletedFolderTabs(params: { + activeTabPathRef: React.MutableRefObject + closeAllTabs: () => void + folderPath: string + setTabs: React.Dispatch> + vaultPath: string +}) { + const { + activeTabPathRef, + closeAllTabs, + folderPath, + setTabs, + vaultPath, + } = params + const folderPrefix = folderAbsolutePath({ vaultPath, folderPath }) + const hasActiveTabInFolder = !!activeTabPathRef.current + && isWithinPrefix({ path: activeTabPathRef.current, prefix: folderPrefix }) + + if (hasActiveTabInFolder) { + closeAllTabs() + return + } + + setTabs((currentTabs) => currentTabs.filter((tab) => !isWithinPrefix({ path: tab.entry.path, prefix: folderPrefix }))) +} + +export function resetSelectionIfFolderDeleted(params: { + folderPath: string + refreshedEntries: VaultEntry[] + selection: SidebarSelection + setSelection: (selection: SidebarSelection) => void + vaultPath: string +}) { + const { + folderPath, + refreshedEntries, + selection, + setSelection, + vaultPath, + } = params + + if (selection.kind === 'folder' && isWithinPrefix({ path: selection.path, prefix: folderPath })) { + setSelection(DEFAULT_SELECTION) + return + } + + if (selection.kind !== 'entity') return + + const folderPrefix = folderAbsolutePath({ vaultPath, folderPath }) + if (!isWithinPrefix({ path: selection.entry.path, prefix: folderPrefix })) return + + const replacement = refreshedEntries.find((entry) => entry.path === selection.entry.path) + setSelection(replacement ? { kind: 'entity', entry: replacement } : DEFAULT_SELECTION) +} diff --git a/src/hooks/folder-actions/useFolderDelete.ts b/src/hooks/folder-actions/useFolderDelete.ts new file mode 100644 index 00000000..886d0c7b --- /dev/null +++ b/src/hooks/folder-actions/useFolderDelete.ts @@ -0,0 +1,92 @@ +import { useCallback, useState } from 'react' +import type { SidebarSelection, VaultEntry } from '../../types' +import { + clearDeletedFolderTabs, + type ConfirmFolderDeleteState, + type FolderTab, + folderLabel, + invokeDeleteFolder, + resetSelectionIfFolderDeleted, +} from './folderActionUtils' + +interface UseFolderDeleteInput { + activeTabPathRef: React.MutableRefObject + clearFolderRename: () => void + closeAllTabs: () => void + reloadFolders: () => Promise + reloadVault: () => Promise + selection: SidebarSelection + setSelection: (selection: SidebarSelection) => void + setTabs: React.Dispatch> + setToastMessage: (message: string | null) => void + vaultPath: string +} + +export function useFolderDelete({ + activeTabPathRef, + clearFolderRename, + closeAllTabs, + reloadFolders, + reloadVault, + selection, + setSelection, + setTabs, + setToastMessage, + vaultPath, +}: UseFolderDeleteInput) { + const [confirmDeleteFolder, setConfirmDeleteFolder] = useState(null) + + const requestDeleteFolder = useCallback((folderPath: string) => { + clearFolderRename() + setConfirmDeleteFolder({ + path: folderPath, + title: `Delete "${folderLabel({ folderPath })}" and everything inside it?`, + message: 'This permanently removes the folder, all nested folders, and every note or file inside it. This cannot be undone.', + confirmLabel: 'Delete folder', + }) + }, [clearFolderRename]) + + const cancelDeleteFolder = useCallback(() => setConfirmDeleteFolder(null), []) + + const confirmDeleteSelectedFolder = useCallback(async () => { + if (!confirmDeleteFolder) return + + const folderPath = confirmDeleteFolder.path + try { + setConfirmDeleteFolder(null) + await invokeDeleteFolder({ vaultPath, folderPath }) + clearDeletedFolderTabs({ + activeTabPathRef, + closeAllTabs, + folderPath, + setTabs, + vaultPath, + }) + await reloadFolders() + const refreshedEntries = await reloadVault() + resetSelectionIfFolderDeleted({ + folderPath, + refreshedEntries, + selection, + setSelection, + vaultPath, + }) + setToastMessage(`Deleted folder "${folderLabel({ folderPath })}"`) + } catch (error) { + setToastMessage(`Failed to delete folder: ${error}`) + } + }, [activeTabPathRef, closeAllTabs, confirmDeleteFolder, reloadFolders, reloadVault, selection, setSelection, setTabs, setToastMessage, vaultPath]) + + const deleteSelectedFolder = useCallback(() => { + if (selection.kind !== 'folder') return + requestDeleteFolder(selection.path) + }, [requestDeleteFolder, selection]) + + return { + cancelDeleteFolder, + confirmDeleteFolder, + confirmDeleteSelectedFolder, + deleteSelectedFolder, + requestDeleteFolder, + } +} diff --git a/src/hooks/folder-actions/useFolderRename.ts b/src/hooks/folder-actions/useFolderRename.ts new file mode 100644 index 00000000..4a455d93 --- /dev/null +++ b/src/hooks/folder-actions/useFolderRename.ts @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react' +import type { SidebarSelection, VaultEntry } from '../../types' +import { + folderLabel, + invokeRenameFolder, + type FolderTab, + updateSelectionAfterFolderRename, + updateTabsAfterFolderRename, +} from './folderActionUtils' + +interface UseFolderRenameInput { + activeTabPathRef: React.MutableRefObject + handleSwitchTab: (path: string) => void + reloadFolders: () => Promise + reloadVault: () => Promise + selection: SidebarSelection + setSelection: (selection: SidebarSelection) => void + setTabs: React.Dispatch> + setToastMessage: (message: string | null) => void + vaultPath: string +} + +export function useFolderRename({ + activeTabPathRef, + handleSwitchTab, + reloadFolders, + reloadVault, + selection, + setSelection, + setTabs, + setToastMessage, + vaultPath, +}: UseFolderRenameInput) { + const [renamingFolderPath, setRenamingFolderPath] = useState(null) + + const cancelFolderRename = useCallback(() => setRenamingFolderPath(null), []) + const startFolderRename = useCallback((folderPath: string) => setRenamingFolderPath(folderPath), []) + + const renameFolder = useCallback(async (folderPath: string, nextName: string) => { + const trimmedName = nextName.trim() + if (trimmedName === folderLabel({ folderPath })) { + setRenamingFolderPath(null) + return true + } + + try { + const renameResult = await invokeRenameFolder({ vaultPath, folderPath, newName: trimmedName }) + setRenamingFolderPath(null) + await reloadFolders() + const refreshedEntries = await reloadVault() + updateTabsAfterFolderRename({ + activeTabPathRef, + handleSwitchTab, + refreshedEntries, + renameResult, + setTabs, + vaultPath, + }) + updateSelectionAfterFolderRename({ + refreshedEntries, + renameResult, + selection, + setSelection, + vaultPath, + }) + setToastMessage(`Renamed folder to "${trimmedName}"`) + return true + } catch (error) { + setToastMessage(`Failed to rename folder: ${error}`) + return false + } + }, [activeTabPathRef, handleSwitchTab, reloadFolders, reloadVault, selection, setSelection, setTabs, setToastMessage, vaultPath]) + + const renameSelectedFolder = useCallback(() => { + if (selection.kind !== 'folder') return + startFolderRename(selection.path) + }, [selection, startFolderRename]) + + return { + cancelFolderRename, + renameFolder, + renameSelectedFolder, + renamingFolderPath, + startFolderRename, + } +} diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 9ce1638e..48ec4a94 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -44,6 +44,8 @@ interface AppCommandsConfig { onZoomReset: () => void zoomLevel: number onSelect: (sel: SidebarSelection) => void + onRenameFolder?: () => void + onDeleteFolder?: () => void showInbox?: boolean onReplaceActiveTab: (entry: VaultEntry) => void onSelectNote: (entry: VaultEntry) => void @@ -100,6 +102,8 @@ type CommandRegistrySelectionState = Pick< | 'onZoomReset' | 'zoomLevel' | 'onSelect' + | 'onRenameFolder' + | 'onDeleteFolder' | 'showInbox' | 'onGoBack' | 'onGoForward' @@ -334,6 +338,8 @@ function createCommandRegistrySelectionConfig( onZoomReset: config.onZoomReset, zoomLevel: config.zoomLevel, onSelect: config.onSelect, + onRenameFolder: config.onRenameFolder, + onDeleteFolder: config.onDeleteFolder, showInbox: config.showInbox, onGoBack: config.onGoBack, onGoForward: config.onGoForward, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 6dae1c1b..1554a99b 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -245,6 +245,47 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'go-inbox')).toBeUndefined() }) + it('enables folder commands when a folder is selected', () => { + const config = makeConfig({ + selection: { kind: 'folder', path: 'projects' }, + onRenameFolder: vi.fn(), + onDeleteFolder: vi.fn(), + }) + const { result } = renderHook(() => useCommandRegistry(config)) + + expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(true) + expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(true) + }) + + it('disables folder commands outside folder selection', () => { + const config = makeConfig({ + selection: { kind: 'filter', filter: 'all' }, + onRenameFolder: vi.fn(), + onDeleteFolder: vi.fn(), + }) + const { result } = renderHook(() => useCommandRegistry(config)) + + expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(false) + expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(false) + }) + + it('executes folder command callbacks', () => { + const onRenameFolder = vi.fn() + const onDeleteFolder = vi.fn() + const config = makeConfig({ + selection: { kind: 'folder', path: 'projects' }, + onRenameFolder, + onDeleteFolder, + }) + const { result } = renderHook(() => useCommandRegistry(config)) + + findCommand(result.current, 'rename-folder')!.execute() + findCommand(result.current, 'delete-folder')!.execute() + + expect(onRenameFolder).toHaveBeenCalledTimes(1) + expect(onDeleteFolder).toHaveBeenCalledTimes(1) + }) + it('omits the removed daily-note command', () => { const config = makeConfig() const { result } = renderHook(() => useCommandRegistry(config)) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 2fe45050..dbc96e14 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -77,6 +77,8 @@ interface CommandRegistryConfig { onZoomReset: () => void zoomLevel: number onSelect: (sel: SidebarSelection) => void + onRenameFolder?: () => void + onDeleteFolder?: () => void showInbox?: boolean onGoBack?: () => void onGoForward?: () => void @@ -99,7 +101,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault, activeNoteModified, onZoomIn, onZoomOut, onZoomReset, zoomLevel, - onSelect, + onSelect, onRenameFolder, onDeleteFolder, showInbox, onGoBack, onGoForward, canGoBack, canGoForward, onCheckForUpdates, onCreateType, @@ -132,7 +134,18 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries]) return useMemo(() => [ - ...buildNavigationCommands({ onQuickOpen, onSelect, showInbox, onGoBack, onGoForward, canGoBack, canGoForward }), + ...buildNavigationCommands({ + onQuickOpen, + onSelect, + selection, + onRenameFolder, + onDeleteFolder, + showInbox, + onGoBack, + onGoForward, + canGoBack, + canGoForward, + }), ...buildNoteCommands({ hasActiveNote, activeTabPath, isArchived, onCreateNote, onCreateType, onSave, @@ -179,7 +192,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote, onCheckForUpdates, onZoomIn, onZoomOut, onZoomReset, zoomLevel, - onSelect, + onSelect, onRenameFolder, onDeleteFolder, showInbox, onGoBack, onGoForward, canGoBack, canGoForward, vaultTypes, @@ -189,6 +202,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, + selection, onOpenInNewWindow, onToggleFavorite, isFavorite, onToggleOrganized, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, onRestoreDeletedNote, canRestoreDeletedNote, activeEntry, diff --git a/src/hooks/useFolderActions.test.ts b/src/hooks/useFolderActions.test.ts new file mode 100644 index 00000000..0384a302 --- /dev/null +++ b/src/hooks/useFolderActions.test.ts @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react' +import { renderHook, act } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SidebarSelection, VaultEntry } from '../types' +import { useFolderActions } from './useFolderActions' + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: vi.fn(), +})) + +const { mockInvoke } = await import('../mock-tauri') +const mockInvokeFn = mockInvoke as ReturnType + +const folderEntry: VaultEntry = { + path: '/vault/projects/note.md', + filename: 'note.md', + title: 'Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: null, + createdAt: null, + fileSize: 10, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, +} + +function renderFolderActions({ + initialSelection, + initialTabs = [{ entry: folderEntry, content: '# Note' }], + reloadVault, + reloadFolders, + setToastMessage, +}: { + initialSelection: SidebarSelection + initialTabs?: Array<{ entry: VaultEntry; content: string }> + reloadVault: ReturnType + reloadFolders: ReturnType + setToastMessage: ReturnType +}) { + return renderHook(() => { + const [selection, setSelection] = useState(initialSelection) + const [tabs, setTabs] = useState(initialTabs) + const [activeTabPath, setActiveTabPath] = useState(initialTabs[0]?.entry.path ?? null) + const activeTabPathRef = useRef(activeTabPath) + + useEffect(() => { + activeTabPathRef.current = activeTabPath + }, [activeTabPath]) + + const actions = useFolderActions({ + vaultPath: '/vault', + selection, + setSelection, + setTabs, + activeTabPathRef, + handleSwitchTab: setActiveTabPath, + closeAllTabs: () => { + setTabs([]) + setActiveTabPath(null) + }, + reloadVault, + reloadFolders, + setToastMessage, + }) + + return { actions, selection, tabs, activeTabPath } + }) +} + +describe('useFolderActions', () => { + let reloadVault: ReturnType + let reloadFolders: ReturnType + let setToastMessage: ReturnType + + beforeEach(() => { + mockInvokeFn.mockReset() + reloadVault = vi.fn() + reloadFolders = vi.fn().mockResolvedValue([]) + setToastMessage = vi.fn() + }) + + it('renames a selected folder and updates selection plus active tab path', async () => { + const renamedEntry = { ...folderEntry, path: '/vault/work/note.md' } + reloadVault.mockResolvedValue([renamedEntry]) + mockInvokeFn.mockResolvedValue({ old_path: 'projects', new_path: 'work' }) + + const { result } = renderFolderActions({ + initialSelection: { kind: 'folder', path: 'projects' }, + reloadVault, + reloadFolders, + setToastMessage, + }) + + await act(async () => { + await result.current.actions.renameFolder('projects', 'work') + }) + + expect(result.current.selection).toEqual({ kind: 'folder', path: 'work' }) + expect(result.current.tabs[0]?.entry.path).toBe('/vault/work/note.md') + expect(result.current.activeTabPath).toBe('/vault/work/note.md') + expect(setToastMessage).toHaveBeenCalledWith('Renamed folder to "work"') + }) + + it('deletes a selected folder and clears the active note gracefully', async () => { + reloadVault.mockResolvedValue([]) + mockInvokeFn.mockResolvedValue('projects') + + const { result } = renderFolderActions({ + initialSelection: { kind: 'folder', path: 'projects' }, + reloadVault, + reloadFolders, + setToastMessage, + }) + + act(() => { + result.current.actions.requestDeleteFolder('projects') + }) + + await act(async () => { + await result.current.actions.confirmDeleteSelectedFolder() + }) + + expect(result.current.selection).toEqual({ kind: 'filter', filter: 'all' }) + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(setToastMessage).toHaveBeenCalledWith('Deleted folder "projects"') + }) +}) diff --git a/src/hooks/useFolderActions.ts b/src/hooks/useFolderActions.ts new file mode 100644 index 00000000..4ec427ff --- /dev/null +++ b/src/hooks/useFolderActions.ts @@ -0,0 +1,59 @@ +import type { FolderNode, SidebarSelection, VaultEntry } from '../types' +import type { FolderTab } from './folder-actions/folderActionUtils' +import { useFolderDelete } from './folder-actions/useFolderDelete' +import { useFolderRename } from './folder-actions/useFolderRename' + +interface UseFolderActionsInput { + vaultPath: string + selection: SidebarSelection + setSelection: (selection: SidebarSelection) => void + setTabs: React.Dispatch> + activeTabPathRef: React.MutableRefObject + handleSwitchTab: (path: string) => void + closeAllTabs: () => void + reloadVault: () => Promise + reloadFolders: () => Promise + setToastMessage: (message: string | null) => void +} + +export function useFolderActions({ + vaultPath, + selection, + setSelection, + setTabs, + activeTabPathRef, + handleSwitchTab, + closeAllTabs, + reloadVault, + reloadFolders, + setToastMessage, +}: UseFolderActionsInput) { + const renameActions = useFolderRename({ + activeTabPathRef, + handleSwitchTab, + reloadFolders, + reloadVault, + selection, + setSelection, + setTabs, + setToastMessage, + vaultPath, + }) + const deleteActions = useFolderDelete({ + activeTabPathRef, + clearFolderRename: renameActions.cancelFolderRename, + closeAllTabs, + reloadFolders, + reloadVault, + selection, + setSelection, + setTabs, + setToastMessage, + vaultPath, + }) + + return { + ...renameActions, + ...deleteActions, + } +}