Compare commits

...

6 Commits

Author SHA1 Message Date
Luca Rossi
4d7252c78f feat: add command palette toggles for all BreadcrumbBar panels (#184)
Adds toggle commands to Cmd+K for:
- Toggle Properties Panel (prop/inspector)
- Toggle Diff Mode (diff) — disabled without note changes
- Toggle Backlinks (back) — disabled without note

Updates:
- useCommandRegistry.ts: 5 new commands with proper disabled states
- useAppCommands.ts: wires onToggleDiff and onToggleBacklinks
- Editor.tsx: added diffToggleRef prop (mirrors rawToggleRef pattern)
- App.tsx: creates diffToggleRef, passes to Editor, wires commands

10 new tests in useCommandRegistry.test.ts covering all new commands.

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 02:00:48 +01:00
Luca Rossi
eb9a3d889f fix: show all direct relationship properties in note list sidebar (#182)
The GroupBuilder's `seen` set was causing direct relationship properties
to be suppressed. Reverse/computed groups (Children, Events) ran before
the entity's own relationship keys, consuming entries into `seen` and
preventing direct properties like "Belongs to" and "Notes" from appearing.

Fix: process all direct relationship keys from entity.relationships
before the reverse groups, so direct properties always take priority.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:37:35 +01:00
Luca Rossi
6d2988b722 feat: trashed notes read-only with visible banner in editor (#181)
* feat: make trashed notes read-only with visible banner in editor

When a note is in the Trash, the editor now shows a banner below the
breadcrumb ("This note is in the Trash") with Restore and Delete
permanently buttons. The BlockNote editor is set to read-only mode,
preventing accidental edits while still allowing navigation and copy.

- Add TrashedNoteBanner component with restore/delete actions
- Pass editable={false} to BlockNoteView when note is trashed
- Add delete_note Rust command for permanent file deletion
- Wire onDeleteNote through Editor → EditorContent → App
- Extract EditorBody to reduce EditorContent cyclomatic complexity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: rustfmt fix

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:37:25 +01:00
Test
a33a000c57 fix: remove persistLastVault assertions from onboarding test 2026-03-03 00:59:09 +01:00
Test
f0b456bb8c fix: remove stale persistLastVault and unused imports after rebase 2026-03-03 00:57:47 +01:00
Test
b489fa8e3e fix: persist vault list across app updates
Vault list was stored only in React useState, lost on every app restart
or update. Now persisted to ~/.config/com.laputa.app/vaults.json via
Rust backend commands (load_vault_list, save_vault_list).

- Add vault_list.rs module with VaultEntry/VaultList types and JSON I/O
- Register load_vault_list/save_vault_list Tauri commands
- Extract vaultListStore.ts utility for frontend persistence calls
- Rewrite useVaultSwitcher to load on mount and persist on change
- Show unavailable vaults greyed out with warning icon instead of
  silently removing them
- Add mock handlers for browser/test environments
- Add useVaultSwitcher tests covering persistence, availability, dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:57:05 +01:00
25 changed files with 992 additions and 237 deletions

View File

@@ -1 +1 @@
3852
45402

View File

@@ -0,0 +1 @@
{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"·","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"↺","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"⚙","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"🗑","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"↺","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"🗑","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}}

View File

@@ -9,6 +9,7 @@ pub mod search;
pub mod settings;
pub mod theme;
pub mod vault;
pub mod vault_list;
use std::borrow::Cow;
use std::path::Path;
@@ -24,6 +25,7 @@ use search::SearchResponse;
use settings::Settings;
use theme::{ThemeFile, VaultSettings};
use vault::{RenameResult, VaultEntry};
use vault_list::VaultList;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -204,6 +206,12 @@ fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
vault::purge_trash(&vault_path)
}
#[tauri::command]
fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
@@ -275,13 +283,13 @@ fn save_settings(settings: Settings) -> Result<(), String> {
}
#[tauri::command]
fn get_last_vault_path() -> Option<String> {
settings::get_last_vault()
fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
fn set_last_vault_path(path: String) -> Result<(), String> {
settings::set_last_vault(&path)
fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
@@ -471,6 +479,21 @@ mod tests {
}
}
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -501,23 +524,7 @@ pub fn run() {
}
run_startup_tasks();
// Spawn the MCP WebSocket bridge for the default vault
{
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
}
}
spawn_ws_bridge(app);
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -543,14 +550,15 @@ pub fn run() {
save_image,
copy_image_to_vault,
purge_trash,
delete_note,
migrate_is_a_to_type,
batch_archive_notes,
batch_trash_notes,
get_settings,
update_menu_state,
save_settings,
get_last_vault_path,
set_last_vault_path,
load_vault_list,
save_vault_list,
github_list_repos,
github_create_repo,
clone_repo,

View File

@@ -11,7 +11,7 @@ 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::{rename_note, RenameResult};
pub use trash::purge_trash;
pub use trash::{delete_note, purge_trash};
use parsing::{
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,

View File

@@ -42,6 +42,21 @@ fn try_purge_file(path: &Path) -> Option<String> {
}
}
/// Permanently delete a single note file.
/// Returns the deleted path on success, or an error if the file doesn't exist.
pub fn delete_note(path: &str) -> Result<String, String> {
let file = Path::new(path);
if !file.exists() {
return Err(format!("File does not exist: {}", path));
}
if !file.is_file() {
return Err(format!("Path is not a file: {}", path));
}
fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?;
log::info!("Permanently deleted note: {}", path);
Ok(path.to_string())
}
/// Scan all markdown files in the vault and delete those where
/// `Trashed at` frontmatter is more than 30 days ago.
/// Returns the list of deleted file paths.
@@ -95,6 +110,28 @@ mod tests {
file.write_all(content.as_bytes()).unwrap();
}
#[test]
fn test_delete_note_removes_file() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"doomed.md",
"---\ntitle: Doomed\n---\n# Doomed\n",
);
let path = dir.path().join("doomed.md");
assert!(path.exists());
let result = delete_note(path.to_str().unwrap());
assert!(result.is_ok());
assert!(!path.exists());
}
#[test]
fn test_delete_note_nonexistent_file() {
let result = delete_note("/nonexistent/path/that/does/not/exist.md");
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_purge_trash_deletes_old_trashed_files() {
let dir = TempDir::new().unwrap();

140
src-tauri/src/vault_list.rs Normal file
View File

@@ -0,0 +1,140 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VaultEntry {
pub label: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultList {
pub vaults: Vec<VaultEntry>,
pub active_vault: Option<String>,
}
fn vault_list_path() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("vaults.json"))
.ok_or_else(|| "Could not determine config directory".to_string())
}
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
if !path.exists() {
return Ok(VaultList::default());
}
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e))
}
fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
let json = serde_json::to_string_pretty(list)
.map_err(|e| format!("Failed to serialize vault list: {}", e))?;
fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e))
}
pub fn load_vault_list() -> Result<VaultList, String> {
load_at(&vault_list_path()?)
}
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
save_at(&vault_list_path()?, list)
}
#[cfg(test)]
mod tests {
use super::*;
fn save_and_reload(list: &VaultList) -> VaultList {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("vaults.json");
save_at(&path, list).unwrap();
load_at(&path).unwrap()
}
#[test]
fn default_vault_list_is_empty() {
let vl = VaultList::default();
assert!(vl.vaults.is_empty());
assert!(vl.active_vault.is_none());
}
#[test]
fn roundtrip_preserves_data() {
let list = VaultList {
vaults: vec![
VaultEntry {
label: "My Vault".to_string(),
path: "/Users/luca/Laputa".to_string(),
},
VaultEntry {
label: "Work".to_string(),
path: "/Users/luca/Work".to_string(),
},
],
active_vault: Some("/Users/luca/Laputa".to_string()),
};
let loaded = save_and_reload(&list);
assert_eq!(loaded.vaults.len(), 2);
assert_eq!(loaded.vaults[0].label, "My Vault");
assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa");
assert_eq!(loaded.vaults[1].label, "Work");
assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa"));
}
#[test]
fn load_returns_default_for_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nonexistent.json");
let result = load_at(&path).unwrap();
assert!(result.vaults.is_empty());
assert!(result.active_vault.is_none());
}
#[test]
fn load_returns_error_for_malformed_json() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("bad.json");
fs::write(&path, "not valid json{{{").unwrap();
let err = load_at(&path).unwrap_err();
assert!(err.contains("Failed to parse vault list"));
}
#[test]
fn save_creates_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("vaults.json");
let list = VaultList {
vaults: vec![VaultEntry {
label: "Test".to_string(),
path: "/tmp/test".to_string(),
}],
active_vault: None,
};
save_at(&path, &list).unwrap();
assert!(path.exists());
let loaded = load_at(&path).unwrap();
assert_eq!(loaded.vaults.len(), 1);
}
#[test]
fn vault_list_path_returns_ok() {
let result = vault_list_path();
assert!(result.is_ok());
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
}
#[test]
fn empty_vault_list_roundtrip() {
let list = VaultList::default();
let loaded = save_and_reload(&list);
assert!(loaded.vaults.is_empty());
assert!(loaded.active_vault.is_none());
}
}

View File

@@ -33,6 +33,8 @@ import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import { extractOutgoingLinks } from './utils/wikilinks'
import type { SidebarSelection } from './types'
import './App.css'
@@ -248,6 +250,18 @@ function App() {
createTypeEntry: notes.createTypeEntrySilent,
})
const handleDeleteNote = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
notes.handleCloseTab(path)
vault.removeEntry(path)
setToastMessage('Note permanently deleted')
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
}
}, [notes, vault, setToastMessage])
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const handleCreateType = useCallback((name: string) => {
@@ -272,6 +286,8 @@ function App() {
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
const rawToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const zoom = useZoom()
@@ -293,7 +309,9 @@ function App() {
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
entries: vault.entries, allContent: vault.allContent,
modifiedCount: vault.modifiedFiles.length, selection,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
selection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate,
@@ -305,6 +323,7 @@ function App() {
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: () => rawToggleRef.current(),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
@@ -410,6 +429,7 @@ function App() {
vaultPath={resolvedPath}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
@@ -417,6 +437,7 @@ function App() {
onSave={handleSave}
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}

View File

@@ -44,7 +44,7 @@ vi.mock('@blocknote/react', () => ({
}))
vi.mock('@blocknote/mantine', () => ({
BlockNoteView: ({ children }: { children?: React.ReactNode }) => <div data-testid="blocknote-view">{children}</div>,
BlockNoteView: ({ children, editable }: { children?: React.ReactNode; editable?: boolean }) => <div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>{children}</div>,
}))
vi.mock('@blocknote/mantine/style.css', () => ({}))
@@ -294,6 +294,41 @@ describe('Editor', () => {
mockEditor.replaceBlocks.mockClear()
mockEditor.insertBlocks.mockClear()
})
describe('trashed note behavior', () => {
const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
const trashedTab = { entry: trashedEntry, content: mockContent }
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
}
it('shows banner and read-only editor when note is trashed', () => {
renderTrashed()
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
})
it('does not show banner and sets editable for normal notes', () => {
render(<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
})
it('calls onRestoreNote when banner restore is clicked', () => {
const onRestoreNote = vi.fn()
renderTrashed({ onRestoreNote })
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
expect(onRestoreNote).toHaveBeenCalledWith(trashedEntry.path)
})
it('calls onDeleteNote when banner delete is clicked', () => {
const onDeleteNote = vi.fn()
renderTrashed({ onDeleteNote })
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path)
})
})
})
describe('wikilink autocomplete', () => {

View File

@@ -50,6 +50,7 @@ interface EditorProps {
vaultPath?: string
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
onRenameTab?: (path: string, newTitle: string) => void
@@ -65,16 +66,19 @@ interface EditorProps {
isDarkTheme?: boolean
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
}
function useEditorModeExclusion({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
}: {
diffMode: boolean
rawMode: boolean
handleToggleDiff: () => void | Promise<void>
handleToggleRaw: () => void
rawToggleRef?: React.MutableRefObject<() => void>
diffToggleRef?: React.MutableRefObject<() => void>
}) {
const handleToggleDiffExclusive = useCallback(async () => {
if (!diffMode && rawMode) handleToggleRaw()
@@ -90,6 +94,10 @@ function useEditorModeExclusion({
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
}, [rawToggleRef, handleToggleRawExclusive])
useEffect(() => {
if (diffToggleRef) diffToggleRef.current = handleToggleDiffExclusive
}, [diffToggleRef, handleToggleDiffExclusive])
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
@@ -110,11 +118,12 @@ export const Editor = memo(function Editor({
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
rawToggleRef,
diffToggleRef,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -151,13 +160,12 @@ export const Editor = memo(function Editor({
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
})
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
const showRightPanel = !!(showAIChat || !inspectorCollapsed)
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
@@ -202,13 +210,14 @@ export const Editor = memo(function Editor({
onEditorChange={handleEditorChange}
onTrashNote={onTrashNote}
onRestoreNote={onRestoreNote}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
isDarkTheme={isDarkTheme}
/>
}
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
<EditorRightPanel
showAIChat={showAIChat}
inspectorCollapsed={inspectorCollapsed}

View File

@@ -2,6 +2,7 @@ import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
@@ -34,6 +35,7 @@ interface EditorContentProps {
onEditorChange?: () => void
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
@@ -97,7 +99,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
function ActiveTabBreadcrumb({ activeTab, props }: {
activeTab: Tab
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
}) {
const wordCount = countWords(activeTab.content)
const path = activeTab.entry.path
@@ -124,14 +126,38 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
)
}
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: {
activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean; diffContent: string | null; onToggleDiff: () => void
rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void
onNavigateWikilink: (target: string) => void; onEditorChange?: () => void
vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean
}) {
const showEditor = !diffMode && !rawMode
return (
<>
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
{showEditor && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
</div>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</>
)
}
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
onDeleteNote,
...breadcrumbProps
}: EditorContentProps) {
const showEditor = !diffMode && !rawMode
const isTrashed = activeTab?.entry.trashed ?? false
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
@@ -141,14 +167,13 @@ export function EditorContent({
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
{showEditor && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
</div>
{activeTab && isTrashed && (
<TrashedNoteBanner
onRestore={() => breadcrumbProps.onRestoreNote?.(activeTab.entry.path)}
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} />
</div>
)
}

View File

@@ -23,13 +23,14 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: {
editor: ReturnType<typeof useCreateBlockNote>
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onChange?: () => void
vaultPath?: string
isDarkTheme?: boolean
editable?: boolean
}) {
const navigateRef = useRef(onNavigateWikilink)
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
@@ -103,6 +104,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
editor={editor}
theme={isDarkTheme ? 'dark' : 'light'}
onChange={onChange}
editable={editable}
>
<SuggestionMenuController
triggerCharacter="[["

View File

@@ -6,6 +6,7 @@ import { openExternalUrl } from '../utils/url'
export interface VaultOption {
label: string
path: string
available?: boolean
}
interface StatusBarProps {
@@ -29,19 +30,36 @@ interface StatusBarProps {
buildNumber?: string
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
if (isActive) return <Check size={12} />
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
return <span style={{ width: 12 }} />
}
function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: unavailable ? 'not-allowed' : 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
}
}
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
const unavailable = vault.available === false
const canHover = !isActive && !unavailable
return (
<div
role="button" onClick={onSelect}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
role="button"
onClick={unavailable ? undefined : onSelect}
style={vaultItemStyle(isActive, unavailable)}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
data-testid={`vault-menu-item-${vault.label}`}
>
{isActive ? <Check size={12} /> : <span style={{ width: 12 }} />}
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</div>
)
@@ -116,21 +134,21 @@ const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cu
const SEP_STYLE = { color: 'var(--border)' } as const
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
if (status === 'syncing') return 'Syncing…'
if (status === 'conflict') return 'Conflict'
if (status === 'error') return 'Sync failed'
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
function formatElapsedSync(lastSyncTime: number | null): string {
if (!lastSyncTime) return 'Not synced'
const elapsed = Math.round((Date.now() - lastSyncTime) / 1000)
if (elapsed < 60) return 'Synced just now'
const mins = Math.floor(elapsed / 60)
return `Synced ${mins}m ago`
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago`
}
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime)
}
function syncIconColor(status: SyncStatus): string {
if (status === 'conflict') return 'var(--accent-orange)'
if (status === 'error') return 'var(--muted-foreground)'
return 'var(--accent-green)'
return SYNC_COLORS[status] ?? 'var(--accent-green)'
}
function CommitBadge({ info }: { info: LastCommitInfo }) {
@@ -156,17 +174,59 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void }) {
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
const isSyncing = status === 'syncing'
return (
<span
role="button"
onClick={onTriggerSync}
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={isSyncing ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
</span>
)
}
function ConflictBadge({ count }: { count: number }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
</span>
</>
)
}
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
</>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) {
// Force re-render every 30s to keep relative time label fresh
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
return () => clearInterval(id)
}, [])
const syncLabel = formatSyncLabel(syncStatus, lastSyncTime)
const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -174,38 +234,10 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onTriggerSync}
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={syncStatus === 'syncing' ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
</span>
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
{conflictCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
<AlertTriangle size={13} />{conflictCount} conflict{conflictCount > 1 ? 's' : ''}
</span>
</>
)}
{modifiedCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClickPending}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
</>
)}
<ConflictBadge count={conflictCount} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>

View File

@@ -0,0 +1,31 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TrashedNoteBanner } from './TrashedNoteBanner'
describe('TrashedNoteBanner', () => {
it('renders the banner with trash message', () => {
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
})
it('shows Restore and Delete permanently buttons', () => {
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
expect(screen.getByText('Restore')).toBeInTheDocument()
expect(screen.getByText('Delete permanently')).toBeInTheDocument()
})
it('calls onRestore when Restore button is clicked', () => {
const onRestore = vi.fn()
render(<TrashedNoteBanner onRestore={onRestore} onDeletePermanently={vi.fn()} />)
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
expect(onRestore).toHaveBeenCalledOnce()
})
it('calls onDeletePermanently when Delete permanently button is clicked', () => {
const onDeletePermanently = vi.fn()
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={onDeletePermanently} />)
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
expect(onDeletePermanently).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,44 @@
import { memo } from 'react'
import { Trash, ArrowCounterClockwise } from '@phosphor-icons/react'
interface TrashedNoteBannerProps {
onRestore: () => void
onDeletePermanently: () => void
}
export const TrashedNoteBanner = memo(function TrashedNoteBanner({
onRestore,
onDeletePermanently,
}: TrashedNoteBannerProps) {
return (
<div
className="flex shrink-0 items-center gap-3"
style={{
padding: '6px 16px',
background: 'var(--destructive-muted, color-mix(in srgb, var(--destructive) 8%, var(--background)))',
borderBottom: '1px solid var(--border)',
fontSize: 12,
}}
data-testid="trashed-note-banner"
>
<Trash size={14} style={{ color: 'var(--destructive)', flexShrink: 0 }} />
<span className="text-muted-foreground" style={{ flex: 1 }}>This note is in the Trash</span>
<button
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-primary hover:bg-accent"
onClick={onRestore}
data-testid="trashed-banner-restore"
>
<ArrowCounterClockwise size={12} />
Restore
</button>
<button
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-destructive hover:bg-destructive/10"
onClick={onDeletePermanently}
data-testid="trashed-banner-delete"
>
<Trash size={12} />
Delete permanently
</button>
</div>
)
})

View File

@@ -33,7 +33,9 @@ interface AppCommandsConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
activeNoteModified: boolean
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -135,7 +137,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommitPush: config.onCommitPush,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,

View File

@@ -36,6 +36,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
activeTabPath: null as string | null,
entries: [] as VaultEntry[],
modifiedCount: 0,
activeNoteModified: false,
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onCreateNoteOfType: vi.fn(),
@@ -264,6 +265,82 @@ describe('useCommandRegistry', () => {
expect(onToggleAIChat).toHaveBeenCalled()
})
it('has toggle-diff command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Toggle Diff Mode')
})
it('disables toggle-diff when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(false)
})
it('disables toggle-diff when note has no changes', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: false })),
)
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-diff when note has uncommitted changes', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true })),
)
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleDiff when toggle-diff executes', () => {
const onToggleDiff = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true, onToggleDiff })),
)
result.current.find(c => c.id === 'toggle-diff')!.execute()
expect(onToggleDiff).toHaveBeenCalledOnce()
})
it('has toggle-backlinks command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Toggle Backlinks')
})
it('disables toggle-backlinks when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-backlinks when a note is open', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
)
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleInspector when toggle-backlinks executes', () => {
const onToggleInspector = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleInspector })),
)
result.current.find(c => c.id === 'toggle-backlinks')!.execute()
expect(onToggleInspector).toHaveBeenCalledOnce()
})
it('toggle-inspector label includes Properties', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-inspector')
expect(cmd!.label).toBe('Toggle Properties Panel')
})
it('has open-daily-note command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'open-daily-note')

View File

@@ -33,8 +33,10 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
activeNoteModified: boolean
onCheckForUpdates?: () => void
isUpdating?: boolean
onZoomIn: () => void
@@ -109,8 +111,10 @@ export function buildTypeCommands(
export function buildViewCommands(
hasActiveNote: boolean,
activeNoteModified: boolean,
onSetViewMode: (mode: ViewMode) => void,
onToggleInspector: () => void,
onToggleDiff: (() => void) | undefined,
onToggleRawEditor: (() => void) | undefined,
onToggleAIChat: (() => void) | undefined,
zoomLevel: number,
@@ -122,9 +126,11 @@ export function buildViewCommands(
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
@@ -173,7 +179,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
@@ -227,7 +234,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
// View
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
// Appearance
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme),
@@ -243,10 +250,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates, isUpdating,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,

View File

@@ -21,7 +21,6 @@ vi.mock('../mock-tauri', () => ({
}))
vi.mock('./useVaultSwitcher', () => ({
persistLastVault: vi.fn(),
}))
vi.mock('../utils/vault-dialog', () => ({
@@ -30,7 +29,6 @@ vi.mock('../utils/vault-dialog', () => ({
import { useOnboarding } from './useOnboarding'
import { pickFolder } from '../utils/vault-dialog'
import { persistLastVault } from './useVaultSwitcher'
describe('useOnboarding', () => {
beforeEach(() => {
@@ -109,7 +107,6 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(persistLastVault).toHaveBeenCalledWith('/mock/Documents/Getting Started')
})
it('handleCreateVault sets error on failure', async () => {
@@ -154,7 +151,6 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(persistLastVault).toHaveBeenCalledWith('/selected/folder')
})
it('handleOpenFolder does nothing when picker is cancelled', async () => {

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { persistLastVault } from './useVaultSwitcher'
type OnboardingState =
| { status: 'loading' }
@@ -71,7 +70,6 @@ export function useOnboarding(initialVaultPath: string) {
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath: null })
markDismissed()
persistLastVault(vaultPath)
setState({ status: 'ready', vaultPath })
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
@@ -85,7 +83,6 @@ export function useOnboarding(initialVaultPath: string) {
const path = await pickFolder('Open vault folder')
if (!path) return
markDismissed()
persistLastVault(path)
setState({ status: 'ready', vaultPath: path })
} catch (err) {
setError(`Failed to open folder: ${err}`)

View File

@@ -1,133 +1,190 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useVaultSwitcher, DEFAULT_VAULTS } from './useVaultSwitcher'
import type { PersistedVaultList } from './useVaultSwitcher'
const mockInvokeFn = vi.fn()
let mockVaultListStore: PersistedVaultList = { vaults: [], active_vault: null }
const mockInvokeFn = vi.fn((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(true)
return Promise.resolve(null)
})
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
import { useVaultSwitcher, DEFAULT_VAULTS, persistLastVault } from './useVaultSwitcher'
import { pickFolder } from '../utils/vault-dialog'
describe('useVaultSwitcher', () => {
const onSwitch = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_last_vault_path') return null
if (cmd === 'set_last_vault_path') return null
return null
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null }
// Re-set default implementation after resetAllMocks
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(true)
return Promise.resolve(null)
})
})
it('starts with the default vault path', () => {
it('starts with default vaults', () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
})
it('loads last vault path from backend on mount', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_last_vault_path') return '/Users/test/MyVault'
if (cmd === 'set_last_vault_path') return null
return null
it('loads persisted vaults on mount', async () => {
mockVaultListStore = {
vaults: [{ label: 'My Vault', path: '/Users/luca/Laputa' }],
active_vault: '/Users/luca/Laputa',
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(result.current.loaded).toBe(true)
})
expect(result.current.allVaults).toHaveLength(2) // default + persisted
expect(result.current.allVaults[1].label).toBe('My Vault')
expect(result.current.allVaults[1].path).toBe('/Users/luca/Laputa')
expect(result.current.allVaults[1].available).toBe(true)
expect(result.current.vaultPath).toBe('/Users/luca/Laputa')
expect(mockInvokeFn).toHaveBeenCalledWith('load_vault_list', {})
})
it('marks unavailable vaults when check_vault_exists returns false', async () => {
mockVaultListStore = {
vaults: [{ label: 'External', path: '/Volumes/USB/vault' }],
active_vault: null,
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(false)
return Promise.resolve(null)
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(result.current.vaultPath).toBe('/Users/test/MyVault')
expect(result.current.loaded).toBe(true)
})
expect(result.current.allVaults[1].available).toBe(false)
expect(result.current.allVaults[1].label).toBe('External')
})
it('falls back to default when no last vault is stored', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_last_vault_path') return null
return null
})
it('persists vault list when adding a vault via handleVaultCloned', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
// Wait for the async load to complete
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_last_vault_path', {})
})
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
})
it('falls back to default when get_last_vault_path fails', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_last_vault_path') throw new Error('read failed')
return null
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
// Wait for the async load to complete
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_last_vault_path', {})
})
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
})
it('persists vault path when switching', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.switchVault('/Users/test/NewVault')
result.current.handleVaultCloned('/cloned/vault', 'Cloned')
})
expect(result.current.vaultPath).toBe('/Users/test/NewVault')
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/NewVault' })
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
list: expect.objectContaining({
vaults: expect.arrayContaining([
expect.objectContaining({ label: 'Cloned', path: '/cloned/vault' }),
]),
}),
}))
})
})
it('persists vault path on handleOpenLocalFolder', async () => {
vi.mocked(pickFolder).mockResolvedValue('/Users/test/OpenedVault')
it('persists active vault when switching', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: null,
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.switchVault('/work/vault')
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
list: expect.objectContaining({
active_vault: '/work/vault',
}),
}))
})
expect(onSwitch).toHaveBeenCalled()
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'load_vault_list') return Promise.reject(new Error('disk error'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
// Should fall back to defaults
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
warnSpy.mockRestore()
})
it('does not duplicate vaults with same path', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.handleVaultCloned('/some/vault', 'First')
})
act(() => {
result.current.handleVaultCloned('/some/vault', 'Duplicate')
})
const extras = result.current.allVaults.filter(v => v.path === '/some/vault')
expect(extras).toHaveLength(1)
})
it('opens local folder and persists', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/MyVault')
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
await act(async () => {
await result.current.handleOpenLocalFolder()
})
expect(result.current.vaultPath).toBe('/Users/test/OpenedVault')
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/OpenedVault' })
})
it('persists vault path on handleVaultCloned', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
act(() => {
result.current.handleVaultCloned('/Users/test/ClonedVault', 'ClonedVault')
})
expect(result.current.vaultPath).toBe('/Users/test/ClonedVault')
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/ClonedVault' })
})
})
describe('persistLastVault', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockResolvedValue(null)
})
it('calls set_last_vault_path with the given path', () => {
persistLastVault('/Users/test/Vault')
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/Vault' })
})
it('does not throw when the backend call fails', () => {
mockInvokeFn.mockRejectedValue(new Error('write failed'))
expect(() => persistLastVault('/Users/test/Vault')).not.toThrow()
expect(result.current.allVaults.some(v => v.path === '/Users/luca/MyVault')).toBe(true)
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
})

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
export type { PersistedVaultList } from '../utils/vaultListStore'
export const DEFAULT_VAULTS: VaultOption[] = [
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
@@ -13,62 +14,79 @@ interface UseVaultSwitcherOptions {
onToast: (msg: string) => void
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
function labelFromPath(path: string): string {
return path.split('/').pop() || 'Local Vault'
}
export function persistLastVault(path: string): void {
tauriCall('set_last_vault_path', { path }).catch(() => {})
}
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [loaded, setLoaded] = useState(false)
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
// On mount, load the last vault path from persistent storage
useEffect(() => {
tauriCall<string | null>('get_last_vault_path', {}).then((saved) => {
if (saved) setVaultPath(saved)
}).catch(() => {})
}, [])
// Refs ensure stable callbacks that always invoke the latest closures,
// breaking the circular dependency between useVaultSwitcher and downstream hooks.
const onSwitchRef = useRef(onSwitch)
const onToastRef = useRef(onToast)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
const hasLoadedRef = useRef(false)
useEffect(() => {
let cancelled = false
loadVaultList()
.then(({ vaults, activeVault }) => {
if (cancelled) return
setExtraVaults(vaults)
if (activeVault) {
setVaultPath(activeVault)
onSwitchRef.current()
}
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
hasLoadedRef.current = true
setLoaded(true)
})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!hasLoadedRef.current) return
saveVaultList(extraVaults, vaultPath).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, vaultPath])
const addVault = useCallback((path: string, label: string) => {
setExtraVaults(prev => prev.some(v => v.path === path) ? prev : [...prev, { label, path }])
setExtraVaults(prev => {
const exists = prev.some(v => v.path === path)
return exists ? prev : [...prev, { label, path, available: true }]
})
}, [])
const switchVault = useCallback((path: string) => {
setVaultPath(path)
persistLastVault(path)
onSwitchRef.current()
}, [])
const handleVaultCloned = useCallback((path: string, label: string) => {
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
onToastRef.current(`Vault "${label}" cloned and opened`)
}, [addVault, switchVault])
const handleVaultCloned = useCallback((path: string, label: string) => {
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" cloned and opened`)
}, [addAndSwitch])
const handleOpenLocalFolder = useCallback(async () => {
try {
const path = await pickFolder('Open vault folder')
if (!path) return
const label = path.split('/').pop() || 'Local Vault'
addVault(path, label)
switchVault(path)
onToastRef.current(`Vault "${label}" opened`)
} catch (err) {
console.error('Failed to open local folder:', err)
onToastRef.current(`Failed to open folder: ${err}`)
}
}, [addVault, switchVault])
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch])
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder }
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded }
}

View File

@@ -86,6 +86,11 @@ let mockLastVaultPath: string | null = null
let mockVaultSettings: VaultSettings = { theme: null }
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
vaults: [],
active_vault: null,
}
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
@@ -199,6 +204,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}
return null
},
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
@@ -214,6 +221,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}),
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
purge_trash: () => [],
delete_note: (args: { path: string }) => args.path,
migrate_is_a_to_type: () => 0,
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups } from './noteListHelpers'
import type { VaultEntry } from '../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
@@ -154,3 +154,174 @@ describe('relativeDate', () => {
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
})
})
// --- buildRelationshipGroups tests ---
function makeVault(overrides: Partial<VaultEntry>[]): VaultEntry[] {
return overrides.map((o, i) => makeEntry({
path: `/Laputa/note/entry-${i}.md`,
filename: `entry-${i}.md`,
title: `Entry ${i}`,
modifiedAt: 1700000000 - i * 100,
...o,
}))
}
describe('buildRelationshipGroups', () => {
it('shows direct relationship properties from entity.relationships', () => {
const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, building], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
})
it('shows all direct relationships even when entries also appear as Children', () => {
// The entity has "Notes" pointing at note1 and note2.
// Those notes also have belongsTo pointing back at the entity.
// Previously, Children consumed them via the seen set, suppressing "Notes".
const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, note1, note2], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Notes')
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
it('shows all 5+ direct relationship properties', () => {
const entries = makeVault([
{ path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' },
{ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' },
{ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' },
{ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' },
{ path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' },
{ path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' },
])
const entity = makeEntry({
path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project',
relationships: {
'Belongs to': ['[[area/eng]]'],
Notes: ['[[note/n1]]', '[[note/n2]]'],
Owner: ['[[person/alice]]'],
'Related to': ['[[project/sibling]]'],
Topics: ['[[topic/rust]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, ...entries], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(labels).toContain('Notes')
expect(labels).toContain('Owner')
expect(labels).toContain('Related to')
expect(labels).toContain('Topics')
})
it('shows Children group for reverse belongsTo entries not covered by direct rels', () => {
const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, child], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Children')
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
})
it('excludes Type key from relationship groups', () => {
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Type: ['[[type/project]]'] },
})
const groups = buildRelationshipGroups(entity, [entity], {})
const labels = groups.map((g) => g.label)
expect(labels).not.toContain('Type')
})
it('returns empty groups for entity with no relationships', () => {
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
const groups = buildRelationshipGroups(entity, [entity], {})
expect(groups).toHaveLength(0)
})
it('shows single-item and multi-item relationship properties', () => {
const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' })
const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' })
const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' })
const entity = makeEntry({
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
relationships: {
Owner: ['[[person/alice]]'],
Notes: ['[[note/n1]]', '[[note/n2]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {})
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
it('shows Instances group for Type entities', () => {
const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 })
const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 })
const typeEntity = makeEntry({
path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project',
isA: 'Type', relationships: {},
})
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Instances')
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
})
it('direct relationships are sorted alphabetically', () => {
const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' })
const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' })
const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' })
const entity = makeEntry({
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
relationships: {
Zebra: ['[[note/c]]'],
Alpha: ['[[note/a]]'],
Middle: ['[[note/b]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, a, b, c], {})
const directLabels = groups.map((g) => g.label)
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
})
it('Referenced By shows entries whose relatedTo matches the entity', () => {
const referer = makeEntry({
path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer',
relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, referer], {})
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
})
it('Backlinks shows entries that mention the entity via wikilinks in content', () => {
const linker = makeEntry({
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' }
const groups = buildRelationshipGroups(entity, [entity, linker], allContent)
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
})
})

View File

@@ -220,17 +220,16 @@ export function buildRelationshipGroups(
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
}
b.addFromRefs('Has', rels['Has'] ?? [])
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.addFromRefs('Topics', rels['Topics'] ?? [])
const handledKeys = new Set(['Has', 'Topics'])
// Direct relationships first — all keys from entity.relationships take
// priority so that reverse/computed groups (Children, Events, Referenced By)
// only show *additional* entries not already covered by a direct property.
Object.keys(rels)
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
.filter((k) => k.toLowerCase() !== 'type')
.sort((a, b) => a.localeCompare(b))
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))

View File

@@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultOption } from '../components/StatusBar'
export interface PersistedVaultList {
vaults: Array<{ label: string; path: string }>
active_vault: string | null
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function checkAvailability(v: { label: string; path: string }): Promise<VaultOption> {
try {
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
return { label: v.label, path: v.path, available: exists }
} catch {
return { label: v.label, path: v.path, available: false }
}
}
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null }> {
const data = await tauriCall<PersistedVaultList>('load_vault_list', {})
const persisted = data?.vaults ?? []
const checked = await Promise.all(persisted.map(checkAvailability))
return { vaults: checked, activeVault: data?.active_vault ?? null }
}
export function saveVaultList(vaults: VaultOption[], activeVault: string): Promise<void> {
const list: PersistedVaultList = {
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
active_vault: activeVault,
}
return tauriCall('save_vault_list', { list })
}