Compare commits

...

22 Commits

Author SHA1 Message Date
lucaronin
cec1627a81 fix: preserve image toolbar hover bridge 2026-04-18 15:12:39 +02:00
lucaronin
b00183419e fix: show Note metadata in autocomplete 2026-04-18 13:37:15 +02:00
lucaronin
ed9ceff1d2 fix: align Notes counts with explicit type 2026-04-18 13:07:17 +02:00
lucaronin
e18491bcd3 fix: preserve untitled rename session continuity 2026-04-18 12:17:24 +02:00
lucaronin
fd0cfc7fb6 fix: exclude attachments from all notes totals 2026-04-18 11:53:06 +02:00
lucaronin
6541ce5755 test: stabilize wikilink smoke timing 2026-04-18 11:19:41 +02:00
lucaronin
4456801526 fix: keep h1 typing responsive 2026-04-18 11:06:55 +02:00
lucaronin
db7aaf1385 fix: restore note type during vault repair 2026-04-18 10:38:50 +02:00
lucaronin
54b2616fa4 fix: restore manual commit dialog when autogit is off 2026-04-18 09:58:49 +02:00
lucaronin
6a90e9f770 fix: correct editor layout diff callback type 2026-04-18 09:28:18 +02:00
lucaronin
b79c78a5ba fix: save raw note changes before switching notes 2026-04-18 09:28:18 +02:00
Luca Rossi
48a8882970 Reorder CodeScene badges in README 2026-04-18 09:16:06 +02:00
Luca Rossi
98276702a2 Added codescene badges 2026-04-18 09:14:59 +02:00
lucaronin
857d55a7cd fix: stop cmd+s from renaming note titles 2026-04-18 08:25:43 +02:00
lucaronin
b78dfad384 fix: resume onboarding after telemetry on fresh start 2026-04-18 03:10:41 +02:00
lucaronin
9ac51bab9a test: pin app test active vault 2026-04-18 02:35:41 +02:00
lucaronin
1fee994863 test: cover mock vault config path 2026-04-18 02:21:48 +02:00
lucaronin
764bbf0e4c test: scope inbox sidebar assertion 2026-04-18 02:10:54 +02:00
lucaronin
ff05ba0430 chore: ratchet codescene thresholds 2026-04-18 01:56:12 +02:00
lucaronin
347df47c62 fix: stabilize smoke vault harness 2026-04-18 01:50:29 +02:00
lucaronin
7536169508 fix: resume onboarding after telemetry consent 2026-04-18 00:21:52 +02:00
lucaronin
34de384738 fix: preserve yml views in raw editor 2026-04-17 23:32:36 +02:00
61 changed files with 2885 additions and 658 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.86
AVERAGE_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.7

View File

@@ -1,3 +1,4 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
# Tolaria App
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.

View File

@@ -578,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `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`, repairs missing config files |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -615,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
@@ -659,7 +659,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|---------|-------------|
| `get_vault_settings` | Read `.laputa/settings.json` |
| `save_vault_settings` | Write vault settings |
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore config |
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore root config/type defaults including `note.md` |
### AI & MCP

View File

@@ -19,6 +19,17 @@ sidebar label: Config
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
";
/// Content for `note.md` — restores the default Note type definition when missing.
const NOTE_TYPE_DEFINITION: &str = "\
---
type: Type
---
# Note
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
";
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
@@ -113,8 +124,8 @@ fn classify_guidance_file(
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
let vault = Path::new(vault_path);
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
let vault = vault_path;
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
}
@@ -137,7 +148,7 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
)
}
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
let (agents_path, claude_path) = guidance_paths(vault_path);
let agents_state = classify_agents_file(&agents_path);
let claude_state = classify_claude_file(&claude_path);
@@ -150,12 +161,12 @@ fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
}
}
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
@@ -203,34 +214,45 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
Ok(true)
}
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
pub fn get_ai_guidance_status(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
}
pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
sync_ai_guidance_files(vault_path)?;
get_ai_guidance_status(vault_path)
}
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
/// Also seeds `config.md` type definition for sidebar visibility.
pub fn seed_config_files(vault_path: &str) {
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
ensure_config_type_definition(vault_path);
ensure_root_type_definitions(vault_path);
}
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) {
let path = Path::new(vault_path).join("config.md");
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
let path = vault_path.join(file_name);
let _ = write_if_missing(&path, content);
}
/// Ensure the default root type definitions exist for opened/repaired vaults.
fn ensure_root_type_definitions(vault_path: &Path) {
ensure_root_type_definition(vault_path, "config.md", CONFIG_TYPE_DEFINITION);
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
}
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
@@ -241,8 +263,8 @@ fn ensure_config_type_definition(vault_path: &str) {
/// - Cleans up empty `config/` directory after migration.
///
/// Always idempotent and silent.
pub fn migrate_agents_md(vault_path: &str) {
let vault = Path::new(vault_path);
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
@@ -259,22 +281,23 @@ pub fn migrate_agents_md(vault_path: &str) {
log::info!("Removed empty config/ directory");
}
let _ = sync_ai_guidance_files(vault_path);
let _ = sync_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
/// Migrates legacy `config/agents.md` to root if present.
/// Called by the "Repair Vault" command. Returns a status message.
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault_path)?;
sync_ai_guidance_files(vault)?;
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string())
}
@@ -417,15 +440,19 @@ mod tests {
}
#[test]
fn test_seed_config_files_creates_type_definition() {
fn test_seed_config_files_creates_type_definitions() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("config.md").exists());
let content = fs::read_to_string(vault.join("config.md")).unwrap();
assert!(content.contains("type: Type"));
assert!(content.contains("icon: gear-six"));
assert!(vault.join("note.md").exists());
let config_content = fs::read_to_string(vault.join("config.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(config_content.contains("type: Type"));
assert!(config_content.contains("icon: gear-six"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
}
@@ -543,10 +570,14 @@ mod tests {
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("config.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));
}
#[test]

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -76,8 +76,14 @@ const mockAllContent: Record<string, string> = {
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
}
const mockVaultList = {
vaults: [{ label: 'Test Vault', path: '/vault' }],
active_vault: '/vault',
hidden_defaults: [],
}
const mockCommandResults: Record<string, unknown> = {
load_vault_list: { vaults: [], active_vault: null, hidden_defaults: [] },
load_vault_list: mockVaultList,
list_vault: mockEntries,
list_vault_folders: [],
list_views: [],
@@ -94,9 +100,42 @@ const mockCommandResults: Record<string, unknown> = {
get_vault_settings: { theme: null },
}
function resetMockCommandResults() {
Object.assign(mockCommandResults, {
load_vault_list: mockVaultList,
list_vault: mockEntries,
list_vault_folders: [],
list_views: [],
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
get_file_history: [],
get_settings: {
auto_pull_interval_minutes: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
},
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Getting Started',
list_themes: [],
get_vault_settings: { theme: null },
})
}
function resolveMockCommandResult(cmd: string, args?: unknown) {
const result = mockCommandResults[cmd]
return typeof result === 'function'
? (result as (input?: unknown) => unknown)(args)
: result ?? null
}
vi.mock('./mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
}))
@@ -156,6 +195,7 @@ const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dis
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockCommandResults()
localStorage.clear()
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
@@ -204,6 +244,68 @@ describe('App', () => {
})
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('telemetry-accept'))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it.each([
['telemetry-accept', 'Allow anonymous reporting'],
['telemetry-decline', 'No thanks'],
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = {
vaults: [],
active_vault: rememberedDefaultVaultPath,
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(buttonTestId))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('renders sidebar with correct default selection (All Notes)', async () => {
render(<App />)
await waitFor(() => {
@@ -214,6 +316,11 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',
hidden_defaults: [],
}
const disabledWorkflowConfig = JSON.stringify({
zoom: null,
view_mode: null,
@@ -223,13 +330,19 @@ describe('App', () => {
property_display_modes: null,
inbox: { noteListProperties: null, explicitOrganization: false },
})
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
const vaultPaths = [
'/Users/mock/Documents/Getting Started',
'/Users/mock/demo-vault-v2',
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
]
for (const path of vaultPaths) {
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
}
render(<App />)
await waitFor(() => {
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
expect(within(screen.getByTestId('sidebar-top-nav')).queryByText('Inbox')).not.toBeInTheDocument()
expect(screen.getByText('All Notes')).toBeInTheDocument()
})
})

View File

@@ -35,9 +35,10 @@ import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { triggerCommitEntryAction } from './utils/commitEntryAction'
import { generateCommitMessage } from './utils/commitMessage'
import { useDialogs } from './hooks/useDialogs'
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useAutoSync } from './hooks/useAutoSync'
@@ -206,22 +207,40 @@ function App() {
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const { handleVaultCloned } = vaultSwitcher
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
if (!vaultPath) return
if (allVaults.some((vault) => vault.path === vaultPath)) {
switchVault(vaultPath)
return
}
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
handleVaultCloned(vaultPath, label)
}, [allVaults, handleVaultCloned, switchVault])
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
rememberOnboardingVaultChoice(vaultPath)
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
}, [handleVaultCloned])
}, [rememberOnboardingVaultChoice])
const cloneGettingStartedVault = useGettingStartedClone({
onError: (message) => setToastMessage(message),
onSuccess: handleGettingStartedVaultReady,
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
handleGettingStartedVaultReady(vaultPath)
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
useEffect(() => {
if (onboarding.state.status !== 'ready') return
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
// The active vault path can temporarily come from onboarding before the
// persisted vault switcher catches up to the newly cloned starter vault.
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
@@ -336,12 +355,20 @@ function App() {
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const notes = useNoteActions({
addEntry: vault.addEntry,
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
flushBeforeNoteSwitch: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforePathRename: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -634,6 +661,7 @@ function App() {
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
})
const recordAutoGitActivity = autoGit.recordActivity
const openCommitDialog = commitFlow.openCommitDialog
const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint
const handleAppContentChange = appSave.handleContentChange
const handleAppSave = appSave.handleSave
@@ -644,9 +672,13 @@ function App() {
recordAutoGitActivity()
}, [modifiedFilesSignature, recordAutoGitActivity])
const handleQuickCommitPush = useCallback(() => {
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
}, [runAutomaticCheckpoint])
const handleCommitPush = useCallback(() => {
triggerCommitEntryAction({
autoGitEnabled: settings.autogit_enabled === true,
openCommitDialog,
runAutomaticCheckpoint,
})
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
const handleTrackedContentChange = useCallback((path: string, content: string) => {
recordAutoGitActivity()
@@ -899,6 +931,17 @@ function App() {
) ?? null
}, [notes.activeTabPath, vault.modifiedFiles])
const activeCommandEntry = useMemo(() => {
if (!notes.activeTabPath) return null
return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
?? null
}, [notes.activeTabPath, notes.tabs, vault.entries])
const canToggleRichEditor = !!activeCommandEntry
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -916,13 +959,13 @@ function App() {
onOpenFeedback: openFeedback,
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onCommitPush: handleCommitPush,
onPull: autoSync.triggerSync,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -987,16 +1030,45 @@ function App() {
return { type: null, query: '' }
}, [effectiveSelection])
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
}
const shouldResumeFreshStartOnboarding = useMemo(() => {
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
return remembersOnlyDefaultVault
&& vaultSwitcher.allVaults.length === 1
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows).
// After the user answers, the next render can continue into onboarding.
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
const id = crypto.randomUUID()
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
}}
onDecline={() => {
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
}}
/>
)
}
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = shouldResumeFreshStartOnboarding
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
: onboarding
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
return (
<>
@@ -1026,21 +1098,6 @@ function App() {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows)
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
const id = crypto.randomUUID()
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
}}
onDecline={() => {
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
}}
/>
)
}
return (
<div className="app-shell">
<div className="app">
@@ -1117,12 +1174,13 @@ function App() {
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} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleQuickCommitPush} 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={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<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} 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={installMcp} 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} />

View File

@@ -658,7 +658,7 @@ describe('wikilink autocomplete', () => {
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})
it('shows correct noteType and color for typed entries, neutral for untyped', async () => {
it('shows Note chips and icons for explicit Note entries while keeping untyped entries neutral', async () => {
const mixedEntries: VaultEntry[] = [
{ ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
{ ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
@@ -675,20 +675,24 @@ describe('wikilink autocomplete', () => {
/>
)
const items = await capturedGetItems!('Test')
// Typed entries should have noteType and color
// Typed entries should have noteType, color, and a left-side icon
const project = items.find((i: { title: string }) => i.title === 'Test Project')
expect(project).toBeDefined()
expect(project!.noteType).toBe('Project')
expect(project!.typeColor).toBeTruthy()
// Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral)
expect(project!.TypeIcon).toBeTruthy()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBe('Note')
expect(explicitNote!.typeColor).toBeTruthy()
expect(explicitNote!.TypeIcon).toBeTruthy()
// Untyped entries should remain neutral
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
expect(plainNote).toBeDefined()
expect(plainNote!.noteType).toBeUndefined()
expect(plainNote!.typeColor).toBeUndefined()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBeUndefined()
expect(explicitNote!.typeColor).toBeUndefined()
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})

View File

@@ -87,6 +87,8 @@ interface EditorProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}
function useEditorModeExclusion({
@@ -224,39 +226,147 @@ function useEditorSetup({
}
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useEffect(() => {
if (!flushPendingRawContentRef) return
flushPendingRawContentRef.current = flushPendingRawContent
return () => {
if (flushPendingRawContentRef.current === flushPendingRawContent) {
flushPendingRawContentRef.current = null
}
}
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function EditorLayout({
tabs,
activeTab,
isLoadingNewTab,
entries,
editor,
diffMode,
diffContent,
diffLoading,
handleToggleDiffExclusive,
rawMode,
handleToggleRawExclusive,
onContentChange,
onSave,
activeStatus,
showDiffToggle,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
vaultPath,
rawModeContent,
rawLatestContentRef,
onRenameFilename,
isConflicted,
onKeepMine,
onKeepTheirs,
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
gitHistory,
noteList,
noteListFilter,
handleViewCommitDiff,
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
onCreateAndOpenNote,
onInitializeProperties,
onFileCreated,
onFileModified,
onVaultChanged,
}: {
tabs: Tab[]
activeTab: Tab | null
isLoadingNewTab: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
diffContent: string | null
diffLoading: boolean
handleToggleDiffExclusive: () => void | Promise<void>
rawMode: boolean
handleToggleRawExclusive: () => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
gitHistory: GitCommit[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
handleViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
@@ -330,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
</div>
</div>
)
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
} = props
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
})
return (
<EditorLayout
tabs={tabs}
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
editor={editor}
diffMode={diffMode}
diffContent={diffContent}
diffLoading={diffLoading}
handleToggleDiffExclusive={handleToggleDiffExclusive}
rawMode={rawMode}
handleToggleRawExclusive={handleToggleRawExclusive}
onContentChange={onContentChange}
onSave={onSave}
activeStatus={activeStatus}
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
gitHistory={gitHistory}
noteList={noteList}
noteListFilter={noteListFilter}
handleViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
)
})

View File

@@ -262,6 +262,23 @@ describe('NoteList filter pills', () => {
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows only explicit Note entries for the Notes type filter', () => {
const noteEntries = [
makeEntry({ title: 'Note Type', isA: 'Type', path: '/types/note.md', filename: 'note.md' }),
makeEntry({ title: 'Explicit Note', isA: 'Note', path: '/explicit-note.md', filename: 'explicit-note.md' }),
makeEntry({ title: 'Untyped Note', isA: null, path: '/untyped-note.md', filename: 'untyped-note.md' }),
makeEntry({ title: 'Archived Explicit Note', isA: 'Note', archived: true, path: '/archived-note.md', filename: 'archived-note.md' }),
]
renderNoteList({ entries: noteEntries, selection: { kind: 'sectionGroup', type: 'Note' } })
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
expect(screen.queryByText('Untyped Note')).not.toBeInTheDocument()
expect(screen.queryByText('Archived Explicit Note')).not.toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('1')
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
})
it('shows the archived empty state when a section has no archived notes', () => {
renderNoteList({
entries: projectEntries.filter((entry) => !entry.archived),

View File

@@ -780,6 +780,14 @@ describe('Sidebar', () => {
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/binary-note.pdf', filename: 'binary-note.pdf', title: 'Binary Note',
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, modifiedAt: 1700000000,
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
outgoingLinks: [], properties: {}, fileKind: 'binary',
},
]
it('shows Notes section when Note entries exist', () => {
@@ -787,13 +795,40 @@ describe('Sidebar', () => {
expect(screen.getByText('Notes')).toBeInTheDocument()
})
it('counts both explicit and untyped notes in Notes section chip', () => {
it('counts only explicit Note entries in the Notes section chip', () => {
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
})
it('ignores non-markdown Note entries in the Notes section chip', () => {
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
expect(notesHeader.textContent).not.toContain('2')
})
it('keeps the Notes section count aligned when an entry changes to or from Note', () => {
const { rerender } = render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
let notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
const withoutExplicitNote = noteEntries.map((entry) =>
entry.path === '/vault/explicit-note.md' ? { ...entry, isA: null } : entry,
)
rerender(<Sidebar entries={withoutExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toBe('Notes')
const withNewExplicitNote = noteEntries.map((entry) =>
entry.path === '/vault/untyped-note.md' ? { ...entry, isA: 'Note' } : entry,
)
rerender(<Sidebar entries={withNewExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('2')
})
it('shows Notes section for untyped entries even without explicit Note entries', () => {
it('does not show Notes section for untyped entries without explicit Note entries', () => {
const untypedOnly: VaultEntry[] = [
{
path: '/vault/plain.md', filename: 'plain.md', title: 'Plain Note',
@@ -805,7 +840,7 @@ describe('Sidebar', () => {
},
]
render(<Sidebar entries={untypedOnly} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Notes')).toBeInTheDocument()
expect(screen.queryByText('Notes')).not.toBeInTheDocument()
})
})
@@ -874,6 +909,88 @@ describe('Sidebar', () => {
expect(topNav.children[0].textContent).toContain('All Notes')
})
it('excludes attachments-folder markdown from top-nav note totals', () => {
const entries: VaultEntry[] = [
{
path: '/vault/note/real-note.md',
filename: 'real-note.md',
title: 'Real Note',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 310, snippet: '', wordCount: 120,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/reference.md',
filename: 'reference.md',
title: 'Attachment Markdown',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 220, snippet: '', wordCount: 50,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/nested/archive.md',
filename: 'archive.md',
title: 'Attachment Archive',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: true,
modifiedAt: 1700000000, createdAt: null,
fileSize: 180, snippet: '', wordCount: 25,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/archive/real-archive.md',
filename: 'real-archive.md',
title: 'Real Archive',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: true,
modifiedAt: 1700000000, createdAt: null,
fileSize: 280, snippet: '', wordCount: 90,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/image.png',
filename: 'image.png',
title: 'image.png',
isA: null,
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 1024, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
fileKind: 'binary',
outgoingLinks: [], properties: {},
},
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
const topNav = screen.getByTestId('sidebar-top-nav')
expect(topNav.children[1].textContent).toContain('All Notes1')
expect(topNav.children[2].textContent).toContain('Archive1')
})
it('does not show inline entries — no child items in type sections', () => {
const entriesWithEmoji: VaultEntry[] = [
{

View File

@@ -179,6 +179,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
}))),

View File

@@ -27,4 +27,9 @@ describe('TelemetryConsentDialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByText(/no vault content, note titles/i)).toBeDefined()
})
it('focuses the first action for keyboard users', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
})
})

View File

@@ -46,6 +46,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
onClick={onDecline}
data-testid="telemetry-decline"
autoFocus
>
No thanks
</button>

View File

@@ -30,6 +30,11 @@ describe('WelcomeScreen', () => {
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
it('focuses the first action for keyboard users', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
})
it('shows default vault path in template option description', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()

View File

@@ -160,6 +160,7 @@ interface OptionButtonProps {
disabled: boolean
loading?: boolean
testId: string
autoFocus?: boolean
}
function OptionButton({
@@ -173,6 +174,7 @@ function OptionButton({
disabled,
loading,
testId,
autoFocus = false,
}: OptionButtonProps) {
const [hover, setHover] = useState(false)
return (
@@ -188,6 +190,7 @@ function OptionButton({
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
data-testid={testId}
autoFocus={autoFocus}
>
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
@@ -278,6 +281,7 @@ export function WelcomeScreen({
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
/>
<OptionButton

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
describe('blockNoteFormattingToolbarHoverGuard', () => {
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 368, y: 104 },
rect(300, 130, 140, 90),
rect(322, 78, 96, 24),
),
).toBe(true)
})
it('suppresses hover updates when the pointer is already over the toolbar', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = document.createElement('button')
toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: toolbarButton,
point: { x: 350, y: 90 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 368, y: 104 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 520, y: 220 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
})
})

View File

@@ -0,0 +1,179 @@
import { useEffect, useRef, type RefObject } from 'react'
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
const HOVER_BRIDGE_PADDING_X = 8
const HOVER_BRIDGE_PADDING_Y = 8
function isVisibleRect(rect: RectLike) {
return rect.right > rect.left && rect.bottom > rect.top
}
function getSelectedFileBlockBridgeElement(
container: HTMLElement,
blockId: string,
) {
const selectedBlock = container.querySelector<HTMLElement>(
`.bn-block[data-id="${blockId}"]`,
)
if (!selectedBlock) return null
return (
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-visual-media-wrapper',
) ??
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-file-name-with-icon',
) ??
selectedBlock.querySelector<HTMLElement>('[data-file-block] .bn-add-file-button') ??
selectedBlock.querySelector<HTMLElement>('[data-file-block]')
)
}
export function isWithinFormattingToolbarHoverBridge(
point: { x: number; y: number },
fileBlockRect: RectLike,
toolbarRect: RectLike,
) {
if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) {
return false
}
const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X
const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X
const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y
const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y
return (
point.x >= left &&
point.x <= right &&
point.y >= top &&
point.y <= bottom
)
}
export function shouldSuppressFormattingToolbarHoverUpdate({
eventTarget,
point,
container,
doc,
selectedFileBlockId,
}: {
eventTarget: EventTarget | null
point: { x: number; y: number }
container: HTMLElement | null
doc: Document
selectedFileBlockId: string | null
}) {
if (!container || !selectedFileBlockId) return false
if (
eventTarget instanceof Element &&
eventTarget.closest('.bn-formatting-toolbar')
) {
return true
}
const selectedFileBlock = getSelectedFileBlockBridgeElement(
container,
selectedFileBlockId,
)
const toolbar = doc.querySelector<HTMLElement>('.bn-formatting-toolbar')
if (!selectedFileBlock || !toolbar) return false
return isWithinFormattingToolbarHoverBridge(
point,
selectedFileBlock.getBoundingClientRect(),
toolbar.getBoundingClientRect(),
)
}
function useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId: string | null,
isOpen: boolean,
) {
const lastSelectedFileBlockIdRef = useRef<string | null>(selectedFileBlockId)
useEffect(() => {
if (selectedFileBlockId) {
lastSelectedFileBlockIdRef.current = selectedFileBlockId
return
}
if (!isOpen) {
lastSelectedFileBlockIdRef.current = null
}
}, [isOpen, selectedFileBlockId])
return lastSelectedFileBlockIdRef
}
function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) {
const doc = container?.ownerDocument
const view = doc?.defaultView
if (!container || !doc || !view) return null
return { container, doc, view }
}
function createFormattingToolbarHoverGuardHandler({
container,
doc,
selectedFileBlockIdRef,
}: {
container: HTMLElement
doc: Document
selectedFileBlockIdRef: RefObject<string | null>
}) {
return (event: MouseEvent) => {
if (
!shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: event.target,
point: { x: event.clientX, y: event.clientY },
container,
doc,
selectedFileBlockId: selectedFileBlockIdRef.current,
})
) {
return
}
event.stopPropagation()
}
}
export function useBlockNoteFormattingToolbarHoverGuard({
container,
selectedFileBlockId,
isOpen,
}: {
container: HTMLElement | null
selectedFileBlockId: string | null
isOpen: boolean
}) {
const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId,
isOpen,
)
useEffect(() => {
if (!isOpen) return
const environment = getFormattingToolbarHoverGuardEnvironment(container)
if (!environment) return
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
container: environment.container,
doc: environment.doc,
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
})
environment.view.addEventListener('mousemove', handleMouseMove, true)
return () => {
environment.view.removeEventListener('mousemove', handleMouseMove, true)
}
}, [container, isOpen, lastSelectedFileBlockIdRef])
}

View File

@@ -8,7 +8,7 @@ import type {
ViewFile,
} from '../../types'
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
@@ -76,7 +76,7 @@ function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) {
return useMemo(() => {
if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type)
if (selection.kind === 'folder') return countAllByFilter(entries)
if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries)
if (selection.kind === 'filter' && selection.filter === 'all') return countAllNotesByFilter(entries)
return { open: 0, archived: 0 }
}, [entries, selection])
}

View File

@@ -20,6 +20,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { countByFilter } from '../../utils/noteListHelpers'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -94,9 +95,7 @@ function SortableSection({
sectionProps: SidebarSectionProps
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const itemCount = sectionProps.entries.filter((entry) =>
!entry.archived && (group.type === 'Note' ? (entry.isA === 'Note' || !entry.isA) : entry.isA === group.type),
).length
const itemCount = countByFilter(sectionProps.entries, group.type).open
const isRenaming = sectionProps.renamingType === group.type
return (

View File

@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react
import type { VaultEntry } from '../../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { countAllNotesByFilter } from '../../utils/noteListHelpers'
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
@@ -58,13 +59,8 @@ export function useSidebarCollapsed() {
export function useEntryCounts(entries: VaultEntry[]) {
return useMemo(() => {
let active = 0
let archived = 0
for (const entry of entries) {
if (entry.archived) archived++
else active++
}
return { activeCount: active, archivedCount: archived }
const counts = countAllNotesByFilter(entries)
return { activeCount: counts.open, archivedCount: counts.archived }
}, [entries])
}

View File

@@ -54,6 +54,7 @@ import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
@@ -166,6 +167,13 @@ type TolariaSelectedBlock = ReturnType<
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
>['block']
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
type TolariaBlockTypeSelectOption = ReturnType<
typeof getTolariaBlockTypeSelectItems
>[number] & {
@@ -263,6 +271,17 @@ function getTolariaBlockTypeSelectOptions(
}))
}
function getFormattingToolbarBridgeBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
: null
}
function updateSelectedBlocksToType(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
@@ -451,6 +470,19 @@ export function TolariaFormattingToolbarController(props: {
})
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const currentBridgeBlockId = useEditorState({
editor,
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
})
useBlockNoteFormattingToolbarHoverGuard({
container:
editor.domElement?.closest('.editor__blocknote-container') ??
editor.domElement ??
null,
selectedFileBlockId: currentBridgeBlockId,
isOpen,
})
const position = useEditorState({
editor,

View File

@@ -32,7 +32,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', 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-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', 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: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },

View File

@@ -461,6 +461,57 @@ describe('useAppSave', () => {
expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending')
})
it('does not run markdown title-sync renames for non-markdown text files', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const viewPath = '/vault/views/active-projects.yml'
const viewContent = 'name: Active Projects\nicon: rocket\ncolor: blue\n'
const viewEntry = {
...makeEntry(viewPath, 'Active Projects', 'active-projects.yml'),
fileKind: 'text' as const,
}
const { result } = renderSave({
tabs: [{ entry: viewEntry, content: viewContent }],
activeTabPath: viewPath,
unsavedPaths: new Set([viewPath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: viewPath,
content: viewContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('does not rename an existing markdown note when Cmd+S saves a desynced H1/title state', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const notePath = '/vault/note-b.md'
const noteContent = '# Breadcrumb Sync Target\n\nBody'
const entry = makeEntry(notePath, 'Breadcrumb Sync Target', 'note-b.md')
const { result } = renderSave({
tabs: [{ entry, content: noteContent }],
activeTabPath: notePath,
unsavedPaths: new Set([notePath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: notePath,
content: noteContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => {
const initialContent = '# Fresh Title\n\nInitial body'
const bufferedContent = '# Fresh Title\n\nBody typed right before rename'

View File

@@ -1,7 +1,6 @@
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { needsRenameOnSave } from './useNoteRename'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
@@ -77,20 +76,6 @@ function findUnsavedFallback({
return { path: activeTab.entry.path, content: activeTab.content }
}
function activeTabNeedsRename({
tabs,
activeTabPath,
}: {
tabs: TabState[]
activeTabPath: string | null
}): { path: string; title: string } | null {
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
if (!activeTab) return null
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
? { path: activeTab.entry.path, title: activeTab.entry.title }
: null
}
function isUntitledRenameCandidate(path: string): boolean {
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
@@ -546,16 +531,6 @@ function useRenameHandlers({
replaceRenamedEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
}) {
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
const currentPath = await preparePathForManualRename({
path,
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
})
await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles)
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => {
const currentPath = await preparePathForManualRename({
path,
@@ -578,12 +553,11 @@ function useRenameHandlers({
.catch((err) => console.error('Title rename failed:', err))
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
return { handleRenameTab, handleFilenameRename, handleTitleSync }
return { handleFilenameRename, handleTitleSync }
}
function useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,
@@ -591,7 +565,6 @@ function useHandleSaveAction({
resolveCurrentPath,
}: {
handleSaveRaw: (unsavedFallback?: { path: string; content: string }) => Promise<void>
handleRenameTab: (path: string, newTitle: string) => Promise<void>
tabs: TabState[]
activeTabPath: string | null
unsavedPaths: Set<string>
@@ -605,10 +578,8 @@ function useHandleSaveAction({
activeTabPath: resolvedActiveTabPath,
unsavedPaths,
}))
const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath })
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
}, [handleSaveRaw, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
}
function useEditorPersistence({
@@ -741,7 +712,7 @@ function useAppSaveHandlers({
setToastMessage,
flushPendingUntitledRename,
})
const { handleRenameTab, handleFilenameRename, handleTitleSync } = useRenameHandlers({
const { handleFilenameRename, handleTitleSync } = useRenameHandlers({
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
@@ -753,7 +724,6 @@ function useAppSaveHandlers({
})
const handleSave = useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,

View File

@@ -214,6 +214,12 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
const config = makeConfig({ onToggleRawEditor: undefined })
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
})
it('omits Inbox navigation when the explicit workflow is disabled', () => {
const config = makeConfig({ showInbox: false })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
const { startTransitionMock } = vi.hoisted(() => ({
startTransitionMock: vi.fn((callback: () => void) => callback()),
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
startTransition: startTransitionMock,
}
})
const mockHandleContentChange = vi.fn()
const mockHandleSave = vi.fn()
const mockSavePendingForPath = vi.fn()
@@ -25,6 +37,7 @@ describe('useEditorSaveWithLinks', () => {
setTabs = vi.fn()
setToastMessage = vi.fn()
onAfterSave = vi.fn()
startTransitionMock.mockClear()
mockHandleContentChange.mockClear()
mockHandleSave.mockClear()
mockSavePendingForPath.mockClear()
@@ -147,6 +160,8 @@ describe('useEditorSaveWithLinks', () => {
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Project',
status: 'Active',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -172,6 +187,8 @@ describe('useEditorSaveWithLinks', () => {
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Essay',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -181,6 +198,8 @@ describe('useEditorSaveWithLinks', () => {
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Note',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -199,6 +218,20 @@ describe('useEditorSaveWithLinks', () => {
expect(updateEntry).toHaveBeenCalledWith(path, expected)
})
it('defers H1 title sync updates in a transition so typing stays responsive', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/old-title.md', '# Renamed Note\n\nBody')
})
expect(startTransitionMock).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledWith('/old-title.md', {
title: 'Renamed Note',
hasH1: true,
})
})
it('spreads all properties from useEditorSave onto the return value', () => {
const { result } = renderHookWithLinks()

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { startTransition, useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { contentToEntryPatch } from './frontmatterOps'
@@ -35,6 +35,7 @@ export function useEditorSaveWithLinks(config: {
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmKeyRef = useRef('')
const prevTitleKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
@@ -45,19 +46,25 @@ export function useEditorSaveWithLinks(config: {
}
const frontmatterPatch = contentToEntryPatch(content)
const filename = path.split('/').pop() ?? path
const fmPatch = {
...frontmatterPatch,
...deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
}),
}
const titlePatch = deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
})
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
}
const titleKey = JSON.stringify(titlePatch)
if (titleKey !== prevTitleKeyRef.current) {
prevTitleKeyRef.current = titleKey
startTransition(() => {
updateEntry(path, titlePatch)
})
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -67,6 +67,7 @@ describe('useEditorTabSwap untitled rename continuity', () => {
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
@@ -104,4 +105,46 @@ describe('useEditorTabSwap untitled rename continuity', () => {
expect(editor.tryParseMarkdownToBlocks).toHaveBeenCalled()
})
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const onContentChange = vi.fn()
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
expect.stringContaining('Body typed live'),
)
})
})

View File

@@ -35,23 +35,36 @@ export function extractEditorBody(rawFileContent: string): string {
return rawBody.trimStart()
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
type HeadingTextInline = { type?: string; text?: string }
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: Array<{ type?: string; text?: string }>
content?: HeadingTextInline[]
} | undefined
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
? first.content
: null
if (!first) return null
if (first.type !== 'heading') return null
if (first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
return first.content
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
const content = extractH1Content(blocks)
if (!content) return null
const text = content
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
return text.trim() || null
let text = ''
for (const item of content) {
if (item.type === 'text') {
text += item.text || ''
}
}
const trimmed = text.trim()
return trimmed || null
}
/** Replace the title: line in YAML frontmatter with a new title value. */
@@ -274,9 +287,11 @@ function normalizeTabBody(content: string): string {
}
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
return currentBody === nextBody
|| currentBody.startsWith(nextBody)
|| nextBody.startsWith(currentBody)
const current = currentBody.trimEnd()
const next = nextBody.trimEnd()
return current === next
|| current.startsWith(next)
|| next.startsWith(current)
}
function isUntitledRenameTransition(
@@ -377,21 +392,52 @@ function cachePreviousTabOnPathChange(options: {
cacheEditorState(cache, prevPath, editor.document)
}
function rememberPendingTabArrival(
function shouldWaitForActiveTab(
pathChanged: boolean,
activeTabPath: string | null,
activeTab: Tab | undefined,
pendingTabArrivalPathRef: MutableRefObject<string | null>,
) {
if (!activeTabPath) {
pendingTabArrivalPathRef.current = null
return pathChanged && !!activeTabPath && !activeTab
}
function syncActivePathTransition(options: {
prevPath: string | null
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
}) {
const {
prevPath,
pathChanged,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
} = options
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
if (!preserveUntitledRenameState({
prevPath,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
})) {
prevActivePathRef.current = activeTabPath
return false
}
if (activeTab) {
pendingTabArrivalPathRef.current = null
return true
}
pendingTabArrivalPathRef.current = activeTabPath
return false
prevActivePathRef.current = activeTabPath
return true
}
function handleStableActivePath(options: {
@@ -399,7 +445,6 @@ function handleStableActivePath(options: {
rawModeJustEnded: boolean
activeTabPath: string | null
activeTab: Tab | undefined
pendingTabArrival: boolean
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
@@ -410,7 +455,6 @@ function handleStableActivePath(options: {
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
@@ -423,7 +467,6 @@ function handleStableActivePath(options: {
rawSwapPendingRef.current = true
return false
}
if (pendingTabArrival) return false
if (rawSwapPendingRef.current) return true
cacheStableActivePath({
@@ -638,7 +681,81 @@ function scheduleTabSwap(options: {
pendingSwapRef.current = doSwap
}
function useTabSwapEffect(options: {
function runTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
rawMode?: boolean
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
if (syncActivePathTransition({
prevPath,
pathChanged,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!activeTabPath || !activeTab) return
scheduleTabSwap({
editor,
cache,
targetPath: activeTabPath,
activeTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
})
}
function useTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
@@ -647,7 +764,6 @@ function useTabSwapEffect(options: {
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
pendingTabArrivalPathRef: MutableRefObject<string | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
@@ -661,64 +777,22 @@ function useTabSwapEffect(options: {
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
useEffect(() => {
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const pendingTabArrival = activeTabPath !== null
&& pendingTabArrivalPathRef.current === activeTabPath
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
prevActivePathRef.current = activeTabPath
if (preserveUntitledRenameState({
prevPath,
runTabSwapEffect({
tabs,
activeTabPath,
activeTab,
cache,
editor,
rawMode,
tabCacheRef,
editorMountedRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
return
}
const targetPath = activeTabPath
const readyActiveTab = activeTab
if (!targetPath || !readyActiveTab) return
scheduleTabSwap({
editor,
cache,
targetPath,
activeTab: readyActiveTab,
pendingSwapRef,
prevActivePathRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
})
@@ -727,7 +801,6 @@ function useTabSwapEffect(options: {
editor,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevActivePathRef,
prevRawModeRef,
rawMode,
@@ -771,7 +844,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
const pendingTabArrivalPathRef = useRef<string | null>(null)
const prevRawModeRef = useRef(!!rawMode)
const rawSwapPendingRef = useRef(false)
const suppressChangeRef = useRef(false)
@@ -795,7 +867,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,

View File

@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
removeEntry: (path: string) => void
entries: VaultEntry[]
flushBeforeNoteSwitch?: (path: string) => Promise<void>
flushBeforePathRename?: (path: string) => Promise<void>
reloadVault?: () => Promise<unknown>
setToastMessage: (msg: string | null) => void
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
config.onFrontmatterPersisted?.()
}
function buildTabManagementOptions(
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
) {
return flushBeforeNoteSwitch
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
: undefined
}
function useFrontmatterActionHandlers({
config,
renameTabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
}: {
config: NoteActionsConfig
renameTabsRef: TitleRenameDeps['tabsRef']
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
setToastMessage: (msg: string | null) => void
updateTabContent: (path: string, newContent: string) => void
runFrontmatterOp: (
op: 'update' | 'delete',
path: string,
key: string,
value?: FrontmatterValue,
options?: FrontmatterOpOptions,
) => Promise<string | undefined>
}) {
const handleUpdateFrontmatter = useCallback(async (
path: string,
key: string,
value: FrontmatterValue,
options?: FrontmatterOpOptions,
) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: renameTabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
return {
handleUpdateFrontmatter,
handleDeleteProperty,
handleAddProperty,
}
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
const updateTabContent = useCallback((path: string, newContent: string) => {
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
const frontmatterActions = useFrontmatterActionHandlers({
config,
renameTabsRef: rename.tabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
})
return {
...tabMgmt,
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: rename.tabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
handleAddProperty: frontmatterActions.handleAddProperty,
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
}

View File

@@ -35,6 +35,7 @@ interface ReloadTabsAfterRenameRequest {
/** 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
return `${slugify(title)}.md` !== filename
}

View File

@@ -72,6 +72,22 @@ async function expectStatus(
})
}
async function expectCancelledPickerLeavesWelcome(
action: (onboarding: ReturnType<typeof useOnboarding>) => Promise<void>,
) {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await action(result.current)
})
expect(result.current.state.status).toBe('welcome')
}
describe('useOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -94,9 +110,15 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
})
it('shows vault-missing when the welcome screen was previously dismissed', async () => {
it('shows vault-missing when a previously configured active vault is missing', async () => {
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
mockCommands()
mockCommands({
load_vault_list: {
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
active_vault: '/vault/deleted',
hidden_defaults: [],
},
})
const { result } = await renderOnboarding('/vault/deleted')
@@ -153,17 +175,9 @@ describe('useOnboarding', () => {
})
it('does nothing when the template folder picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateVault()
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateVault()
})
expect(result.current.state.status).toBe('welcome')
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
})
@@ -233,17 +247,9 @@ describe('useOnboarding', () => {
})
it('does nothing when the empty-vault picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateNewVault()
})
expect(result.current.state.status).toBe('welcome')
})
it('opens an existing folder and transitions to ready', async () => {
@@ -262,17 +268,9 @@ describe('useOnboarding', () => {
})
it('does nothing when the open-folder picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleOpenFolder()
})
expect(result.current.state.status).toBe('welcome')
})
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
import { cacheNoteContent } from './useTabManagement'
export async function persistContent(path: string, content: string): Promise<void> {
if (isTauri()) {
@@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise<voi
export function useSaveNote(updateContent: (path: string, content: string) => void) {
const saveNote = useCallback(async (path: string, content: string) => {
await persistContent(path, content)
cacheNoteContent(path, content)
if (!isTauri()) {
updateMockContent(path, content)
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
@@ -47,15 +47,32 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
})
}
async function prefetchResolvedContent(path: string, content: string) {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue(content)
prefetchNoteContent(path)
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
return mockInvoke
}
function expectSingleActiveTab(result: HookState, path: string) {
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe(path)
expect(result.current.activeTabPath).toBe(path)
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useTabManagement (single-note model)', () => {
beforeEach(() => {
vi.clearAllMocks()
clearPrefetchCache()
})
it('starts with no note and null active path', () => {
@@ -204,11 +221,7 @@ describe('useTabManagement (single-note model)', () => {
describe('content prefetch cache', () => {
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
prefetchNoteContent('/vault/note/pre.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
@@ -218,11 +231,7 @@ describe('useTabManagement (single-note model)', () => {
})
it('clearPrefetchCache prevents stale content from being served', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
prefetchNoteContent('/vault/note/stale.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')
clearPrefetchCache()
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
@@ -244,6 +253,18 @@ describe('useTabManagement (single-note model)', () => {
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
})
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
cacheNoteContent('/vault/note/saved.md', '# Persisted content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })
expect(result.current.tabs[0].content).toBe('# Persisted content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
})
describe('rapid switching safety', () => {
@@ -277,5 +298,91 @@ describe('useTabManagement (single-note model)', () => {
expect(result.current.activeTabPath).toBe('/vault/b.md')
})
it('waits for beforeNavigate before switching away from the current note', async () => {
const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
const deferred = createDeferred<void>()
beforeNavigate.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
let replaceDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
.then(() => { replaceDone = true })
await Promise.resolve()
})
expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
expect(result.current.activeTabPath).toBe('/vault/a.md')
expect(replaceDone).toBe(false)
await act(async () => {
deferred.resolve(undefined)
await Promise.resolve()
})
await vi.waitFor(() => expect(replaceDone).toBe(true))
expectSingleActiveTab(result, '/vault/b.md')
})
it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
const first = createDeferred<void>()
const second = createDeferred<void>()
const beforeNavigate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
let switchToBDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
.then(() => { switchToBDone = true })
await Promise.resolve()
})
let switchToCDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
.then(() => { switchToCDone = true })
await Promise.resolve()
})
await act(async () => {
first.resolve(undefined)
await Promise.resolve()
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
await act(async () => {
second.resolve(undefined)
await Promise.resolve()
})
await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
expect(result.current.activeTabPath).toBe('/vault/c.md')
})
it('keeps the current note active when beforeNavigate fails', async () => {
const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await act(async () => {
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
expect(warnSpy).toHaveBeenCalledWith(
'Failed to persist note before navigation:',
expect.any(Error),
)
warnSpy.mockRestore()
})
})
})

View File

@@ -30,6 +30,10 @@ export function prefetchNoteContent(path: string): void {
prefetchCache.set(path, promise)
}
export function cacheNoteContent(path: string, content: string): void {
prefetchCache.set(path, Promise.resolve(content))
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
export function clearPrefetchCache(): void {
prefetchCache.clear()
@@ -49,6 +53,10 @@ async function loadNoteContent(path: string): Promise<string> {
export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
}
function syncActiveTabPath(
activeTabPathRef: React.MutableRefObject<string | null>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
@@ -112,7 +120,7 @@ async function navigateToEntry(options: {
}
}
export function useTabManagement() {
export function useTabManagement(options: TabManagementOptions = {}) {
// Single-note model: tabs has 0 or 1 elements.
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
@@ -123,18 +131,38 @@ export function useTabManagement() {
// Sequence counter for rapid-switch safety: only the latest navigation wins.
const navSeqRef = useRef(0)
const beforeNavigateSeqRef = useRef(0)
const beforeNavigate = options.beforeNavigate
const executeNavigationWithBoundary = useCallback(async (
targetPath: string,
navigate: () => void | Promise<void>,
) => {
const seq = ++beforeNavigateSeqRef.current
const currentPath = activeTabPathRef.current
if (beforeNavigate && currentPath && currentPath !== targetPath) {
try {
await beforeNavigate(currentPath, targetPath)
} catch (err) {
console.warn('Failed to persist note before navigation:', err)
return
}
if (beforeNavigateSeqRef.current !== seq) return
}
await navigate()
}, [beforeNavigate])
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
await navigateToEntry({
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
}))
}, [executeNavigationWithBoundary])
const handleSwitchTab = useCallback((path: string) => {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
@@ -142,20 +170,22 @@ export function useTabManagement() {
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
}, [])
void executeNavigationWithBoundary(entry.path, () => {
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
})
}, [executeNavigationWithBoundary])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
await navigateToEntry({
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
}))
}, [executeNavigationWithBoundary])
const closeAllTabs = useCallback(() => {
tabsRef.current = []

View File

@@ -148,6 +148,16 @@ describe('useVaultSwitcher', () => {
expect(onSwitch).toHaveBeenCalled()
})
it('does not persist the implicit default vault as an active selection', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBeNull()
expect(mockVaultListStore.active_vault).toBeNull()
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {
@@ -413,6 +423,20 @@ describe('useVaultSwitcher', () => {
expect(result.current.vaultPath).toBe(persistedPath)
})
it('treats a remembered default vault as unselected when it is the only vault', async () => {
mockVaultListStore = {
vaults: [],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBeNull()
})
})
describe('isGettingStartedHidden', () => {

View File

@@ -31,8 +31,10 @@ interface PersistedVaultState {
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
selectedVaultPath: string | null
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
@@ -48,10 +50,12 @@ interface PersistedVaultStore {
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
selectedVaultPath: string | null
setDefaultPath: Dispatch<SetStateAction<string>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setLoaded: Dispatch<SetStateAction<boolean>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
@@ -77,10 +81,13 @@ interface RemainingVaultOptions {
}
interface RemoveVaultStateOptions extends RemainingVaultOptions {
selectedVaultPath: string | null
onSwitchRef: MutableRefObject<() => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
interface RemoveVaultActionOptions {
@@ -91,10 +98,17 @@ interface RemoveVaultActionOptions {
onToastRef: MutableRefObject<(msg: string) => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
selectedVaultPath: string | null
vaultPath: string
}
function labelFromPath(path: string): string {
interface VaultPathInput {
path: string
}
function labelFromPath({ path }: VaultPathInput): string {
return path.split('/').pop() || 'Local Vault'
}
@@ -127,22 +141,37 @@ async function loadInitialVaultState() {
return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults }
}
function buildDefaultVaults(defaultPath: string): VaultOption[] {
function buildDefaultVaults({ defaultPath }: { defaultPath: string }): VaultOption[] {
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
}
function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] {
function buildVisibleDefaultVaults({
defaultVaults,
hiddenDefaults,
}: {
defaultVaults: VaultOption[]
hiddenDefaults: string[]
}): VaultOption[] {
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
}
function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] {
function buildAllVaults({
visibleDefaults,
extraVaults,
}: {
visibleDefaults: VaultOption[]
extraVaults: VaultOption[]
}): VaultOption[] {
return [...visibleDefaults, ...extraVaults]
}
function applyResolvedDefaultPath(
resolvedDefaultPath: string,
setDefaultPath: Dispatch<SetStateAction<string>>,
) {
function applyResolvedDefaultPath({
resolvedDefaultPath,
setDefaultPath,
}: {
resolvedDefaultPath: string
setDefaultPath: Dispatch<SetStateAction<string>>
}) {
if (!resolvedDefaultPath) {
return
}
@@ -151,14 +180,35 @@ function applyResolvedDefaultPath(
syncDefaultVaultExport(resolvedDefaultPath)
}
function applyInitialVaultTarget(
function normalizeInitialSelectedVaultPath(
activeVault: string | null,
resolvedDefaultPath: string,
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
) {
vaults: VaultOption[],
): string | null {
if (!activeVault) {
return null
}
const isRememberedDefaultOnlySelection = activeVault === resolvedDefaultPath && vaults.length === 0
return isRememberedDefaultOnlySelection ? null : activeVault
}
function applyInitialVaultTarget({
activeVault,
resolvedDefaultPath,
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
}: {
activeVault: string | null
resolvedDefaultPath: string
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
onSwitchRef: MutableRefObject<() => void>
}) {
if (activeVault) {
setVaultPath(activeVault)
setSelectedVaultPath(activeVault)
onSwitchRef.current()
return
}
@@ -174,15 +224,15 @@ function useVaultCollections(
extraVaults: VaultOption[],
): VaultCollections {
const defaultVaults = useMemo(
() => buildDefaultVaults(defaultPath),
() => buildDefaultVaults({ defaultPath }),
[defaultPath],
)
const visibleDefaults = useMemo(
() => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults),
() => buildVisibleDefaultVaults({ defaultVaults, hiddenDefaults }),
[defaultVaults, hiddenDefaults],
)
const allVaults = useMemo(
() => buildAllVaults(visibleDefaults, extraVaults),
() => buildAllVaults({ visibleDefaults, extraVaults }),
[extraVaults, visibleDefaults],
)
const isGettingStartedHidden = useMemo(
@@ -202,6 +252,7 @@ function useLoadPersistedVaultState(
setExtraVaults,
setHiddenDefaults,
setLoaded,
setSelectedVaultPath,
setVaultPath,
} = store
@@ -214,8 +265,14 @@ function useLoadPersistedVaultState(
setExtraVaults(vaults)
setHiddenDefaults(hidden)
applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath)
applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef)
applyResolvedDefaultPath({ resolvedDefaultPath, setDefaultPath })
applyInitialVaultTarget({
activeVault: normalizeInitialSelectedVaultPath(activeVault, resolvedDefaultPath, vaults),
resolvedDefaultPath,
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
})
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
@@ -225,23 +282,24 @@ function useLoadPersistedVaultState(
})
return () => { cancelled = true }
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath])
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}
function usePersistedVaultStorage(store: PersistedVaultStore) {
const { extraVaults, hiddenDefaults, loaded, vaultPath } = store
const { extraVaults, hiddenDefaults, loaded, selectedVaultPath } = store
useEffect(() => {
if (!loaded) return
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, hiddenDefaults, loaded, vaultPath])
}, [extraVaults, hiddenDefaults, loaded, selectedVaultPath])
}
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
const [selectedVaultPath, setSelectedVaultPath] = useState<string | null>(null)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
@@ -252,10 +310,12 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
setDefaultPath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}
@@ -268,8 +328,10 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}
@@ -304,23 +366,34 @@ async function ensureGettingStartedVaultReady(path: string): Promise<void> {
}
}
function addVaultToList(
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
path: string,
label: string,
) {
function addVaultToList({
setExtraVaults,
path,
label,
}: {
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
path: string
label: string
}) {
setExtraVaults(previousVaults => {
const exists = previousVaults.some(vault => vault.path === path)
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
})
}
function switchVaultPath(
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
path: string,
) {
function switchVaultPath({
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
path,
}: {
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
onSwitchRef: MutableRefObject<() => void>
path: string
}) {
trackEvent('vault_switched')
setSelectedVaultPath(path)
setVaultPath(path)
onSwitchRef.current()
}
@@ -349,7 +422,10 @@ function removeVaultFromState({
removedPath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}: RemoveVaultStateOptions) {
if (isDefault) {
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
@@ -357,43 +433,52 @@ function removeVaultFromState({
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
}
setVaultPath(currentPath => {
if (currentPath !== removedPath) {
return currentPath
if (vaultPath !== removedPath) {
if (selectedVaultPath === removedPath) {
setSelectedVaultPath(null)
}
return
}
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
if (remainingVaults.length === 0) {
return currentPath
}
onSwitchRef.current()
return remainingVaults[0].path
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
if (remainingVaults.length === 0) {
setSelectedVaultPath(null)
return
}
const nextPath = remainingVaults[0].path
setSelectedVaultPath(nextPath)
setVaultPath(nextPath)
onSwitchRef.current()
}
function getRemovedVaultLabel(
path: string,
defaultVaults: VaultOption[],
extraVaults: VaultOption[],
): string {
function getRemovedVaultLabel({
path,
defaultVaults,
extraVaults,
}: {
path: string
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
}): string {
const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path)
return removedVault?.label ?? labelFromPath(path)
return removedVault?.label ?? labelFromPath({ path })
}
function useSwitchVaultAction(
onSwitchRef: MutableRefObject<() => void>,
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>,
setVaultPath: Dispatch<SetStateAction<string>>,
) {
return useCallback((path: string) => {
switchVaultPath(setVaultPath, onSwitchRef, path)
}, [onSwitchRef, setVaultPath])
switchVaultPath({ setSelectedVaultPath, setVaultPath, onSwitchRef, path })
}, [onSwitchRef, setSelectedVaultPath, setVaultPath])
}
function useVaultClonedAction(
@@ -414,7 +499,7 @@ function useOpenLocalFolderAction(
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
const label = labelFromPath({ path })
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch, onToastRef])
@@ -428,7 +513,10 @@ function useRemoveVaultAction({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}: RemoveVaultActionOptions) {
return useCallback((path: string) => {
const isDefault = defaultVaults.some(vault => vault.path === path)
@@ -442,9 +530,12 @@ function useRemoveVaultAction({
removedPath: path,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
})
onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`)
onToastRef.current(`Vault "${getRemovedVaultLabel({ path, defaultVaults, extraVaults })}" removed from list`)
}, [
defaultVaults,
extraVaults,
@@ -453,7 +544,10 @@ function useRemoveVaultAction({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
])
}
@@ -479,13 +573,16 @@ function useVaultActions({
onToastRef,
setExtraVaults,
setHiddenDefaults,
selectedVaultPath,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}: VaultActionOptions) {
const addVault = useCallback((path: string, label: string) => {
addVaultToList(setExtraVaults, path, label)
addVaultToList({ setExtraVaults, path, label })
}, [setExtraVaults])
const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath)
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
@@ -502,7 +599,10 @@ function useVaultActions({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}),
restoreGettingStarted: useRestoreGettingStartedAction({
defaultPath,
@@ -543,7 +643,14 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
const persistedState = usePersistedVaultState(onSwitchRef)
const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState
const {
defaultPath,
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
vaultPath,
} = persistedState
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
defaultPath,
hiddenDefaults,
@@ -559,7 +666,16 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
})
return {
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
removeVault, restoreGettingStarted, isGettingStartedHidden,
allVaults,
defaultPath,
handleOpenLocalFolder,
handleVaultCloned,
isGettingStartedHidden,
loaded,
removeVault,
restoreGettingStarted,
selectedVaultPath,
switchVault,
vaultPath,
}
}

View File

@@ -24,11 +24,18 @@ if (typeof window !== 'undefined') {
window.__mockHandlers = mockHandlers
}
function resolveMockHandler(command: string) {
if (typeof window !== 'undefined' && window.__mockHandlers?.[command]) {
return window.__mockHandlers[command]
}
return mockHandlers[command]
}
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const vaultResult = await tryVaultApi<T>(cmd, args)
if (vaultResult !== undefined) return vaultResult
const handler = mockHandlers[cmd]
const handler = resolveMockHandler(cmd)
if (handler) {
await new Promise((r) => setTimeout(r, 100))
return handler(args) as T

View File

@@ -103,11 +103,17 @@ let mockSettings: Settings = {
default_ai_agent: 'claude_code',
}
let mockLastVaultPath: string | null = null
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
const DEFAULT_MOCK_VAULT = {
label: 'demo-vault-v2',
path: DEFAULT_MOCK_VAULT_PATH,
}
let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
vaults: [],
active_vault: null,
vaults: [DEFAULT_MOCK_VAULT],
active_vault: DEFAULT_MOCK_VAULT_PATH,
}
let mockVaultAiGuidanceStatus = {

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalFetch = globalThis.fetch
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('tryVaultApi', () => {
afterEach(() => {
vi.resetModules()
vi.restoreAllMocks()
globalThis.fetch = originalFetch
})
it('retries vault API discovery after an unavailable response', async () => {
let vaultApiOnline = false
const fetchMock = vi.fn(async (input: string | URL) => {
const url = String(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503)
}
if (url === '/api/vault/list?path=%2Ffixture') {
return jsonResponse([{ title: 'Alpha Project' }])
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined()
vaultApiOnline = true
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }])
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2)
})
it('unwraps note content responses from the vault API', async () => {
const fetchMock = vi.fn(async (input: string | URL) => {
const url = String(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: true })
}
if (url === '/api/vault/content?path=%2Ffixture%2Falpha.md') {
return jsonResponse({ content: '# Alpha Project' })
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project')
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1)
})
})

View File

@@ -6,16 +6,22 @@
let vaultApiAvailable: boolean | null = null
async function checkVaultApi(): Promise<boolean> {
if (vaultApiAvailable !== null) return vaultApiAvailable
async function detectVaultApiAvailability(): Promise<boolean> {
try {
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
vaultApiAvailable = res.ok
return res.ok
} catch {
vaultApiAvailable = false
return false
}
}
async function checkVaultApi(): Promise<boolean> {
if (vaultApiAvailable === true) return true
const available = await detectVaultApiAvailability()
vaultApiAvailable = available
console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`)
return vaultApiAvailable
return available
}
interface VaultApiRequest {
@@ -65,25 +71,38 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
},
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const available = await checkVaultApi()
if (!available) return undefined
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>) {
if (!args) return null
const requestBuilder = VAULT_API_COMMANDS[cmd]
if (!requestBuilder || !args) return undefined
return requestBuilder?.(args) ?? null
}
const request = requestBuilder(args)
function buildFetchOptions(request: VaultApiRequest): RequestInit {
if (!request.body) {
return { method: request.method || 'GET' }
}
return {
method: request.method || 'GET',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request.body),
}
}
async function fetchVaultApiResponse(request: VaultApiRequest) {
const res = await fetch(request.url, buildFetchOptions(request))
if (!res.ok) return undefined
return res.json()
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const request = buildVaultApiRequest(cmd, args)
if (!request) return undefined
if (!await checkVaultApi()) return undefined
try {
const fetchOpts: RequestInit = { method: request.method || 'GET' }
if (request.body) {
fetchOpts.headers = { 'Content-Type': 'application/json' }
fetchOpts.body = JSON.stringify(request.body)
}
const res = await fetch(request.url, fetchOpts)
if (!res.ok) return undefined
const data = await res.json()
const data = await fetchVaultApiResponse(request)
if (data === undefined) return undefined
return (cmd === 'get_note_content' ? data.content : data) as T
} catch (err) {
console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err)

View File

@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
import { triggerCommitEntryAction } from './commitEntryAction'
describe('triggerCommitEntryAction', () => {
it('runs the automatic checkpoint when AutoGit is enabled', () => {
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
triggerCommitEntryAction({
autoGitEnabled: true,
openCommitDialog,
runAutomaticCheckpoint,
})
expect(runAutomaticCheckpoint).toHaveBeenCalledWith({ savePendingBeforeCommit: true })
expect(openCommitDialog).not.toHaveBeenCalled()
})
it('opens the manual commit dialog when AutoGit is disabled', () => {
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
triggerCommitEntryAction({
autoGitEnabled: false,
openCommitDialog,
runAutomaticCheckpoint,
})
expect(openCommitDialog).toHaveBeenCalledTimes(1)
expect(runAutomaticCheckpoint).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,21 @@
type RunAutomaticCheckpoint = (options?: { savePendingBeforeCommit?: boolean }) => Promise<boolean>
type OpenCommitDialog = () => Promise<void>
interface CommitEntryActionConfig {
autoGitEnabled: boolean
openCommitDialog: OpenCommitDialog
runAutomaticCheckpoint: RunAutomaticCheckpoint
}
export function triggerCommitEntryAction({
autoGitEnabled,
openCommitDialog,
runAutomaticCheckpoint,
}: CommitEntryActionConfig): void {
if (autoGitEnabled) {
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
return
}
void openCommitDialog()
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
describe('filterEntries', () => {
@@ -62,6 +62,17 @@ describe('filterEntries', () => {
const result = filterEntries(entries, allSelection, 'archived')
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
})
it('excludes attachments-folder markdown from the All Notes view', () => {
const entries = [
makeEntry({ path: '/vault/note/real-note.md', title: 'Real Note', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/reference.md', title: 'Attachment Markdown', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/nested/diagram.md', title: 'Nested Attachment Markdown', isA: 'Note' }),
]
const result = filterEntries(entries, allSelection, 'open')
expect(result.map((entry) => entry.title)).toEqual(['Real Note'])
})
})
describe('countByFilter', () => {
@@ -102,3 +113,17 @@ describe('countAllByFilter', () => {
expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 })
})
})
describe('countAllNotesByFilter', () => {
it('excludes attachments-folder files from All Notes totals', () => {
const entries = [
makeEntry({ path: '/vault/note/real-note.md', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/reference.md', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/archive.md', isA: 'Note', archived: true }),
makeEntry({ path: '/vault/attachments/image.png', fileKind: 'binary' }),
makeEntry({ path: '/vault/archive/real-archive.md', isA: 'Note', archived: true }),
]
expect(countAllNotesByFilter(entries)).toEqual({ open: 1, archived: 1 })
})
})

View File

@@ -310,6 +310,7 @@ export function buildRelationshipGroups(
const isActive = (e: VaultEntry) => !e.archived
const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
const ATTACHMENTS_FOLDER = 'attachments'
function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] {
if (subFilter === 'archived') return entries.filter((e) => e.archived)
@@ -321,26 +322,46 @@ function isInFolder(entryPath: string, folderRelPath: string): boolean {
return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/')
}
export function isAllNotesEntry(entry: VaultEntry): boolean {
return isMarkdown(entry) && !isInFolder(entry.path, ATTACHMENTS_FOLDER)
}
function filterViewEntries(entries: VaultEntry[], filename: string, views?: ViewFile[]): VaultEntry[] {
const view = views?.find((candidate) => candidate.filename === filename)
if (!view) return []
return evaluateView(view.definition, entries.filter(isMarkdown))
}
function filterFolderEntries(entries: VaultEntry[], folderPath: string, subFilter?: NoteListFilter): VaultEntry[] {
// Folder view shows ALL files (text + binary), not just markdown
const folderEntries = entries.filter((entry) => isInFolder(entry.path, folderPath))
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
}
function filterSectionGroupEntries(entries: VaultEntry[], type: string, subFilter?: NoteListFilter): VaultEntry[] {
const typeEntries = entries.filter((entry) => isMarkdown(entry) && entry.isA === type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
function filterTopLevelEntries(
entries: VaultEntry[],
selection: Extract<SidebarSelection, { kind: 'filter' }>,
subFilter?: NoteListFilter,
): VaultEntry[] {
const filterableEntries = selection.filter === 'all'
? entries.filter(isAllNotesEntry)
: entries.filter(isMarkdown)
if (selection.filter === 'all' && subFilter) return applySubFilter(filterableEntries, subFilter)
return filterByFilterType(filterableEntries, selection.filter)
}
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter, views?: ViewFile[]): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'view') {
const view = views?.find((v) => v.filename === selection.filename)
if (!view) return []
return evaluateView(view.definition, entries.filter(isMarkdown))
}
if (selection.kind === 'folder') {
// Folder view shows ALL files (text + binary), not just markdown
const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path))
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
}
if (selection.kind === 'sectionGroup') {
const typeEntries = entries.filter((e) => isMarkdown(e) && e.isA === selection.type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
// Non-folder views: only markdown files
const mdEntries = entries.filter(isMarkdown)
if (selection.filter === 'all' && subFilter) return applySubFilter(mdEntries, subFilter)
return filterByFilterType(mdEntries, selection.filter)
if (selection.kind === 'view') return filterViewEntries(entries, selection.filename, views)
if (selection.kind === 'folder') return filterFolderEntries(entries, selection.path, subFilter)
if (selection.kind === 'sectionGroup') return filterSectionGroupEntries(entries, selection.type, subFilter)
if (selection.kind === 'filter') return filterTopLevelEntries(entries, selection, subFilter)
return []
}
function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] {
@@ -366,17 +387,25 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
return { open, archived }
}
/** Count notes per sub-filter across all entries (no type filter). */
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
function countEntriesByArchiveStatus(entries: VaultEntry[]): Record<NoteListFilter, number> {
let open = 0, archived = 0
for (const e of entries) {
if (!isMarkdown(e)) continue
if (e.archived) archived++
for (const entry of entries) {
if (entry.archived) archived++
else open++
}
return { open, archived }
}
/** Count notes per sub-filter across all entries (no type filter). */
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
return countEntriesByArchiveStatus(entries.filter(isMarkdown))
}
/** Count All Notes-eligible documents per sub-filter, excluding files under attachments/. */
export function countAllNotesByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
return countEntriesByArchiveStatus(entries.filter(isAllNotesEntry))
}
// --- Inbox ---
/** Check if entry belongs in the Inbox (markdown only, not organized, not archived, not a Type). */

View File

@@ -99,6 +99,7 @@ describe('buildRawEditorBaseItems', () => {
title: 'Project Alpha',
aliases: ['project-alpha', 'Alpha'],
group: 'Project',
entryType: 'Project',
entryTitle: 'Project Alpha',
path: 'projects/project-alpha.md',
},

View File

@@ -8,6 +8,7 @@ export interface RawEditorBaseItem {
title: string
aliases: string[]
group: string
entryType?: string | null
entryTitle: string
path: string
}
@@ -59,6 +60,7 @@ export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseIte
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
})))

View File

@@ -31,13 +31,10 @@ const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
const isActive = (e: VaultEntry) => !e.archived
const isSupportedSectionType = (type: string) => !isLegacyJournalingType(type)
function resolveEntrySectionType(entry: VaultEntry): string {
return entry.isA || 'Note'
}
function shouldCollectActiveType(entry: VaultEntry): boolean {
if (!isActive(entry) || !isMarkdown(entry)) return false
return isSupportedSectionType(resolveEntrySectionType(entry))
if (!entry.isA) return false
return isSupportedSectionType(entry.isA)
}
function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
@@ -45,12 +42,12 @@ function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
return isSupportedSectionType(name)
}
/** Collect unique isA values from active (non-archived) markdown entries. Untyped entries count as 'Note'. */
/** Collect unique explicit isA values from active (non-archived) markdown entries. */
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (!shouldCollectActiveType(e)) continue
types.add(resolveEntrySectionType(e))
types.add(e.isA!)
}
return types
}

View File

@@ -76,8 +76,8 @@ describe('enrichSuggestionItems', () => {
Project: makeEntry({ isA: 'Type', title: 'Project', color: 'blue', icon: 'wrench' }),
}
function makeItem(title: string, group: string, path: string) {
return { title, aliases: [] as string[], group, entryTitle: title, path, onItemClick: vi.fn() }
function makeItem(title: string, group: string, path: string, entryType?: string | null) {
return { title, aliases: [] as string[], group, entryType, entryTitle: title, path, onItemClick: vi.fn() }
}
it('filters items by query', () => {
@@ -88,7 +88,7 @@ describe('enrichSuggestionItems', () => {
})
it('adds type metadata for non-Note groups', () => {
const items = [makeItem('My Project', 'Project', '/p.md')]
const items = [makeItem('My Project', 'Project', '/p.md', 'Project')]
const result = enrichSuggestionItems(items, '', typeEntryMap)
expect(result[0].noteType).toBe('Project')
expect(result[0].typeColor).toBeDefined()
@@ -96,7 +96,15 @@ describe('enrichSuggestionItems', () => {
expect(result[0].TypeIcon).toBeDefined()
})
it('omits type metadata for Note group', () => {
it('preserves Note type metadata for explicit Note entries', () => {
const items = [makeItem('Explicit Note', 'Note', '/n.md', 'Note')]
const result = enrichSuggestionItems(items, '', {})
expect(result[0].noteType).toBe('Note')
expect(result[0].typeColor).toBeDefined()
expect(result[0].TypeIcon).toBeDefined()
})
it('keeps untyped Note-group entries neutral', () => {
const items = [makeItem('Plain Note', 'Note', '/n.md')]
const result = enrichSuggestionItems(items, '', {})
expect(result[0].noteType).toBeUndefined()

View File

@@ -13,6 +13,7 @@ interface BaseSuggestionItem {
title: string
aliases: string[]
group: string
entryType?: string | null
entryTitle: string
path: string
}
@@ -48,15 +49,15 @@ export function enrichSuggestionItems(
)
const sliced = filtered.slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(sliced))
return final.map(({ group, ...rest }) => {
const noteType = group !== 'Note' ? group : undefined
const te = typeEntryMap[group]
return final.map(({ entryType, ...rest }) => {
const noteType = entryType ?? undefined
const te = noteType ? typeEntryMap[noteType] : undefined
return {
...rest,
noteType,
typeColor: noteType ? getTypeColor(group, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(group, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(group, te?.icon) : undefined,
typeColor: noteType ? getTypeColor(noteType, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(noteType, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(noteType, te?.icon) : undefined,
}
})
}

View File

@@ -28,7 +28,7 @@ export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVa
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
}
export function saveVaultList(vaults: VaultOption[], activeVault: string, hiddenDefaults: string[] = []): Promise<void> {
export function saveVaultList(vaults: VaultOption[], activeVault: string | null, hiddenDefaults: string[] = []): Promise<void> {
const list: PersistedVaultList = {
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
active_vault: activeVault,

View File

@@ -2,20 +2,40 @@ import { expect, type Page } from '@playwright/test'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { installFixtureVaultDesktopBridgeInBrowser } from './fixtureVaultDesktopBridge'
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
const FIXTURE_VAULT_REMOVE_RETRIES = 10
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
type FixtureCommandArgs = Record<string, unknown> | undefined
function copyDirSync(src: string, dest: string): void {
interface FixtureVaultPageArgs {
page: Page
vaultPath: string
}
interface FixturePageArgs {
page: Page
}
interface CopyDirArgs {
src: string
dest: string
}
interface RemoveFixtureVaultArgs {
tempVaultDir: string
}
function copyDirSync({ src, dest }: CopyDirArgs): void {
fs.mkdirSync(dest, { recursive: true })
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
const sourcePath = path.join(src, item.name)
const destinationPath = path.join(dest, item.name)
if (item.isDirectory()) {
copyDirSync(sourcePath, destinationPath)
copyDirSync({ src: sourcePath, dest: destinationPath })
continue
}
fs.copyFileSync(sourcePath, destinationPath)
@@ -24,12 +44,11 @@ function copyDirSync(src: string, dest: string): void {
export function createFixtureVaultCopy(): string {
const tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
copyDirSync(FIXTURE_VAULT, tempVaultDir)
copyDirSync({ src: FIXTURE_VAULT, dest: tempVaultDir })
return tempVaultDir
}
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
if (!tempVaultDir) return
function removeFixtureVaultDirectory({ tempVaultDir }: RemoveFixtureVaultArgs): void {
fs.rmSync(tempVaultDir, {
recursive: true,
force: true,
@@ -38,15 +57,21 @@ export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined):
})
}
export async function openFixtureVault(
page: Page,
vaultPath: string,
): Promise<void> {
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
if (!tempVaultDir) return
removeFixtureVaultDirectory({ tempVaultDir })
}
async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPageArgs): Promise<void> {
await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => {
localStorage.clear()
localStorage.setItem(dismissedKey, '1')
const jsonHeaders = { 'Content-Type': 'application/json' }
const FRONTMATTER_OPEN = '---\n'
const FRONTMATTER_CLOSE = '\n---\n'
const nativeFetch = window.fetch.bind(window)
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const requestUrl = typeof input === 'string'
? input
@@ -64,85 +89,21 @@ export async function openFixtureVault(
return nativeFetch(input, init)
}
const applyFixtureVaultOverrides = (
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
) => {
if (!handlers) return handlers
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
return handlers
const readJson = async (url: string, init?: RequestInit) => {
const response = await nativeFetch(url, init)
if (!response.ok) {
let message = `HTTP ${response.status}`
try {
const body = await response.json() as { error?: string }
message = body.error ?? message
} catch {
// Preserve the HTTP status fallback when the body is not JSON.
}
throw new Error(message)
}
return response.json()
}
let ref = applyFixtureVaultOverrides(
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
) ?? null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = applyFixtureVaultOverrides(
value as Record<string, ((args?: unknown) => unknown)> | undefined,
) ?? null
},
get() {
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers))
await page.evaluate((resolvedVaultPath: string) => {
const handlers = window.__mockHandlers
if (!handlers) {
throw new Error('Mock handlers unavailable for fixture vault override')
}
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
}, vaultPath)
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
/**
* Browser harness for desktop command-routing tests.
*
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
* renderer shortcut dispatch and desktop menu-command dispatch without a native
* shell. It is deterministic, but it is not a substitute for real native QA.
*/
export async function openFixtureVaultDesktopHarness(
page: Page,
vaultPath: string,
): Promise<void> {
await openFixtureVault(page, vaultPath)
await page.evaluate((resolvedVaultPath: string) => {
const jsonHeaders = { 'Content-Type': 'application/json' }
const nativeFetch = window.fetch.bind(window)
const FRONTMATTER_OPEN = '---\n'
const FRONTMATTER_CLOSE = '\n---\n'
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const splitFrontmatter = (content: string) => {
if (!content.startsWith(FRONTMATTER_OPEN)) {
return { frontmatter: null as string | null, body: content }
@@ -206,16 +167,12 @@ export async function openFixtureVaultDesktopHarness(
return `${FRONTMATTER_OPEN}${nextEntryLines.join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const entries = splitFrontmatterEntries(frontmatter).filter((entry) => entry.key !== '')
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
let replaced = false
const nextEntries = entries.map((entry) => {
if (!keyPattern.test(entry.key)) return entry
replaced = true
return { key, lines: nextEntryLines }
})
const nextEntries = splitFrontmatterEntries(frontmatter)
.filter((entry) => entry.key !== '')
.map((entry) => (entry.key === key ? { key, lines: nextEntryLines } : entry))
if (!replaced) {
const hasEntry = nextEntries.some((entry) => entry.key === key)
if (!hasEntry) {
nextEntries.push({ key, lines: nextEntryLines })
}
@@ -226,9 +183,8 @@ export async function openFixtureVaultDesktopHarness(
const { frontmatter, body } = splitFrontmatter(content)
if (frontmatter === null) return content
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
const nextEntries = splitFrontmatterEntries(frontmatter)
.filter((entry) => entry.key !== '' && !keyPattern.test(entry.key))
.filter((entry) => entry.key !== '' && entry.key !== key)
if (nextEntries.length === 0) {
return body
@@ -237,32 +193,19 @@ export async function openFixtureVaultDesktopHarness(
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const persistFrontmatterChange = async (path: string, transform: (content: string) => string) => {
const current = await readJson(`/api/vault/content?path=${encodeURIComponent(path)}`) as { content: string }
const persistFrontmatterChange = async (notePath: string, transform: (content: string) => string) => {
const current = await readJson(
`/api/vault/content?path=${encodeURIComponent(notePath)}`,
) as { content: string }
const updatedContent = transform(current.content)
await readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ path, content: updatedContent }),
body: JSON.stringify({ path: notePath, content: updatedContent }),
})
return updatedContent
}
const readJson = async (url: string, init?: RequestInit) => {
const response = await nativeFetch(url, init)
if (!response.ok) {
let message = `HTTP ${response.status}`
try {
const body = await response.json() as { error?: string }
message = body.error ?? message
} catch {
// Keep the HTTP status fallback when the body is not JSON.
}
throw new Error(message)
}
return response.json()
}
const activeVaultList = {
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
@@ -283,16 +226,16 @@ export async function openFixtureVaultDesktopHarness(
body: JSON.stringify(payload),
})
const commandHandlers: Record<string, (commandArgs?: Record<string, unknown>) => Promise<unknown> | unknown> = {
trigger_menu_command: (commandArgs) => {
const commandId = String(commandArgs?.id ?? '')
const bridge = window.__laputaTest?.dispatchBrowserMenuCommand
if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
bridge(commandId)
return null
},
const readCommandValue = (commandArgs: FixtureCommandArgs, key: string, fallback?: unknown) =>
commandArgs?.[key] ?? fallback
const readCommandString = (commandArgs: FixtureCommandArgs, key: string, fallback = '') =>
String(readCommandValue(commandArgs, key, fallback))
const buildFixtureStateHandlers = () => ({
load_vault_list: () => activeVaultList,
check_vault_exists: () => true,
check_vault_exists: (commandArgs?: FixtureCommandArgs) =>
readCommandString(commandArgs, 'path') === resolvedVaultPath,
is_git_repo: () => true,
get_last_vault_path: () => resolvedVaultPath,
get_default_vault_path: () => resolvedVaultPath,
@@ -309,97 +252,164 @@ export async function openFixtureVaultDesktopHarness(
anonymous_id: null,
release_channel: null,
}),
list_vault: (commandArgs) => readVaultList(commandArgs),
reload_vault: (commandArgs) => readVaultList(commandArgs, true),
})
const buildFixtureReadHandlers = () => ({
list_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs),
reload_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs, true),
list_vault_folders: () => [],
list_views: () => [],
get_modified_files: () => [],
detect_renames: () => [],
reload_vault_entry: (commandArgs) =>
readJson(`/api/vault/entry?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`),
get_note_content: async (commandArgs) => {
reload_vault_entry: (commandArgs?: FixtureCommandArgs) =>
readJson(`/api/vault/entry?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`),
get_note_content: async (commandArgs?: FixtureCommandArgs) => {
const data = await readJson(
`/api/vault/content?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`,
`/api/vault/content?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`,
) as { content: string }
return data.content
},
get_all_content: (commandArgs) =>
get_all_content: (commandArgs?: FixtureCommandArgs) =>
readJson(
`/api/vault/all-content?path=${encodeURIComponent(String(commandArgs?.path ?? resolvedVaultPath))}`,
`/api/vault/all-content?path=${encodeURIComponent(readCommandString(commandArgs, 'path', resolvedVaultPath))}`,
),
save_note_content: (commandArgs) =>
readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ path: commandArgs?.path, content: commandArgs?.content }),
}),
update_frontmatter: (commandArgs) =>
persistFrontmatterChange(
String(commandArgs?.path ?? ''),
(content) => replaceFrontmatterEntry(content, String(commandArgs?.key ?? ''), commandArgs?.value),
),
delete_frontmatter_property: (commandArgs) =>
persistFrontmatterChange(
String(commandArgs?.path ?? ''),
(content) => removeFrontmatterEntry(content, String(commandArgs?.key ?? '')),
),
rename_note: (commandArgs) =>
renameNoteRequest({
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
old_path: commandArgs?.oldPath,
new_title: commandArgs?.newTitle,
old_title: commandArgs?.oldTitle ?? null,
}),
rename_note_filename: (commandArgs) =>
readJson('/api/vault/rename-filename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
old_path: commandArgs?.oldPath,
new_filename_stem: commandArgs?.newFilenameStem,
}),
}),
search_vault: (commandArgs) => {
const resolvedPath = String(commandArgs?.path ?? commandArgs?.vaultPath ?? resolvedVaultPath)
const query = encodeURIComponent(String(commandArgs?.query ?? ''))
const mode = encodeURIComponent(String(commandArgs?.mode ?? 'all'))
search_vault: (commandArgs?: FixtureCommandArgs) => {
const resolvedPath = readCommandString(
commandArgs,
'path',
readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
)
const query = encodeURIComponent(readCommandString(commandArgs, 'query'))
const mode = encodeURIComponent(readCommandString(commandArgs, 'mode', 'all'))
return readJson(
`/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}`,
)
},
auto_rename_untitled: async (commandArgs) => {
const notePath = String(commandArgs?.notePath ?? '')
})
const buildFixtureWriteHandlers = () => ({
save_note_content: (commandArgs?: FixtureCommandArgs) =>
readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
path: readCommandValue(commandArgs, 'path'),
content: readCommandValue(commandArgs, 'content'),
}),
}),
update_frontmatter: (commandArgs?: FixtureCommandArgs) =>
persistFrontmatterChange(
readCommandString(commandArgs, 'path'),
(content) => replaceFrontmatterEntry(
content,
readCommandString(commandArgs, 'key'),
readCommandValue(commandArgs, 'value'),
),
),
delete_frontmatter_property: (commandArgs?: FixtureCommandArgs) =>
persistFrontmatterChange(
readCommandString(commandArgs, 'path'),
(content) => removeFrontmatterEntry(content, readCommandString(commandArgs, 'key')),
),
rename_note: (commandArgs?: FixtureCommandArgs) =>
renameNoteRequest({
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
old_path: readCommandValue(commandArgs, 'oldPath'),
new_title: readCommandValue(commandArgs, 'newTitle'),
old_title: readCommandValue(commandArgs, 'oldTitle', null),
}),
rename_note_filename: (commandArgs?: FixtureCommandArgs) =>
readJson('/api/vault/rename-filename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
old_path: readCommandValue(commandArgs, 'oldPath'),
new_filename_stem: readCommandValue(commandArgs, 'newFilenameStem'),
}),
}),
auto_rename_untitled: async (commandArgs?: FixtureCommandArgs) => {
const notePath = readCommandString(commandArgs, 'notePath')
const contentData = await readJson(
`/api/vault/content?path=${encodeURIComponent(notePath)}`,
) as { content: string }
const match = contentData.content.match(/^#\s+(.+)$/m)
if (!match) return null
return renameNoteRequest({
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
old_path: notePath,
new_title: match[1].trim(),
})
},
})
const applyFixtureVaultOverrides = (
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
) => {
if (!handlers) return handlers
Object.assign(
handlers,
buildFixtureStateHandlers(),
buildFixtureReadHandlers(),
buildFixtureWriteHandlers(),
)
return handlers
}
const invoke = async (command: string, args?: Record<string, unknown>) => {
const handler = commandHandlers[command] ?? window.__mockHandlers?.[command]
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
return handler(args)
}
let ref = applyFixtureVaultOverrides(
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
) ?? null
Object.defineProperty(window, '__TAURI__', {
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
value: {},
set(value) {
ref = applyFixtureVaultOverrides(
value as Record<string, ((args?: unknown) => unknown)> | undefined,
) ?? null
},
get() {
return applyFixtureVaultOverrides(ref) ?? ref
},
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: { invoke },
})
}, vaultPath)
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
}
async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise<void> {
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault))
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
export async function openFixtureVault(
page: Page,
vaultPath: string,
): Promise<void> {
await installFixtureVaultInitScript({ page, vaultPath })
await waitForFixtureVaultReady({ page })
}
async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Promise<void> {
await page.evaluate(installFixtureVaultDesktopBridgeInBrowser)
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
}
/**
* Browser harness for desktop command-routing tests.
*
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
* renderer shortcut dispatch and desktop menu-command dispatch without a native
* shell. It is deterministic, but it is not a substitute for real native QA.
*/
export async function openFixtureVaultDesktopHarness(
page: Page,
vaultPath: string,
): Promise<void> {
await openFixtureVault(page, vaultPath)
await installFixtureVaultDesktopBridge({ page })
}
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness

View File

@@ -0,0 +1,27 @@
export function installFixtureVaultDesktopBridgeInBrowser(): void {
const dispatchBrowserMenuCommand =
window.__laputaTest?.dispatchBrowserMenuCommand
?? (() => { throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') })
const specialHandlers: Record<string, (args?: Record<string, unknown>) => unknown> = {
trigger_menu_command: (args) => {
dispatchBrowserMenuCommand(String(args?.id ?? ''))
return null
},
}
const invoke = (command: string, args?: Record<string, unknown>) => {
const handler = specialHandlers[command] ?? window.__mockHandlers?.[command]
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
return handler(args)
}
Object.defineProperty(window, '__TAURI__', {
configurable: true,
value: {},
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: { invoke },
})
}

View File

@@ -57,7 +57,7 @@ async function setRawEditorContent(page: Page, content: string) {
}, content)
}
test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => {
test('@smoke Cmd+S preserves the existing filename until the explicit breadcrumb sync action', async ({ page }) => {
const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md')
const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md')
const originalPath = path.join(tempVaultDir, 'note', 'note-b.md')

View File

@@ -0,0 +1,81 @@
import { test, expect, type Page } from '@playwright/test'
const REMEMBERED_DEFAULT_VAULT_PATH = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
async function mockFreshStart(
page: Page,
options: {
activeVault: string | null
checkExistingPath: string
rememberWelcomeDismissal?: boolean
},
) {
await page.addInitScript((config) => {
localStorage.clear()
if (config.rememberWelcomeDismissal) {
localStorage.setItem('tolaria_welcome_dismissed', '1')
}
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
const originalGetSettings = ref.get_settings as (() => Record<string, unknown>) | undefined
ref.get_settings = () => ({
...(originalGetSettings ? originalGetSettings() : {}),
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
})
ref.load_vault_list = () => ({
vaults: [],
active_vault: config.activeVault,
hidden_defaults: [],
})
ref.get_default_vault_path = () => config.checkExistingPath
ref.check_vault_exists = (args: { path?: string }) => args?.path === config.checkExistingPath
},
get() {
return ref
},
})
}, options)
}
test('accepting telemetry consent on a fresh start opens the vault choice wizard @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
await page.getByTestId('telemetry-accept').click()
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
await mockFreshStart(page, {
activeVault: REMEMBERED_DEFAULT_VAULT_PATH,
checkExistingPath: REMEMBERED_DEFAULT_VAULT_PATH,
rememberWelcomeDismissal: true,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
await page.getByTestId(`telemetry-${action}`).click()
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
})
}

View File

@@ -65,6 +65,13 @@ function quickOpenSelectedTitle(page: Page) {
return page.getByTestId('quick-open-palette').locator('[class*="bg-accent"] span.truncate').first()
}
async function focusHeadingEnd(page: Page, title: string) {
const heading = page.getByRole('heading', { name: title, level: 1 })
await expect(heading).toBeVisible({ timeout: 5_000 })
await heading.click()
await page.keyboard.press('End')
}
test('creating an untitled draft hides the legacy title section in the editor', async ({ page }) => {
await page.locator('button[title="Create new note"]').click()
@@ -139,3 +146,29 @@ test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toContainText(updatedTitle, { timeout: 5_000 })
})
test('@smoke rapid H1 typing stays stable while editing an existing note', async ({ page }) => {
const noteList = page.locator('[data-testid="note-list-container"]')
const firstTitle = 'Alpha Project Fast Typing Check'
const finalTitle = 'Alpha Project Fast Typing Flow'
await openNote(page, 'Alpha Project')
await focusHeadingEnd(page, 'Alpha Project')
await page.keyboard.type(' Fast Typing Check')
await expect(page.getByRole('heading', { name: firstTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
for (let i = 0; i < ' Check'.length; i += 1) {
await page.keyboard.press('Backspace')
}
await page.keyboard.type(' Flow')
await page.keyboard.press('Meta+s')
await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText(finalTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText('Alpha Project', { exact: true })).toHaveCount(0)
await openNote(page, 'Spring 2026')
await openNote(page, finalTitle)
await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
})

View File

@@ -73,6 +73,24 @@ async function seedImageBlock(page: Page) {
return image
}
async function moveMouseInSteps(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ steps, stepDelayMs }: { steps: number; stepDelayMs: number },
) {
await page.mouse.move(from.x, from.y)
for (let step = 1; step <= steps; step += 1) {
const progress = step / steps
await page.mouse.move(
from.x + (to.x - from.x) * progress,
from.y + (to.y - from.y) * progress,
)
await page.waitForTimeout(stepDelayMs)
}
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
@@ -86,28 +104,34 @@ test.afterEach(async () => {
test('image toolbar stays usable while the pointer crosses onto its controls', async ({ page }) => {
const image = await seedImageBlock(page)
await image.click()
const toolbar = page.locator('.bn-formatting-toolbar')
const replaceButton = page.getByRole('button', { name: /Replace image/i })
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
const imageBox = await image.boundingBox()
const replaceButtonBox = await replaceButton.boundingBox()
expect(imageBox).not.toBeNull()
expect(replaceButtonBox).not.toBeNull()
await page.mouse.move(
imageBox!.x + imageBox!.width / 2,
imageBox!.y + imageBox!.height / 2,
)
await page.mouse.move(
replaceButtonBox!.x + replaceButtonBox!.width / 2,
replaceButtonBox!.y + replaceButtonBox!.height / 2,
{ steps: 16 },
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
const replaceButtonBox = await replaceButton.boundingBox()
expect(replaceButtonBox).not.toBeNull()
await moveMouseInSteps(
page,
{
x: imageBox!.x + imageBox!.width / 2,
y: imageBox!.y + imageBox!.height / 2,
},
{
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
},
{ steps: 12, stepDelayMs: 35 },
)
await expect(toolbar).toBeVisible()

View File

@@ -0,0 +1,168 @@
import { expect, test, type Page } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
import { seedAutoGitSavedChange } from './testBridge'
type MockHandler = (args?: Record<string, unknown>) => unknown
function installCommitFlowMocks() {
type BrowserWindow = Window & typeof globalThis & {
__gitCommitMessages?: string[]
__gitPushCalls?: number
__mockHandlers?: Record<string, MockHandler>
}
const browserWindow = window as BrowserWindow
const dirtyPaths = new Set<string>()
let ahead = 0
const createModifiedFiles = () => [...dirtyPaths].map((path) => ({
path,
relativePath: path.split('/').pop() ?? path,
status: 'modified',
}))
const isPatched = (handlers?: Record<string, MockHandler> | null) =>
!handlers || (handlers as Record<string, unknown>).__manualCommitPatched === true
const patchSaveNoteContent = (handlers: Record<string, MockHandler>) => {
const originalSaveNoteContent = handlers.save_note_content
handlers.save_note_content = (args?: Record<string, unknown>) => {
const path = typeof args?.path === 'string' ? args.path : null
if (path) dirtyPaths.add(path)
return originalSaveNoteContent?.(args)
}
}
const patchGitHandlers = (handlers: Record<string, MockHandler>) => {
handlers.get_modified_files = () => createModifiedFiles()
handlers.git_commit = (args?: Record<string, unknown>) => {
const message = typeof args?.message === 'string' ? args.message : ''
browserWindow.__gitCommitMessages?.push(message)
dirtyPaths.clear()
ahead = 1
return `[main abc1234] ${message}`
}
handlers.git_push = () => {
browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1
ahead = 0
return { status: 'ok', message: 'Pushed to remote' }
}
handlers.git_remote_status = () => ({
branch: 'main',
ahead,
behind: 0,
hasRemote: true,
})
}
const markPatched = (handlers: Record<string, MockHandler>) => {
Object.defineProperty(handlers, '__manualCommitPatched', {
configurable: true,
enumerable: false,
value: true,
})
}
const patchHandlers = (handlers?: Record<string, MockHandler> | null) => {
if (isPatched(handlers)) {
return handlers ?? null
}
patchSaveNoteContent(handlers)
patchGitHandlers(handlers)
markPatched(handlers)
return handlers
}
browserWindow.__gitCommitMessages = []
browserWindow.__gitPushCalls = 0
let ref = patchHandlers(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
get() {
return patchHandlers(ref) ?? ref
},
set(value) {
ref = patchHandlers(value as Record<string, MockHandler> | undefined) ?? null
},
})
}
async function openFirstNote(page: Page) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5_000 })
await noteList.locator('.cursor-pointer').first().click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function setAutoGitEnabled(page: Page, enabled: boolean) {
await openCommandPalette(page)
await executeCommand(page, 'Open Settings')
const settingsPanel = page.getByTestId('settings-panel')
await expect(settingsPanel).toBeVisible({ timeout: 5_000 })
const toggle = page.getByRole('switch', { name: 'AutoGit' })
const isEnabled = (await toggle.getAttribute('aria-checked')) === 'true'
if (isEnabled !== enabled) {
await toggle.click()
}
await page.getByTestId('settings-save').click()
await expect(settingsPanel).not.toBeVisible({ timeout: 5_000 })
}
async function triggerCommitButton(page: Page) {
const commitButton = page.getByTestId('status-commit-push')
await commitButton.focus()
await expect(commitButton).toBeFocused()
await page.keyboard.press('Enter')
}
async function expectCommitMessage(page: Page, index: number, expectedMessage: string) {
await expect.poll(async () =>
page.evaluate(
({ targetIndex }) => (window as Window & { __gitCommitMessages?: string[] }).__gitCommitMessages?.[targetIndex] ?? '',
{ targetIndex: index },
),
).toBe(expectedMessage)
}
async function expectPushCount(page: Page, expectedCount: number) {
await expect.poll(async () =>
page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0),
).toBe(expectedCount)
}
test('@smoke commit entry opens the manual modal when AutoGit is off and switches back immediately when enabled', async ({ page }) => {
await page.addInitScript(installCommitFlowMocks)
await page.goto('/')
await page.waitForLoadState('networkidle')
await openFirstNote(page)
await setAutoGitEnabled(page, false)
await seedAutoGitSavedChange(page)
await triggerCommitButton(page)
await expect(page.getByRole('heading', { name: 'Commit & Push' })).toBeVisible()
const messageInput = page.locator('textarea[placeholder="Commit message..."]')
await expect(messageInput).toBeFocused()
await page.keyboard.press('Meta+A')
await page.keyboard.type('Manual commit from keyboard')
await page.keyboard.press('Meta+Enter')
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed and pushed', { timeout: 5_000 })
await expectCommitMessage(page, 0, 'Manual commit from keyboard')
await expectPushCount(page, 1)
await setAutoGitEnabled(page, true)
await seedAutoGitSavedChange(page)
await triggerCommitButton(page)
await expect(messageInput).not.toBeVisible()
await expectCommitMessage(page, 1, 'Updated 1 note')
await expectPushCount(page, 2)
})

View File

@@ -0,0 +1,184 @@
import { test, expect, type Page } from '@playwright/test'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { triggerShortcutCommand } from './testBridge'
const VIEW_RELATIVE_PATH = path.join('views', 'active-projects.yml')
const DUPLICATE_RELATIVE_PATH = path.join('views', 'active-projects.md')
const VIEW_TITLE = 'Active Projects'
const VIEW_CONTENT = `name: Active Projects
icon: rocket
color: blue
sort: "modified:desc"
filters:
all:
- field: type
op: equals
value: Project
`
let tempVaultDir: string
function seedViewFile(vaultPath: string): void {
const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH)
fs.mkdirSync(path.dirname(viewPath), { recursive: true })
fs.writeFileSync(viewPath, VIEW_CONTENT)
}
function buildViewEntry(vaultPath: string) {
const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH)
return {
path: viewPath,
filename: path.basename(viewPath),
title: VIEW_TITLE,
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: Date.now(),
createdAt: null,
fileSize: Buffer.byteLength(VIEW_CONTENT),
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
fileKind: 'text',
}
}
async function injectViewEntryIntoVaultList(page: Page, vaultPath: string): Promise<void> {
const viewEntry = buildViewEntry(vaultPath)
await page.route('**/api/vault/list*', async (route) => {
const response = await route.fetch()
const entries = await response.json()
if (!Array.isArray(entries)) {
await route.fulfill({ response })
return
}
const hasViewEntry = entries.some((entry) => entry?.path === viewEntry.path)
const nextEntries = hasViewEntry ? entries : [...entries, viewEntry]
await route.fulfill({ response, json: nextEntries })
})
}
async function openQuickOpenEntry(page: Page, title: string): Promise<void> {
await triggerShortcutCommand(page, APP_COMMAND_IDS.fileQuickOpen)
const input = page.locator('input[placeholder="Search notes..."]')
await expect(input).toBeVisible({ timeout: 5_000 })
await input.fill(title)
await page.keyboard.press('Enter')
}
async function appendRawEditorLine(page: Page, line: string): Promise<void> {
await page.evaluate((nextLine) => {
const host = document.querySelector('.cm-content')
if (!host) {
throw new Error('CodeMirror content element is missing')
}
type CodeMirrorHost = Element & {
cmTile?: {
view?: {
state: { doc: { toString(): string; length: number } }
dispatch(transaction: { changes: { from: number; to: number; insert: string } }): void
}
}
}
const view = (host as CodeMirrorHost).cmTile?.view
if (!view) {
throw new Error('CodeMirror view is missing')
}
const doc = view.state.doc.toString()
view.dispatch({
changes: {
from: doc.length,
to: doc.length,
insert: `\n${nextLine}\n`,
},
})
}, line)
}
async function readRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const host = document.querySelector('.cm-content')
if (!host) return ''
type CodeMirrorHost = Element & {
cmTile?: {
view?: {
state: { doc: { toString(): string } }
}
}
}
return (host as CodeMirrorHost).cmTile?.view?.state.doc.toString() ?? host.textContent ?? ''
})
}
test.describe('raw .yml view save regression', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
seedViewFile(tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('saving a raw-edited .yml view preserves the .yml file and blocks the raw toggle shortcut @smoke', async ({ page }) => {
const viewPath = path.join(tempVaultDir, VIEW_RELATIVE_PATH)
const duplicatePath = path.join(tempVaultDir, DUPLICATE_RELATIVE_PATH)
const marker = `# raw-yml-save-${Date.now()}-${os.userInfo().username}`
await injectViewEntryIntoVaultList(page, tempVaultDir)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await openQuickOpenEntry(page, VIEW_TITLE)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toHaveCount(0)
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toHaveCount(0)
await appendRawEditorLine(page, marker)
await triggerShortcutCommand(page, APP_COMMAND_IDS.fileSave)
await expect.poll(() => fs.readFileSync(viewPath, 'utf-8')).toContain(marker)
await expect.poll(() => fs.existsSync(duplicatePath)).toBe(false)
await openQuickOpenEntry(page, 'Alpha Project')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await openQuickOpenEntry(page, VIEW_TITLE)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await expect.poll(async () => readRawEditorContent(page)).toContain(marker)
})
})

View File

@@ -0,0 +1,83 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
async function openNote(page: Page, title: string) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (view) return view.state.doc.toString() as string
return el.textContent ?? ''
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) {
throw new Error('CodeMirror content element is missing')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (!view) {
throw new Error('CodeMirror view is missing')
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: nextContent },
})
}, content)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
await page.goto('/')
await page.waitForLoadState('networkidle')
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke switching notes persists unsaved raw edits without waiting for the debounce window', async ({ page }) => {
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
const appendedText = `Flushed before note switch ${Date.now()}`
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
await setRawEditorContent(page, `${rawContent}\n\n${appendedText}`)
await page.waitForTimeout(100)
await openNote(page, 'Alpha Project')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 })
await expect.poll(async () => getRawEditorContent(page)).toContain('# Alpha Project')
await expect.poll(
() => fs.readFileSync(noteBPath, 'utf8'),
{ timeout: 450, intervals: [50, 100, 100, 100, 100] },
).toContain(appendedText)
})

View File

@@ -56,6 +56,7 @@ test.describe('Wikilink insertion and navigation', () => {
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)