feat: retarget notes to types and folders

This commit is contained in:
lucaronin
2026-04-22 23:56:00 +02:00
parent cea71a4fd9
commit 2c9361d704
31 changed files with 2067 additions and 382 deletions

View File

@@ -153,7 +153,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
└── type/ ← type definition documents
```
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
@@ -270,6 +270,7 @@ type SidebarSelection =
- `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.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- 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.

View File

@@ -176,7 +176,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **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/`.
- **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. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. 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).
@@ -586,7 +586,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `rename.rs` | `rename_note` / `rename_note_filename` — stage crash-safe file renames, update `title` frontmatter, recover unfinished rename transactions, and report backlink rewrite failures |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
@@ -620,6 +620,7 @@ 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` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based 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 |
@@ -737,7 +738,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming with wikilink update |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
@@ -768,7 +770,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 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.
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. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:

View File

@@ -1,7 +1,10 @@
use crate::commands::expand_tilde;
use crate::vault::{self, DetectedRename, RenameResult};
use std::path::Path;
use super::boundary::with_existing_path_in_requested_vault;
use super::boundary::{
with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
};
#[tauri::command]
pub fn rename_note(
@@ -39,6 +42,34 @@ pub fn rename_note_filename(
)
}
#[tauri::command]
pub fn move_note_to_folder(
vault_path: String,
old_path: String,
folder_path: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(&vault_path, &old_path, |requested_root, validated_path| {
let trimmed_folder_path = folder_path.trim();
if trimmed_folder_path.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let folder_absolute_path = Path::new(requested_root).join(trimmed_folder_path);
with_validated_path(
folder_absolute_path.to_string_lossy().as_ref(),
Some(&vault_path),
ValidatedPathMode::Existing,
|validated_folder_path| {
let validated_folder = Path::new(validated_folder_path);
if !validated_folder.is_dir() {
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
}
vault::move_note_to_folder(requested_root, validated_path, validated_folder_path)
},
)
})
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,

View File

@@ -202,6 +202,7 @@ macro_rules! app_invoke_handler {
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::move_note_to_folder,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,

View File

@@ -26,7 +26,8 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note,
rename_note_filename,
update_wikilinks_for_renames, DetectedRename, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};

View File

@@ -385,6 +385,57 @@ pub fn rename_note_filename(
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Move a note into a different folder while preserving its filename and content.
pub fn move_note_to_folder(
vault_path: &str,
old_path: &str,
destination_folder_path: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
let destination_dir = Path::new(destination_folder_path);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
if !destination_dir.exists() {
return Err(format!(
"Folder does not exist: {}",
destination_folder_path
));
}
if !destination_dir.is_dir() {
return Err(format!(
"Folder is not a directory: {}",
destination_folder_path
));
}
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_file = destination_dir.join(&old_filename);
if new_file == old_file {
return Ok(unchanged_result(old_path));
}
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
fn is_untitled_filename(filename: &str) -> bool {
let stem = filename.strip_suffix(".md").unwrap_or(filename);
@@ -931,6 +982,68 @@ mod tests {
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_move_note_to_folder_preserves_filename_and_updates_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"projects/weekly-review.md",
"---\ntitle: Weekly Review\n---\n# Weekly Review\nBody\n",
);
create_test_file(vault, "areas/linked.md", "Reference [[projects/weekly-review]]\n");
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
)
.expect("move should succeed");
assert!(result.new_path.ends_with("areas/weekly-review.md"));
assert!(!vault.join("projects/weekly-review.md").exists());
assert!(vault.join("areas/weekly-review.md").exists());
assert_eq!(
fs::read_to_string(vault.join("areas/linked.md")).unwrap(),
"Reference [[areas/weekly-review]]\n"
);
}
#[test]
fn test_move_note_to_folder_noop_when_destination_matches_current_parent() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
let source = vault.join("projects/weekly-review.md");
let result = move_note_to_folder(
vault.to_str().unwrap(),
source.to_str().unwrap(),
vault.join("projects").to_str().unwrap(),
)
.expect("move should noop");
assert_eq!(result.new_path, source.to_string_lossy());
assert!(source.exists());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_move_note_to_folder_rejects_existing_destination() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
);
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.

View File

@@ -20,6 +20,8 @@ import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { McpSetupDialog } from './components/McpSetupDialog'
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
@@ -63,6 +65,7 @@ import { useFolderActions } from './hooks/useFolderActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
@@ -348,44 +351,6 @@ function App() {
return true
}, [handleSetSelection])
const shouldBlockNeighborhoodEscape = (
dialogs.showCreateTypeDialog
|| dialogs.showQuickOpen
|| dialogs.showCommandPalette
|| dialogs.showAIChat
|| dialogs.showSettings
|| dialogs.showCloneVault
|| dialogs.showSearch
|| dialogs.showConflictResolver
|| dialogs.showCreateViewDialog
|| showFeedback
)
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
const activeElement = document.activeElement
if (isEditorEscapeTarget(activeElement)) {
event.preventDefault()
activeElement.blur()
requestAnimationFrame(() => {
focusNoteListContainer(document)
})
return
}
if (isEditableElement(activeElement)) return
if (handleNeighborhoodHistoryBack()) {
event.preventDefault()
}
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
updateConfig('inbox', {
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
@@ -1143,10 +1108,60 @@ function App() {
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
?? null
}, [notes.activeTabPath, notes.tabs, vault.entries])
const noteRetargetingUi = useNoteRetargetingUi({
activeEntry: activeCommandEntry,
activeNoteBlocked: !!activeDeletedFile,
entries: vault.entries,
folders: vault.folders,
selection: effectiveSelection,
setSelection: handleSetSelection,
setToastMessage,
vaultPath: resolvedPath,
updateFrontmatter: notes.handleUpdateFrontmatter,
moveNoteToFolder: notes.handleMoveNoteToFolder,
})
const canToggleRichEditor = !!activeCommandEntry
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const shouldBlockNeighborhoodEscape = (
dialogs.showCreateTypeDialog
|| dialogs.showQuickOpen
|| dialogs.showCommandPalette
|| dialogs.showAIChat
|| dialogs.showSettings
|| dialogs.showCloneVault
|| dialogs.showSearch
|| dialogs.showConflictResolver
|| dialogs.showCreateViewDialog
|| noteRetargetingUi.isDialogOpen
|| showFeedback
)
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
const activeElement = document.activeElement
if (isEditorEscapeTarget(activeElement)) {
event.preventDefault()
activeElement.blur()
requestAnimationFrame(() => {
focusNoteListContainer(document)
})
return
}
if (isEditableElement(activeElement)) return
if (handleNeighborhoodHistoryBack()) {
event.preventDefault()
}
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
const noteListColumnsLabel = useMemo(() => {
if (effectiveSelection.kind === 'view') {
@@ -1158,6 +1173,45 @@ function App() {
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
)
const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [])
const toggleRawEditorCommand = useMemo(
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
)
const removeActiveVaultCommand = useCallback(() => {
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
}, [vaultSwitcher])
const restoreVaultAiGuidanceCommand = useCallback(() => {
void restoreVaultAiGuidance()
}, [restoreVaultAiGuidance])
const changeNoteTypeCommand = useMemo(
() => noteRetargetingUi.canChangeActiveNoteType ? noteRetargetingUi.openChangeNoteTypeDialog : undefined,
[noteRetargetingUi.canChangeActiveNoteType, noteRetargetingUi.openChangeNoteTypeDialog],
)
const moveNoteToFolderCommand = useMemo(
() => noteRetargetingUi.canMoveActiveNoteToFolder ? noteRetargetingUi.openMoveNoteToFolderDialog : undefined,
[noteRetargetingUi.canMoveActiveNoteToFolder, noteRetargetingUi.openMoveNoteToFolderDialog],
)
const activeNoteHasIcon = useMemo(() => {
const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath)
return hasNoteIconValue(entry?.icon)
}, [notes.activeTabPath, vault.entries])
const toggleOrganizedCommand = explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined
const canCustomizeNoteListColumns = useMemo(() => (
effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
)
), [effectiveSelection, explicitOrganizationEnabled])
const restoreDeletedNoteCommand = useMemo(
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
[activeDeletedFile, handleDiscardFile],
)
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -1165,7 +1219,7 @@ function App() {
visibleNotesRef,
multiSelectionCommandRef,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
activeNoteModified,
selection: effectiveSelection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
@@ -1181,8 +1235,8 @@ function App() {
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -1198,7 +1252,7 @@ function App() {
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRemoveActiveVault: removeActiveVaultCommand,
onRestoreGettingStarted: cloneGettingStartedVault,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
@@ -1207,7 +1261,7 @@ function App() {
onOpenAiAgents: dialogs.openSettings,
aiAgentsStatus,
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
onRestoreVaultAiGuidance: restoreVaultAiGuidanceCommand,
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
@@ -1216,23 +1270,19 @@ function App() {
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
onRemoveNoteIcon: handleRemoveNoteIconCommand,
activeNoteHasIcon: (() => {
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
return hasNoteIconValue(ae?.icon)
})(),
onChangeNoteType: changeNoteTypeCommand,
onMoveNoteToFolder: moveNoteToFolderCommand,
canMoveNoteToFolder: noteRetargetingUi.canMoveActiveNoteToFolder,
activeNoteHasIcon,
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
onToggleOrganized: toggleOrganizedCommand,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
),
canCustomizeNoteListColumns,
noteListColumnsLabel,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
onRestoreDeletedNote: restoreDeletedNoteCommand,
canRestoreDeletedNote: !!activeDeletedFile,
})
@@ -1328,146 +1378,157 @@ function App() {
}
return (
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<NoteRetargetingDialogs
dialogState={noteRetargetingUi.dialogState}
dialogEntry={noteRetargetingUi.dialogEntry}
typeOptions={noteRetargetingUi.typeOptions}
folderOptions={noteRetargetingUi.folderOptions}
onClose={noteRetargetingUi.closeDialog}
onSelectType={noteRetargetingUi.selectType}
onSelectFolder={noteRetargetingUi.selectFolder}
/>
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
{folderActions.confirmDeleteFolder && (
<ConfirmDeleteDialog
open={true}
title={folderActions.confirmDeleteFolder.title}
message={folderActions.confirmDeleteFolder.message}
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
onConfirm={folderActions.confirmDeleteSelectedFolder}
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
{folderActions.confirmDeleteFolder && (
<ConfirmDeleteDialog
open={true}
title={folderActions.confirmDeleteFolder.title}
message={folderActions.confirmDeleteFolder.message}
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
onConfirm={folderActions.confirmDeleteSelectedFolder}
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
</NoteRetargetingProvider>
)
}

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FolderTree } from './FolderTree'
import type { FolderNode, SidebarSelection } from '../types'
@@ -116,6 +116,18 @@ describe('FolderTree', () => {
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
it('does not reserve a disclosure slot for leaf folders', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement
const parentRowContainer = screen.getByTestId('folder-row:projects').parentElement
expect(leafRowContainer).not.toBeNull()
expect(parentRowContainer).not.toBeNull()
expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(2)
})
it('shows the rename input when a folder is being renamed', () => {
render(
<FolderTree

View File

@@ -45,12 +45,12 @@ export function FolderNameInput({
}, [onSubmit, value])
return (
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
<Folder size={18} className="shrink-0 text-muted-foreground" />
<div className="flex items-center gap-2 rounded" style={{ padding: '6px 8px', borderRadius: 4 }}>
<Folder size={16} className="size-4 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
aria-label={ariaLabel}
className="h-7 flex-1 rounded-sm px-2 text-[13px]"
className="h-auto min-h-0 flex-1 rounded-sm px-2 py-[3px] text-[13px] font-medium"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined}

View File

@@ -10,6 +10,8 @@ import {
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode, SidebarSelection } from '../../types'
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
import { FolderNameInput } from './FolderNameInput'
interface FolderTreeRowProps {
@@ -81,12 +83,12 @@ function FolderItemRow({
return (
<div
className={cn(
'group relative flex items-center gap-1 rounded-[5px] transition-colors',
'group relative flex items-center gap-1 rounded transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ paddingLeft: indentation }}
style={{ paddingLeft: indentation, paddingRight: 8, paddingTop: 6, paddingBottom: 6, borderRadius: 4 }}
onContextMenu={(event) => {
onSelect()
onOpenMenu(node, event)
@@ -152,24 +154,21 @@ function FolderToggleButton({
isExpanded: boolean
onToggle: () => void
}) {
if (!hasChildren) return null
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
disabled={!hasChildren}
onClick={(event) => {
event.stopPropagation()
if (hasChildren) onToggle()
onToggle()
}}
aria-label={hasChildren ? expandLabel : undefined}
aria-label={expandLabel}
>
{hasChildren ? (
isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />
) : (
<span className="block h-3 w-3" />
)}
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</Button>
)
}
@@ -231,9 +230,9 @@ function FolderSelectButton({
type="button"
variant="ghost"
className={cn(
'h-7 flex-1 justify-start gap-2 rounded-[5px] px-2 py-0 text-left text-[13px]',
'h-auto flex-1 justify-start gap-2 rounded p-0 text-left text-[13px] font-medium hover:bg-transparent',
hasActions && 'pr-12',
isSelected ? 'font-medium text-primary hover:text-primary' : 'hover:text-foreground',
isSelected ? 'text-primary hover:text-primary' : 'text-foreground hover:text-foreground',
)}
title={node.path}
onClick={onSelect}
@@ -244,9 +243,9 @@ function FolderSelectButton({
data-testid={`folder-row:${node.path}`}
>
{isSelected || isExpanded ? (
<FolderOpen size={18} weight="fill" className="shrink-0" />
<FolderOpen size={16} weight="fill" className="size-4 shrink-0" />
) : (
<Folder size={18} className="shrink-0" />
<Folder size={16} className="size-4 shrink-0" />
)}
<span className="truncate">{node.name}</span>
</Button>
@@ -315,10 +314,24 @@ export const FolderTreeRow = memo(function FolderTreeRow({
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 indentation = 16 + depth * 16
const noteRetargeting = useNoteRetargetingContext()
const selectFolder = useCallback(() => {
onSelect({ kind: 'folder', path: node.path })
}, [node.path, onSelect])
const row = (
<FolderItemRow
indentation={indentation}
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={onOpenMenu}
onSelect={selectFolder}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
/>
)
return (
<>
@@ -330,17 +343,14 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onRenameFolder={onRenameFolder}
/>
) : (
<FolderItemRow
indentation={indentation}
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={onOpenMenu}
onSelect={selectFolder}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
/>
noteRetargeting ? (
<NoteDropTarget
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnFolder(notePath, node.path)}
onDropNote={(notePath) => noteRetargeting.dropNoteOnFolder(notePath, node.path)}
>
{row}
</NoteDropTarget>
) : row
)}
<FolderChildren
depth={depth}

View File

@@ -11,6 +11,7 @@ import type {
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
import { DraggableNoteItem } from '../note-retargeting/DraggableNoteItem'
import { prefetchNoteContent } from '../../hooks/useTabManagement'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
@@ -349,21 +350,39 @@ function useRenderItem({
const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined
return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => (
<NoteItem
key={entry.path}
entry={entry}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
changeStatus={getChangeStatus(entry.path)}
typeEntryMap={typeEntryMap}
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent}
onContextMenu={contextMenuHandler}
/>
isDeletedNoteEntry(entry) ? (
<NoteItem
key={entry.path}
entry={entry}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
changeStatus={getChangeStatus(entry.path)}
typeEntryMap={typeEntryMap}
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onContextMenu={contextMenuHandler}
/>
) : (
<DraggableNoteItem key={entry.path} notePath={entry.path}>
<NoteItem
entry={entry}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
changeStatus={getChangeStatus(entry.path)}
typeEntryMap={typeEntryMap}
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onPrefetch={prefetchNoteContent}
onContextMenu={contextMenuHandler}
/>
</DraggableNoteItem>
)
), [
contextMenuHandler,
displayPropsOverride,
@@ -581,18 +600,22 @@ export function useNoteListModel({
interaction.noteListKeyboard.focusList()
})
}
const {
isPanelActive: isNoteListSearchActive,
toggleSearchShortcut,
} = interaction.noteListKeyboard
useEffect(() => {
dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive)
dispatchNoteListSearchAvailability(isNoteListSearchActive)
return () => dispatchNoteListSearchAvailability(false)
}, [interaction.noteListKeyboard.isPanelActive])
}, [isNoteListSearchActive])
useEffect(() => {
return addNoteListSearchToggleListener(() => {
if (!interaction.noteListKeyboard.isPanelActive) return
interaction.noteListKeyboard.toggleSearchShortcut()
if (!isNoteListSearchActive) return
toggleSearchShortcut()
})
}, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut])
}, [isNoteListSearchActive, toggleSearchShortcut])
return buildNoteListLayoutModel({
selection,

View File

@@ -0,0 +1,24 @@
import type { DragEvent, ReactNode } from 'react'
import { writeDraggedNotePath } from './noteDragData'
interface DraggableNoteItemProps {
notePath: string
children: ReactNode
}
export function DraggableNoteItem({ notePath, children }: DraggableNoteItemProps) {
const handleDragStart = (event: DragEvent<HTMLDivElement>) => {
writeDraggedNotePath(event, notePath)
}
return (
<div
draggable
data-testid={`draggable-note:${notePath}`}
className="cursor-grab active:cursor-grabbing"
onDragStart={handleDragStart}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useRef, useState, type DragEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { readDraggedNotePath } from './noteDragData'
type DropState = 'idle' | 'valid' | 'invalid'
interface NoteDropTargetProps {
children: ReactNode
className?: string
validClassName?: string
invalidClassName?: string
canAcceptNotePath: (notePath: string) => boolean
onDropNote: (notePath: string) => void | Promise<void>
}
export function NoteDropTarget({
children,
className,
validClassName = 'ring-1 ring-primary/40 bg-primary/10',
invalidClassName = 'ring-1 ring-destructive/35 bg-destructive/5',
canAcceptNotePath,
onDropNote,
}: NoteDropTargetProps) {
const [dropState, setDropState] = useState<DropState>('idle')
const dragDepthRef = useRef(0)
const updateDropState = (event: DragEvent<HTMLDivElement>): string | null => {
const notePath = readDraggedNotePath(event.dataTransfer)
if (!notePath) return null
const isValid = canAcceptNotePath(notePath)
event.preventDefault()
event.dataTransfer.dropEffect = isValid ? 'move' : 'none'
setDropState(isValid ? 'valid' : 'invalid')
return notePath
}
const resetDropState = () => {
dragDepthRef.current = 0
setDropState('idle')
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
const notePath = readDraggedNotePath(event.dataTransfer)
if (!notePath) return
dragDepthRef.current += 1
updateDropState(event)
}
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
updateDropState(event)
}
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
const notePath = readDraggedNotePath(event.dataTransfer)
if (!notePath) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0 && !event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDropState('idle')
}
}
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
const notePath = updateDropState(event)
if (!notePath) return
const isValid = canAcceptNotePath(notePath)
resetDropState()
if (!isValid) return
void onDropNote(notePath)
}
return (
<div
className={cn(
'rounded-[5px] transition-colors',
className,
dropState === 'valid' && validClassName,
dropState === 'invalid' && invalidClassName,
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import {
noteRetargetingContext,
type NoteRetargetingContextValue,
} from './noteRetargetingContext'
export function NoteRetargetingProvider({
children,
value,
}: {
children: ReactNode
value: NoteRetargetingContextValue | null
}) {
return (
<noteRetargetingContext.Provider value={value}>
{children}
</noteRetargetingContext.Provider>
)
}

View File

@@ -0,0 +1,62 @@
import type { VaultEntry } from '../../types'
import type { RetargetOption } from './RetargetNoteDialog'
import { RetargetNoteDialog } from './RetargetNoteDialog'
interface NoteRetargetingDialogsProps {
dialogState: { kind: 'type' | 'folder'; notePath: string } | null
dialogEntry: VaultEntry | null
typeOptions: RetargetOption[]
folderOptions: RetargetOption[]
onClose: () => void
onSelectType: (type: string) => boolean | Promise<boolean>
onSelectFolder: (folderPath: string) => boolean | Promise<boolean>
}
function typeDialogDescription(entry: VaultEntry | null): string {
return entry
? `Set a new type for "${entry.title}".`
: 'Select a type for the active note.'
}
function folderDialogDescription(entry: VaultEntry | null): string {
return entry
? `Choose a destination folder for "${entry.title}".`
: 'Select a destination folder for the active note.'
}
export function NoteRetargetingDialogs({
dialogState,
dialogEntry,
typeOptions,
folderOptions,
onClose,
onSelectType,
onSelectFolder,
}: NoteRetargetingDialogsProps) {
return (
<>
<RetargetNoteDialog
open={dialogState?.kind === 'type'}
title="Change Note Type"
description={typeDialogDescription(dialogEntry)}
searchPlaceholder="Search types"
emptyMessage="No other note types available."
options={typeOptions}
onClose={onClose}
onSelect={onSelectType}
testIdPrefix="retarget-note-type"
/>
<RetargetNoteDialog
open={dialogState?.kind === 'folder'}
title="Move Note to Folder"
description={folderDialogDescription(dialogEntry)}
searchPlaceholder="Search folders"
emptyMessage="No other folders available."
options={folderOptions}
onClose={onClose}
onSelect={onSelectFolder}
testIdPrefix="retarget-note-folder"
/>
</>
)
}

View File

@@ -0,0 +1,173 @@
import { Check, StackSimple } from '@phosphor-icons/react'
import { useMemo, useState, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
export interface RetargetOption {
id: string
label: string
detail?: string
current?: boolean
}
interface RetargetNoteDialogProps {
open: boolean
title: string
description: string
searchPlaceholder: string
emptyMessage: string
options: RetargetOption[]
onClose: () => void
onSelect: (id: string) => boolean | Promise<boolean>
testIdPrefix: string
}
function matchesQuery(option: RetargetOption, query: string): boolean {
const normalized = query.trim().toLowerCase()
if (!normalized) return true
return option.label.toLowerCase().includes(normalized)
|| option.detail?.toLowerCase().includes(normalized)
}
function initialHighlightIndex(options: RetargetOption[]): number {
if (options.length === 0) return -1
const currentIndex = options.findIndex((option) => option.current)
return currentIndex >= 0 ? currentIndex : 0
}
function nextHighlightIndex(current: number, total: number, direction: 'next' | 'previous'): number {
if (total === 0) return -1
if (current < 0) return direction === 'next' ? 0 : total - 1
return direction === 'next'
? (current + 1) % total
: (current - 1 + total) % total
}
export function RetargetNoteDialog({
open,
title,
description,
searchPlaceholder,
emptyMessage,
options,
onClose,
onSelect,
testIdPrefix,
}: RetargetNoteDialogProps) {
const [query, setQuery] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const filteredOptions = useMemo(
() => options.filter((option) => matchesQuery(option, query)),
[options, query],
)
const effectiveHighlightedIndex = highlightedIndex >= 0 && highlightedIndex < filteredOptions.length
? highlightedIndex
: initialHighlightIndex(filteredOptions)
const resetDialogState = () => {
setQuery('')
setHighlightedIndex(-1)
}
const submitSelection = async (optionId: string) => {
const shouldClose = await onSelect(optionId)
if (!shouldClose) return
resetDialogState()
onClose()
}
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setHighlightedIndex((current) => nextHighlightIndex(current, filteredOptions.length, 'next'))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setHighlightedIndex((current) => nextHighlightIndex(current, filteredOptions.length, 'previous'))
return
}
if (event.key === 'Enter' && effectiveHighlightedIndex >= 0) {
event.preventDefault()
void submitSelection(filteredOptions[effectiveHighlightedIndex].id)
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) return
resetDialogState()
onClose()
}}
>
<DialogContent className="max-w-xl gap-3" showCloseButton={true}>
<DialogHeader className="gap-1">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
data-testid={`${testIdPrefix}-search`}
/>
<ScrollArea className="max-h-80 rounded-md border">
{filteredOptions.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground" data-testid={`${testIdPrefix}-empty`}>
{emptyMessage}
</div>
) : (
<div className="p-1" data-testid={`${testIdPrefix}-options`}>
{filteredOptions.map((option, index) => (
<Button
key={option.id}
type="button"
variant="ghost"
className={cn(
'h-auto w-full justify-start rounded-md px-3 py-2 text-left',
effectiveHighlightedIndex === index && 'bg-accent text-accent-foreground',
)}
data-testid={`${testIdPrefix}-option:${option.id}`}
onMouseMove={() => setHighlightedIndex(index)}
onClick={() => { void submitSelection(option.id) }}
>
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="mt-0.5 shrink-0 text-muted-foreground">
{option.current ? <Check size={14} weight="bold" /> : <StackSimple size={14} />}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">{option.label}</span>
{option.detail && (
<span className="truncate text-xs text-muted-foreground">{option.detail}</span>
)}
</span>
</div>
{option.current && (
<span className="shrink-0 text-xs font-medium text-muted-foreground">
Current
</span>
)}
</Button>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,19 @@
import type { DragEvent } from 'react'
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
export function writeDraggedNotePath(event: DragEvent<HTMLElement>, notePath: string): void {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData(NOTE_DRAG_MIME, notePath)
event.dataTransfer.setData('text/plain', notePath)
}
export function readDraggedNotePath(dataTransfer: DataTransfer | null): string | null {
if (!dataTransfer) return null
const customPath = dataTransfer.getData(NOTE_DRAG_MIME).trim()
if (customPath) return customPath
const fallbackPath = dataTransfer.getData('text/plain').trim()
return fallbackPath || null
}

View File

@@ -0,0 +1,24 @@
import { createContext, createElement, useContext, type ReactNode } from 'react'
export interface NoteRetargetingContextValue {
canDropNoteOnType: (notePath: string, type: string) => boolean
dropNoteOnType: (notePath: string, type: string) => Promise<void>
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean
dropNoteOnFolder: (notePath: string, folderPath: string) => Promise<void>
}
export const noteRetargetingContext = createContext<NoteRetargetingContextValue | null>(null)
export function useNoteRetargetingContext() {
return useContext(noteRetargetingContext)
}
export function NoteRetargetingProvider({
children,
value,
}: {
children: ReactNode
value: NoteRetargetingContextValue | null
}) {
return createElement(noteRetargetingContext.Provider, { value }, children)
}

View File

@@ -19,6 +19,8 @@ import {
} from '../SidebarParts'
import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { countByFilter } from '../../utils/noteListHelpers'
@@ -95,9 +97,24 @@ function SortableSection({
group: SectionGroup
sectionProps: SidebarSectionProps
}) {
const noteRetargeting = useNoteRetargetingContext()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const itemCount = countByFilter(sectionProps.entries, group.type).open
const isRenaming = sectionProps.renamingType === group.type
const content = (
<SectionContent
group={group}
itemCount={itemCount}
selection={sectionProps.selection}
onSelect={sectionProps.onSelect}
onContextMenu={sectionProps.onContextMenu}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
/>
)
return (
<div
@@ -110,18 +127,14 @@ function SortableSection({
}}
{...attributes}
>
<SectionContent
group={group}
itemCount={itemCount}
selection={sectionProps.selection}
onSelect={sectionProps.onSelect}
onContextMenu={sectionProps.onContextMenu}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
/>
{noteRetargeting ? (
<NoteDropTarget
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnType(notePath, group.type)}
onDropNote={(notePath) => noteRetargeting.dropNoteOnType(notePath, group.type)}
>
{content}
</NoteDropTarget>
) : content}
</div>
)
}

View File

@@ -11,6 +11,9 @@ interface NoteCommandsConfig {
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
@@ -22,17 +25,18 @@ interface NoteCommandsConfig {
canRestoreDeletedNote?: boolean
}
interface ActivePathCommandConfig {
interface NoteCommandConfig {
id: string
label: string
keywords: string[]
enabled: boolean
path: string | null
execute?: () => void
shortcut?: string
run: (path: string) => void
path?: string | null
run?: (path: string) => void
}
function createActivePathCommand(config: ActivePathCommandConfig): CommandAction {
function createNoteCommand(config: NoteCommandConfig): CommandAction {
return {
id: config.id,
label: config.label,
@@ -41,84 +45,165 @@ function createActivePathCommand(config: ActivePathCommandConfig): CommandAction
keywords: config.keywords,
enabled: config.enabled,
execute: () => {
if (config.path) config.run(config.path)
if (config.path && config.run) {
config.run(config.path)
return
}
config.execute?.()
},
}
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized,
onRestoreDeletedNote, canRestoreDeletedNote,
} = config
function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
{ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'create', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
createActivePathCommand({
createNoteCommand({
id: 'create-note',
label: 'New Note',
shortcut: '⌘N',
keywords: ['new', 'create', 'add'],
enabled: true,
execute: config.onCreateNote,
}),
createNoteCommand({
id: 'create-type',
label: 'New Type',
keywords: ['new', 'create', 'type', 'template'],
enabled: !!config.onCreateType,
execute: () => config.onCreateType?.(),
}),
createNoteCommand({
id: 'save-note',
label: 'Save Note',
shortcut: '⌘S',
keywords: ['write'],
enabled: config.hasActiveNote,
execute: config.onSave,
}),
]
}
function buildPathNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildDestructiveNoteCommands(config),
...buildPinnedNoteCommands(config),
]
}
function buildDestructiveNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'delete-note',
label: 'Delete Note',
shortcut: '⌘⌫',
keywords: ['delete', 'remove'],
enabled: hasActiveNote,
path: activeTabPath,
run: onDeleteNote,
enabled: config.hasActiveNote,
path: config.activeTabPath,
run: config.onDeleteNote,
}),
createActivePathCommand({
createNoteCommand({
id: 'archive-note',
label: isArchived ? 'Unarchive Note' : 'Archive Note',
label: config.isArchived ? 'Unarchive Note' : 'Archive Note',
keywords: ['archive'],
enabled: hasActiveNote,
path: activeTabPath,
run: isArchived ? onUnarchiveNote : onArchiveNote,
enabled: config.hasActiveNote,
path: config.activeTabPath,
run: config.isArchived ? config.onUnarchiveNote : config.onArchiveNote,
}),
{
id: 'restore-deleted-note', label: 'Restore Deleted Note', group: 'Note',
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
enabled: !!canRestoreDeletedNote && !!onRestoreDeletedNote,
execute: () => onRestoreDeletedNote?.(),
},
createActivePathCommand({
id: 'toggle-favorite',
label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: hasActiveNote && !!onToggleFavorite,
path: activeTabPath,
run: (path) => onToggleFavorite?.(path),
}),
createActivePathCommand({
id: 'toggle-organized',
label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: hasActiveNote && !!onToggleOrganized,
path: activeTabPath,
run: (path) => onToggleOrganized?.(path),
}),
{
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
enabled: hasActiveNote && !!onSetNoteIcon,
execute: () => onSetNoteIcon?.(),
},
{
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
execute: () => onRemoveNoteIcon?.(),
},
{
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: hasActiveNote,
execute: () => onOpenInNewWindow?.(),
},
]
}
function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'toggle-favorite',
label: config.isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: config.hasActiveNote && !!config.onToggleFavorite,
path: config.activeTabPath,
run: (path) => config.onToggleFavorite?.(path),
}),
createNoteCommand({
id: 'toggle-organized',
label: config.isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: config.hasActiveNote && !!config.onToggleOrganized,
path: config.activeTabPath,
run: (path) => config.onToggleOrganized?.(path),
}),
]
}
function buildOptionalNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildRecoveryCommands(config),
...buildRetargetingCommands(config),
...buildPresentationCommands(config),
]
}
function buildRecoveryCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'restore-deleted-note',
label: 'Restore Deleted Note',
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
enabled: !!config.canRestoreDeletedNote && !!config.onRestoreDeletedNote,
execute: () => config.onRestoreDeletedNote?.(),
}),
]
}
function buildRetargetingCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'set-note-icon',
label: 'Set Note Icon',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
enabled: config.hasActiveNote && !!config.onSetNoteIcon,
execute: () => config.onSetNoteIcon?.(),
}),
createNoteCommand({
id: 'change-note-type',
label: 'Change Note Type…',
keywords: ['type', 'change', 'retarget', 'section', 'move'],
enabled: config.hasActiveNote && !!config.onChangeNoteType,
execute: () => config.onChangeNoteType?.(),
}),
createNoteCommand({
id: 'move-note-to-folder',
label: 'Move Note to Folder…',
keywords: ['folder', 'move', 'retarget', 'organize'],
enabled: config.hasActiveNote && !!config.onMoveNoteToFolder && !!config.canMoveNoteToFolder,
execute: () => config.onMoveNoteToFolder?.(),
}),
]
}
function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'remove-note-icon',
label: 'Remove Note Icon',
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
enabled: config.hasActiveNote && !!config.activeNoteHasIcon && !!config.onRemoveNoteIcon,
execute: () => config.onRemoveNoteIcon?.(),
}),
createNoteCommand({
id: 'open-in-new-window',
label: 'Open in New Window',
shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: config.hasActiveNote,
execute: () => config.onOpenInNewWindow?.(),
}),
]
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildCoreNoteCommands(config),
...buildPathNoteCommands(config),
...buildOptionalNoteCommands(config),
]
}

View File

@@ -80,6 +80,9 @@ interface AppCommandsConfig {
onRepairVault?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
activeNoteHasIcon?: boolean
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
@@ -169,6 +172,9 @@ type CommandRegistryNoteActions = Pick<
CommandRegistryConfig,
| 'onSetNoteIcon'
| 'onRemoveNoteIcon'
| 'onChangeNoteType'
| 'onMoveNoteToFolder'
| 'canMoveNoteToFolder'
| 'activeNoteHasIcon'
| 'noteListFilter'
| 'onSetNoteListFilter'
@@ -421,6 +427,9 @@ function createCommandRegistryNoteConfig(
return {
onSetNoteIcon: config.onSetNoteIcon,
onRemoveNoteIcon: config.onRemoveNoteIcon,
onChangeNoteType: config.onChangeNoteType,
onMoveNoteToFolder: config.onMoveNoteToFolder,
canMoveNoteToFolder: config.canMoveNoteToFolder,
activeNoteHasIcon: config.activeNoteHasIcon,
noteListFilter: config.noteListFilter,
onSetNoteListFilter: config.onSetNoteListFilter,

View File

@@ -152,6 +152,42 @@ describe('useCommandRegistry', () => {
expect(onSetNoteIcon).toHaveBeenCalled()
})
it('includes Change Note Type when the active note can be retargeted', () => {
const onChangeNoteType = vi.fn()
const config = makeConfig({ onChangeNoteType })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'change-note-type')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(true)
cmd!.execute()
expect(onChangeNoteType).toHaveBeenCalledOnce()
})
it('enables Move Note to Folder only when another folder destination exists', () => {
const onMoveNoteToFolder = vi.fn()
const { result, rerender } = renderHook(
(props) => useCommandRegistry(props),
{
initialProps: makeConfig({
onMoveNoteToFolder,
canMoveNoteToFolder: true,
}),
},
)
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(true)
findCommand(result.current, 'move-note-to-folder')!.execute()
expect(onMoveNoteToFolder).toHaveBeenCalledOnce()
rerender(makeConfig({
onMoveNoteToFolder,
canMoveNoteToFolder: false,
}))
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(false)
})
it('includes restore deleted note command when provided', () => {
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: true })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -40,6 +40,9 @@ interface CommandRegistryConfig {
onRepairVault?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
@@ -109,7 +112,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
onRestoreDeletedNote, canRestoreDeletedNote,
@@ -150,6 +153,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
@@ -200,7 +204,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
isSectionGroup, noteListFilter, onSetNoteListFilter,
selection,
onOpenInNewWindow, onToggleFavorite, isFavorite,

View File

@@ -343,5 +343,6 @@ export function useNoteActions(config: NoteActionsConfig) {
handleAddProperty: frontmatterActions.handleAddProperty,
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
handleMoveNoteToFolder: rename.handleMoveNoteToFolder,
}
}

View File

@@ -248,4 +248,44 @@ describe('useNoteRename hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('A note with that name already exists')
})
it('handleMoveNoteToFolder moves the note and keeps its title intact', async () => {
const entry = makeEntry({ path: '/vault/notes/project-kickoff.md', filename: 'project-kickoff.md', title: 'Project Kickoff' })
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_folder') {
return {
new_path: '/vault/projects/project-kickoff.md',
updated_files: 1,
failed_updates: 0,
}
}
if (cmd === 'get_note_content') return '# Project Kickoff\n'
return ''
})
const { result } = renderHook(() => useNoteRename(
{ entries: [entry], setToastMessage },
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
))
const onEntryRenamed = vi.fn()
await act(async () => {
await result.current.handleMoveNoteToFolder('/vault/notes/project-kickoff.md', 'projects', '/vault', onEntryRenamed)
})
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_folder', expect.objectContaining({
old_path: '/vault/notes/project-kickoff.md',
folder_path: 'projects',
}))
expect(onEntryRenamed).toHaveBeenCalledWith(
'/vault/notes/project-kickoff.md',
expect.objectContaining({
path: '/vault/projects/project-kickoff.md',
filename: 'project-kickoff.md',
title: 'Project Kickoff',
}),
'# Project Kickoff\n',
)
expect(setToastMessage).toHaveBeenCalledWith('Moved to "projects" and updated 1 note')
})
})

View File

@@ -25,6 +25,12 @@ interface FilenameRenameRequest {
vaultPath: string
}
interface FolderMoveRequest {
path: string
folderPath: string
vaultPath: string
}
interface LoadNoteContentRequest {
path: string
}
@@ -34,6 +40,8 @@ interface ReloadTabsAfterRenameRequest {
updateTabContent: (path: string, content: string) => void
}
type RenameCommand = 'rename_note' | 'rename_note_filename' | 'move_note_to_folder'
/** Check if a note's filename doesn't match the slug of its current title. */
export function needsRenameOnSave(title: string, filename: string): boolean {
if (!filename.toLowerCase().endsWith('.md')) return false
@@ -46,10 +54,23 @@ export async function performRename({
vaultPath,
oldTitle,
}: RenameRequest): Promise<RenameResult> {
if (isTauri()) {
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
}
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
return invokeRenameCommand({
command: 'rename_note',
tauriArgs: { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null },
mockArgs: { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null },
})
}
function invokeRenameCommand(
params: {
command: RenameCommand
tauriArgs: Record<string, unknown>
mockArgs: Record<string, unknown>
},
): Promise<RenameResult> {
return isTauri()
? invoke<RenameResult>(params.command, params.tauriArgs)
: mockInvoke<RenameResult>(params.command, params.mockArgs)
}
export async function performFilenameRename({
@@ -57,17 +78,22 @@ export async function performFilenameRename({
newFilenameStem,
vaultPath,
}: FilenameRenameRequest): Promise<RenameResult> {
if (isTauri()) {
return invoke<RenameResult>('rename_note_filename', {
vaultPath,
oldPath: path,
newFilenameStem,
})
}
return mockInvoke<RenameResult>('rename_note_filename', {
vault_path: vaultPath,
old_path: path,
new_filename_stem: newFilenameStem,
return invokeRenameCommand({
command: 'rename_note_filename',
tauriArgs: { vaultPath, oldPath: path, newFilenameStem },
mockArgs: { vault_path: vaultPath, old_path: path, new_filename_stem: newFilenameStem },
})
}
export async function performMoveNoteToFolder({
path,
folderPath,
vaultPath,
}: FolderMoveRequest): Promise<RenameResult> {
return invokeRenameCommand({
command: 'move_note_to_folder',
tauriArgs: { vaultPath, oldPath: path, folderPath },
mockArgs: { vault_path: vaultPath, old_path: path, folder_path: folderPath },
})
}
@@ -87,16 +113,59 @@ export async function loadNoteContent({ path }: LoadNoteContentRequest): Promise
: mockInvoke<string>('get_note_content', { path })
}
export function renameToastMessage(updatedFiles: number, failedUpdates = 0): string {
function rewriteSummaryLabel(params: { updatedFiles: number }): string {
return params.updatedFiles === 1 ? 'Updated 1 note' : `Updated ${params.updatedFiles} notes`
}
function manualUpdateWarning(params: { failedUpdates: number }): string {
const { failedUpdates } = params
return `${failedUpdates} linked note${failedUpdates > 1 ? 's' : ''} need${failedUpdates === 1 ? 's' : ''} manual updates`
}
function formatRewriteToast(
params: {
action: string
updatedFiles: number
failedUpdates?: number
preferBareUpdate?: boolean
},
): string {
const {
action,
updatedFiles,
failedUpdates = 0,
preferBareUpdate = false,
} = params
if (failedUpdates > 0) {
const noteLabel = `${failedUpdates} linked note${failedUpdates > 1 ? 's' : ''}`
if (updatedFiles === 0) {
return `Renamed, but ${noteLabel} need${failedUpdates === 1 ? 's' : ''} manual updates`
return `${action}, but ${manualUpdateWarning({ failedUpdates })}`
}
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}, but ${noteLabel} need${failedUpdates === 1 ? 's' : ''} manual updates`
if (preferBareUpdate) {
return `${rewriteSummaryLabel({ updatedFiles })}, but ${manualUpdateWarning({ failedUpdates })}`
}
return `${action} and ${rewriteSummaryLabel({ updatedFiles }).toLowerCase()}, but ${manualUpdateWarning({ failedUpdates })}`
}
if (updatedFiles === 0) return 'Renamed'
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}`
if (updatedFiles === 0) return action
return preferBareUpdate
? rewriteSummaryLabel({ updatedFiles })
: `${action} and ${rewriteSummaryLabel({ updatedFiles }).toLowerCase()}`
}
export function renameToastMessage(updatedFiles: number, failedUpdates = 0): string {
return formatRewriteToast({ action: 'Renamed', updatedFiles, failedUpdates, preferBareUpdate: true })
}
function folderLabel(params: { folderPath: string }): string {
const trimmed = params.folderPath.trim().replace(/^\/+|\/+$/g, '')
return trimmed.split('/').filter(Boolean).at(-1) ?? trimmed
}
function moveToastMessage(folderPath: string, updatedFiles: number, failedUpdates = 0): string {
return formatRewriteToast({
action: `Moved to "${folderLabel({ folderPath })}"`,
updatedFiles,
failedUpdates,
})
}
export async function reloadVaultAfterRename(reloadVault?: () => Promise<unknown>): Promise<void> {
@@ -137,6 +206,15 @@ function renameErrorMessage(err: unknown): string {
return 'Failed to rename note'
}
function moveNoteErrorMessage(err: unknown): string {
const message = typeof err === 'string'
? err.trim()
: err instanceof Error
? err.message.trim()
: ''
return message || 'Failed to move note'
}
export interface NoteRenameConfig {
entries: VaultEntry[]
setToastMessage: (msg: string | null) => void
@@ -151,7 +229,14 @@ interface RenameTabDeps {
updateTabContent: (path: string, content: string) => void
}
export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) {
interface ApplyRenameOptions {
successMessage?: (result: RenameResult) => string
}
function useRenameResultApplier(
config: NoteRenameConfig,
tabDeps: RenameTabDeps,
) {
const { entries, setToastMessage, reloadVault } = config
const { setTabs, activeTabPathRef, handleSwitchTab, updateTabContent } = tabDeps
@@ -164,6 +249,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
result: RenameResult,
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
options?: ApplyRenameOptions,
) => {
const entry = entries.find((item) => item.path === oldPath)
const newContent = await loadNoteContent({ path: result.new_path })
@@ -174,26 +260,79 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
onEntryRenamed(oldPath, newEntry, newContent)
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent })
await reloadVaultAfterRename(reloadVault)
setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0))
const successMessage = options?.successMessage
? options.successMessage(result)
: renameToastMessage(result.updated_files, result.failed_updates ?? 0)
setToastMessage(successMessage)
return result
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, reloadVault, setToastMessage])
return {
tabsRef,
applyRenameResult,
}
}
async function runRenameAction({
path,
perform,
applyRenameResult,
buildEntry,
onEntryRenamed,
setToastMessage,
errorMessage,
logLabel,
successMessage,
allowUnchangedResult = false,
}: {
path: string
perform: () => Promise<RenameResult>
applyRenameResult: (
oldPath: string,
result: RenameResult,
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
options?: ApplyRenameOptions,
) => Promise<RenameResult>
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
setToastMessage: (message: string | null) => void
errorMessage: (err: unknown) => string
logLabel: string
successMessage?: (result: RenameResult) => string
allowUnchangedResult?: boolean
}): Promise<RenameResult | null> {
try {
const result = await perform()
if (allowUnchangedResult && result.new_path === path) return result
await applyRenameResult(path, result, buildEntry, onEntryRenamed, { successMessage })
return result
} catch (err) {
console.error(`${logLabel}:`, err)
setToastMessage(errorMessage(err))
return null
}
}
export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) {
const { entries, setToastMessage } = config
const { tabsRef, applyRenameResult } = useRenameResultApplier(config, tabDeps)
const handleRenameNote = useCallback(async (
path: string, newTitle: string, vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
try {
const entry = entries.find((e) => e.path === path)
const result = await performRename({ path, newTitle, vaultPath, oldTitle: entry?.title })
await applyRenameResult(
path,
result,
(currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath),
onEntryRenamed,
)
} catch (err) {
console.error('Failed to rename note:', err)
setToastMessage(renameErrorMessage(err))
}
const entry = entries.find((e) => e.path === path)
await runRenameAction({
path,
perform: () => performRename({ path, newTitle, vaultPath, oldTitle: entry?.title }),
applyRenameResult,
buildEntry: (currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath),
onEntryRenamed,
setToastMessage,
errorMessage: renameErrorMessage,
logLabel: 'Failed to rename note',
})
}, [entries, applyRenameResult, setToastMessage])
const handleRenameFilename = useCallback(async (
@@ -202,19 +341,42 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
try {
const result = await performFilenameRename({ path, newFilenameStem, vaultPath })
await applyRenameResult(
path,
result,
(currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
onEntryRenamed,
)
} catch (err) {
console.error('Failed to rename note filename:', err)
setToastMessage(renameErrorMessage(err))
}
await runRenameAction({
path,
perform: () => performFilenameRename({ path, newFilenameStem, vaultPath }),
applyRenameResult,
buildEntry: (currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
onEntryRenamed,
setToastMessage,
errorMessage: renameErrorMessage,
logLabel: 'Failed to rename note filename',
})
}, [applyRenameResult, setToastMessage])
return { handleRenameNote, handleRenameFilename, tabsRef }
const handleMoveNoteToFolder = useCallback(async (
path: string,
folderPath: string,
vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
const normalizedFolderPath = folderPath.trim().replace(/^\/+|\/+$/g, '')
return runRenameAction({
path,
perform: () => performMoveNoteToFolder({ path, folderPath: normalizedFolderPath, vaultPath }),
applyRenameResult,
buildEntry: (currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
onEntryRenamed,
setToastMessage,
errorMessage: moveNoteErrorMessage,
logLabel: 'Failed to move note to folder',
successMessage: (result) => moveToastMessage(
normalizedFolderPath,
result.updated_files,
result.failed_updates ?? 0,
),
allowUnchangedResult: true,
})
}, [applyRenameResult, setToastMessage])
return { handleRenameNote, handleRenameFilename, handleMoveNoteToFolder, tabsRef }
}

View File

@@ -0,0 +1,132 @@
import { renderHook, act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
import { useNoteRetargeting } from './useNoteRetargeting'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/notes/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 10,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
template: null,
sort: null,
sidebarLabel: null,
view: null,
visible: null,
properties: {},
...overrides,
})
const folders: FolderNode[] = [
{ name: 'notes', path: 'notes', children: [] },
{ name: 'projects', path: 'projects', children: [] },
]
describe('useNoteRetargeting', () => {
const setSelection = vi.fn()
const setToastMessage = vi.fn()
const updateFrontmatter = vi.fn()
const moveNoteToFolder = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
function renderUseNoteRetargeting(
selection: SidebarSelection,
entries: VaultEntry[] = [
makeEntry(),
makeEntry({ path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type' }),
],
) {
return renderHook(() => useNoteRetargeting({
entries,
folders,
selection,
setSelection,
setToastMessage,
vaultPath: '/vault',
updateFrontmatter,
moveNoteToFolder,
}))
}
it('rejects dropping a note onto its current type and allows other types', () => {
const { result } = renderUseNoteRetargeting({ kind: 'filter', filter: 'all' })
expect(result.current.canDropNoteOnType('/vault/notes/alpha.md', 'Note')).toBe(false)
expect(result.current.canDropNoteOnType('/vault/notes/alpha.md', 'Type')).toBe(true)
})
it('changes the note type, updates entity selection, and shows a toast', async () => {
updateFrontmatter.mockResolvedValue(undefined)
const selection: SidebarSelection = { kind: 'entity', entry: makeEntry() }
const { result } = renderUseNoteRetargeting(selection)
await act(async () => {
await result.current.changeNoteType('/vault/notes/alpha.md', 'Type')
})
expect(updateFrontmatter).toHaveBeenCalledWith(
'/vault/notes/alpha.md',
'type',
'Type',
{ silent: true },
)
expect(setSelection).toHaveBeenCalledWith({
kind: 'entity',
entry: expect.objectContaining({
path: '/vault/notes/alpha.md',
isA: 'Type',
}),
})
expect(setToastMessage).toHaveBeenCalledWith('Type set to "Type"')
})
it('moves the note into another folder and updates the selected entity path', async () => {
const selection: SidebarSelection = { kind: 'entity', entry: makeEntry() }
moveNoteToFolder.mockImplementation(async (
path: string,
folderPath: string,
vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }) => void,
) => {
onEntryRenamed(path, { path: '/vault/projects/alpha.md', filename: 'alpha.md' })
return { new_path: '/vault/projects/alpha.md' }
})
const { result } = renderUseNoteRetargeting(selection)
await act(async () => {
await result.current.moveIntoFolder('/vault/notes/alpha.md', 'projects')
})
expect(moveNoteToFolder).toHaveBeenCalledWith(
'/vault/notes/alpha.md',
'projects',
'/vault',
expect.any(Function),
)
expect(setSelection).toHaveBeenCalledWith({
kind: 'entity',
entry: expect.objectContaining({
path: '/vault/projects/alpha.md',
filename: 'alpha.md',
}),
})
})
})

View File

@@ -0,0 +1,241 @@
import { useCallback, useMemo } from 'react'
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
import type { FrontmatterOpOptions } from './frontmatterOps'
import { extractVaultTypes } from '../utils/vaultTypes'
type RetargetResult = 'updated' | 'noop' | 'error'
export interface RetargetFolderOption {
path: string
label: string
}
interface NoteRetargetingInput {
entries: VaultEntry[]
folders: FolderNode[]
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
setToastMessage: (message: string | null) => void
vaultPath: string
updateFrontmatter: (
path: string,
key: string,
value: string,
options?: FrontmatterOpOptions,
) => Promise<void>
moveNoteToFolder: (
path: string,
folderPath: string,
vaultPath: string,
onEntryRenamed: (
oldPath: string,
newEntry: Partial<VaultEntry> & { path: string },
newContent: string,
) => void,
) => Promise<{ new_path: string } | null>
}
function normalizeFolderPath(params: { folderPath: string }): string {
return params.folderPath.trim().replace(/^\/+|\/+$/g, '')
}
function folderPathForEntry(params: { entry: VaultEntry; vaultPath: string }): string {
const normalizedVaultPath = params.vaultPath.replace(/\/+$/, '')
const relativePath = params.entry.path.startsWith(`${normalizedVaultPath}/`)
? params.entry.path.slice(normalizedVaultPath.length + 1)
: params.entry.filename
const lastSlashIndex = relativePath.lastIndexOf('/')
return lastSlashIndex >= 0 ? relativePath.slice(0, lastSlashIndex) : ''
}
function flattenFolders(nodes: FolderNode[]): RetargetFolderOption[] {
return nodes.flatMap((node) => [
{ path: node.path, label: node.name },
...flattenFolders(node.children),
])
}
function entryByPath(params: { entries: VaultEntry[]; notePath: string }): VaultEntry | undefined {
return params.entries.find((entry) => entry.path === params.notePath)
}
function canRetargetEntryToType(params: { entry: VaultEntry | undefined; type: string }): boolean {
return !!params.entry && params.entry.isA !== params.type
}
function canRetargetEntryToFolder(
params: {
entry: VaultEntry | undefined
folderPath: string
vaultPath: string
},
): boolean {
if (!params.entry) return false
return folderPathForEntry({ entry: params.entry, vaultPath: params.vaultPath })
!== normalizeFolderPath({ folderPath: params.folderPath })
}
function updateEntitySelection(
selection: SidebarSelection,
setSelection: (selection: SidebarSelection) => void,
notePath: string,
patch: Partial<VaultEntry> & { path: string },
) {
if (selection.kind !== 'entity' || selection.entry.path !== notePath) return
setSelection({
kind: 'entity',
entry: {
...selection.entry,
...patch,
},
})
}
async function changeEntryType({
entry,
notePath,
nextType,
selection,
setSelection,
setToastMessage,
updateFrontmatter,
}: {
entry: VaultEntry | undefined
notePath: string
nextType: string
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
setToastMessage: (message: string | null) => void
updateFrontmatter: (
path: string,
key: string,
value: string,
options?: FrontmatterOpOptions,
) => Promise<void>
}): Promise<RetargetResult> {
const normalizedType = nextType.trim()
if (!entry || !normalizedType) return 'error'
if (entry.isA === normalizedType) return 'noop'
try {
await updateFrontmatter(notePath, 'type', normalizedType, { silent: true })
updateEntitySelection(selection, setSelection, notePath, { path: notePath, isA: normalizedType })
setToastMessage(`Type set to "${normalizedType}"`)
return 'updated'
} catch (error) {
console.error('Failed to change note type:', error)
setToastMessage(typeof error === 'string' ? error : 'Failed to change note type')
return 'error'
}
}
async function moveEntryToFolder({
entry,
notePath,
folderPath,
vaultPath,
selection,
setSelection,
moveNoteToFolder,
}: {
entry: VaultEntry | undefined
notePath: string
folderPath: string
vaultPath: string
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
moveNoteToFolder: (
path: string,
folderPath: string,
vaultPath: string,
onEntryRenamed: (
oldPath: string,
newEntry: Partial<VaultEntry> & { path: string },
newContent: string,
) => void,
) => Promise<{ new_path: string } | null>
}): Promise<RetargetResult> {
const normalizedFolderPath = normalizeFolderPath({ folderPath })
if (!entry || !normalizedFolderPath) return 'error'
if (folderPathForEntry({ entry, vaultPath }) === normalizedFolderPath) return 'noop'
const result = await moveNoteToFolder(
notePath,
normalizedFolderPath,
vaultPath,
(oldPath, newEntry) => updateEntitySelection(selection, setSelection, oldPath, newEntry),
)
if (!result) return 'error'
return result.new_path === notePath ? 'noop' : 'updated'
}
export function useNoteRetargeting({
entries,
folders,
selection,
setSelection,
setToastMessage,
vaultPath,
updateFrontmatter,
moveNoteToFolder,
}: NoteRetargetingInput) {
const availableTypes = useMemo(
() => extractVaultTypes(entries).map((type) => type.canonicalType).sort((left, right) => left.localeCompare(right)),
[entries],
)
const availableFolders = useMemo(() => flattenFolders(folders), [folders])
const canDropNoteOnType = useCallback((notePath: string, type: string) => {
return canRetargetEntryToType({
entry: entryByPath({ entries, notePath }),
type,
})
}, [entries])
const canDropNoteOnFolder = useCallback((notePath: string, folderPath: string) => {
return canRetargetEntryToFolder({
entry: entryByPath({ entries, notePath }),
folderPath,
vaultPath,
})
}, [entries, vaultPath])
const changeNoteType = useCallback(async (
notePath: string,
nextType: string,
): Promise<RetargetResult> => {
return changeEntryType({
entry: entryByPath({ entries, notePath }),
notePath,
nextType,
selection,
setSelection,
setToastMessage,
updateFrontmatter,
})
}, [entries, selection, setSelection, setToastMessage, updateFrontmatter])
const moveIntoFolder = useCallback(async (
notePath: string,
folderPath: string,
): Promise<RetargetResult> => {
return moveEntryToFolder({
entry: entryByPath({ entries, notePath }),
notePath,
folderPath,
vaultPath,
selection,
setSelection,
moveNoteToFolder,
})
}, [entries, moveNoteToFolder, selection, setSelection, vaultPath])
return {
availableTypes,
availableFolders,
canDropNoteOnType,
canDropNoteOnFolder,
changeNoteType,
moveIntoFolder,
}
}

View File

@@ -0,0 +1,249 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
import type { NoteRetargetingContextValue } from '../components/note-retargeting/noteRetargetingContext'
import type { RetargetOption } from '../components/note-retargeting/RetargetNoteDialog'
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
import type { FrontmatterOpOptions } from './frontmatterOps'
import { useNoteRetargeting } from './useNoteRetargeting'
type DialogState =
| { kind: 'type'; notePath: string }
| { kind: 'folder'; notePath: string }
| null
interface NoteRetargetingUiInput {
activeEntry: VaultEntry | null
activeNoteBlocked: boolean
entries: VaultEntry[]
folders: FolderNode[]
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
setToastMessage: (message: string | null) => void
vaultPath: string
updateFrontmatter: (
path: string,
key: string,
value: string,
options?: FrontmatterOpOptions,
) => Promise<void>
moveNoteToFolder: (
path: string,
folderPath: string,
vaultPath: string,
onEntryRenamed: (
oldPath: string,
newEntry: Partial<VaultEntry> & { path: string },
newContent: string,
) => void,
) => Promise<{ new_path: string } | null>
}
function folderPathForNote(notePath: string, vaultPath: string): string {
const normalizedVaultPath = vaultPath.replace(/\/+$/, '')
const relativePath = notePath.startsWith(`${normalizedVaultPath}/`)
? notePath.slice(normalizedVaultPath.length + 1)
: notePath
const lastSlashIndex = relativePath.lastIndexOf('/')
return lastSlashIndex >= 0 ? relativePath.slice(0, lastSlashIndex) : ''
}
function buildTypeOptions(types: string[], entry: VaultEntry | null): RetargetOption[] {
if (!entry) return []
return types.map((type) => ({
id: type,
label: type,
current: entry.isA === type,
}))
}
function buildFolderOptions(
folders: Array<{ path: string; label: string }>,
entry: VaultEntry | null,
vaultPath: string,
): RetargetOption[] {
if (!entry) return []
const currentFolderPath = folderPathForNote(entry.path, vaultPath)
return folders.map((folder) => ({
id: folder.path,
label: folder.label,
detail: folder.path === folder.label ? undefined : folder.path,
current: folder.path === currentFolderPath,
}))
}
function resolveDialogEntry(
dialogState: DialogState,
entries: VaultEntry[],
activeEntry: VaultEntry | null,
): VaultEntry | null {
if (!dialogState) return null
return entries.find((entry) => entry.path === dialogState.notePath)
?? (activeEntry?.path === dialogState.notePath ? activeEntry : null)
}
function hasTypeRetargetDestination(activeEntry: VaultEntry | null, activeNoteBlocked: boolean, types: string[]): boolean {
return !!activeEntry && !activeNoteBlocked && types.some((type) => type !== activeEntry.isA)
}
function hasFolderRetargetDestination(
activeEntry: VaultEntry | null,
activeNoteBlocked: boolean,
folders: Array<{ path: string; label: string }>,
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean,
): boolean {
return !!activeEntry
&& !activeNoteBlocked
&& folders.some((folder) => canDropNoteOnFolder(activeEntry.path, folder.path))
}
function openDialogForActiveEntry(
setDialogState: Dispatch<SetStateAction<DialogState>>,
activeEntry: VaultEntry | null,
enabled: boolean,
kind: 'type' | 'folder',
) {
if (!activeEntry || !enabled) return
setDialogState({ kind, notePath: activeEntry.path })
}
async function selectFromDialogState(
dialogState: DialogState,
kind: 'type' | 'folder',
value: string,
runSelection: (notePath: string, value: string) => Promise<'updated' | 'noop' | 'error'>,
): Promise<boolean> {
if (!dialogState || dialogState.kind !== kind) return false
const result = await runSelection(dialogState.notePath, value)
return result !== 'error'
}
function useNoteRetargetDialogState({
activeEntry,
canChangeActiveNoteType,
canMoveActiveNoteToFolder,
changeNoteType,
moveIntoFolder,
}: {
activeEntry: VaultEntry | null
canChangeActiveNoteType: boolean
canMoveActiveNoteToFolder: boolean
changeNoteType: (notePath: string, type: string) => Promise<'updated' | 'noop' | 'error'>
moveIntoFolder: (notePath: string, folderPath: string) => Promise<'updated' | 'noop' | 'error'>
}) {
const [dialogState, setDialogState] = useState<DialogState>(null)
const openChangeNoteTypeDialog = useCallback(() => {
openDialogForActiveEntry(setDialogState, activeEntry, canChangeActiveNoteType, 'type')
}, [activeEntry, canChangeActiveNoteType])
const openMoveNoteToFolderDialog = useCallback(() => {
openDialogForActiveEntry(setDialogState, activeEntry, canMoveActiveNoteToFolder, 'folder')
}, [activeEntry, canMoveActiveNoteToFolder])
const closeDialog = useCallback(() => {
setDialogState(null)
}, [])
const selectType = useCallback(async (type: string) => {
return selectFromDialogState(dialogState, 'type', type, changeNoteType)
}, [changeNoteType, dialogState])
const selectFolder = useCallback(async (folderPath: string) => {
return selectFromDialogState(dialogState, 'folder', folderPath, moveIntoFolder)
}, [dialogState, moveIntoFolder])
return {
dialogState,
openChangeNoteTypeDialog,
openMoveNoteToFolderDialog,
closeDialog,
selectType,
selectFolder,
}
}
function useRetargetContextValue({
canDropNoteOnType,
changeNoteType,
canDropNoteOnFolder,
moveIntoFolder,
}: {
canDropNoteOnType: (notePath: string, type: string) => boolean
changeNoteType: (notePath: string, type: string) => Promise<'updated' | 'noop' | 'error'>
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean
moveIntoFolder: (notePath: string, folderPath: string) => Promise<'updated' | 'noop' | 'error'>
}) {
return useMemo<NoteRetargetingContextValue>(() => ({
canDropNoteOnType,
dropNoteOnType: async (notePath, type) => {
await changeNoteType(notePath, type)
},
canDropNoteOnFolder,
dropNoteOnFolder: async (notePath, folderPath) => {
await moveIntoFolder(notePath, folderPath)
},
}), [canDropNoteOnFolder, canDropNoteOnType, changeNoteType, moveIntoFolder])
}
function buildDialogOptions(
availableTypes: string[],
availableFolders: Array<{ path: string; label: string }>,
dialogEntry: VaultEntry | null,
vaultPath: string,
) {
return {
typeOptions: buildTypeOptions(availableTypes, dialogEntry),
folderOptions: buildFolderOptions(availableFolders, dialogEntry, vaultPath),
}
}
function buildNoteRetargetingUiState(params: {
contextValue: NoteRetargetingContextValue
dialogState: DialogState
dialogEntry: VaultEntry | null
canChangeActiveNoteType: boolean
canMoveActiveNoteToFolder: boolean
openChangeNoteTypeDialog: () => void
openMoveNoteToFolderDialog: () => void
typeOptions: RetargetOption[]
folderOptions: RetargetOption[]
closeDialog: () => void
selectType: (type: string) => Promise<boolean>
selectFolder: (folderPath: string) => Promise<boolean>
}) {
return {
contextValue: params.contextValue,
isDialogOpen: params.dialogState !== null,
dialogState: params.dialogState,
dialogEntry: params.dialogEntry,
canChangeActiveNoteType: params.canChangeActiveNoteType,
canMoveActiveNoteToFolder: params.canMoveActiveNoteToFolder,
openChangeNoteTypeDialog: params.openChangeNoteTypeDialog,
openMoveNoteToFolderDialog: params.openMoveNoteToFolderDialog,
typeOptions: params.typeOptions,
folderOptions: params.folderOptions,
closeDialog: params.closeDialog,
selectType: params.selectType,
selectFolder: params.selectFolder,
}
}
export function useNoteRetargetingUi({
activeEntry, activeNoteBlocked, entries, folders, selection, setSelection, setToastMessage, vaultPath, updateFrontmatter, moveNoteToFolder,
}: NoteRetargetingUiInput) {
const {
availableTypes, availableFolders, canDropNoteOnType, canDropNoteOnFolder, changeNoteType, moveIntoFolder,
} = useNoteRetargeting({ entries, folders, selection, setSelection, setToastMessage, vaultPath, updateFrontmatter, moveNoteToFolder })
const canChangeActiveNoteType = hasTypeRetargetDestination(activeEntry, activeNoteBlocked, availableTypes)
const canMoveActiveNoteToFolder = hasFolderRetargetDestination(activeEntry, activeNoteBlocked, availableFolders, canDropNoteOnFolder)
const { dialogState, openChangeNoteTypeDialog, openMoveNoteToFolderDialog, closeDialog, selectType, selectFolder } = useNoteRetargetDialogState({
activeEntry, canChangeActiveNoteType, canMoveActiveNoteToFolder, changeNoteType, moveIntoFolder,
})
const dialogEntry = useMemo(() => resolveDialogEntry(dialogState, entries, activeEntry), [activeEntry, dialogState, entries])
const contextValue = useRetargetContextValue({ canDropNoteOnType, changeNoteType, canDropNoteOnFolder, moveIntoFolder })
const { typeOptions, folderOptions } = buildDialogOptions(availableTypes, availableFolders, dialogEntry, vaultPath)
return buildNoteRetargetingUiState({
contextValue, dialogState, dialogEntry, canChangeActiveNoteType, canMoveActiveNoteToFolder,
openChangeNoteTypeDialog, openMoveNoteToFolderDialog, typeOptions, folderOptions, closeDialog, selectType, selectFolder,
})
}

View File

@@ -269,6 +269,42 @@ function handleRenameNoteFilename(args: {
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
}
function handleMoveNoteToFolder(args: {
vault_path: string
old_path: string
folder_path: string
}) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const oldTitle = oldEntry?.title ?? ''
const oldFilename = args.old_path.split('/').pop() ?? ''
const normalizedFolderPath = args.folder_path.trim().replace(/^\/+|\/+$/g, '')
if (!normalizedFolderPath) {
throw new Error('Folder path cannot be empty')
}
const vaultRoot = args.vault_path.replace(/\/+$/, '')
const newPath = `${vaultRoot}/${normalizedFolderPath}/${oldFilename}`
if (newPath === args.old_path) {
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
}
if (Object.prototype.hasOwnProperty.call(MOCK_CONTENT, newPath)) {
throw new Error('A note with that name already exists')
}
delete MOCK_CONTENT[args.old_path]
MOCK_CONTENT[newPath] = oldContent
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
export const mockHandlers: Record<string, (args: any) => any> = {
list_vault: () => MOCK_ENTRIES,
@@ -386,6 +422,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
rename_note_filename: handleRenameNoteFilename,
move_note_to_folder: handleMoveNoteToFolder,
clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => {
const localPath = args.localPath ?? args.local_path ?? ''
setMockRemoteState(localPath, true)

View File

@@ -62,6 +62,16 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
new_filename_stem: args.new_filename_stem,
},
} : null,
move_note_to_folder: (args) =>
args.old_path && args.folder_path ? {
url: '/api/vault/move-to-folder',
method: 'POST',
body: {
vault_path: args.vault_path,
old_path: args.old_path,
folder_path: args.folder_path,
},
} : null,
delete_note: (args) =>
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
search_vault: (args) => {