Compare commits
22 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec1627a81 | ||
|
|
b00183419e | ||
|
|
ed9ceff1d2 | ||
|
|
e18491bcd3 | ||
|
|
fd0cfc7fb6 | ||
|
|
6541ce5755 | ||
|
|
4456801526 | ||
|
|
db7aaf1385 | ||
|
|
54b2616fa4 | ||
|
|
6a90e9f770 | ||
|
|
b79c78a5ba | ||
|
|
48a8882970 | ||
|
|
98276702a2 | ||
|
|
857d55a7cd | ||
|
|
b78dfad384 | ||
|
|
9ac51bab9a | ||
|
|
1fee994863 | ||
|
|
764bbf0e4c | ||
|
|
ff05ba0430 | ||
|
|
347df47c62 | ||
|
|
7536169508 | ||
|
|
34de384738 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.86
|
||||
AVERAGE_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.7
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[](https://codescene.io/projects/76865) [](https://codescene.io/projects/76865)
|
||||
# Tolaria App
|
||||
|
||||
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
125
src/App.test.tsx
125
src/App.test.tsx
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
120
src/App.tsx
120
src/App.tsx
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}))),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal file
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
179
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal file
179
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal 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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.() },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
61
src/mock-tauri/vault-api.test.ts
Normal file
61
src/mock-tauri/vault-api.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
32
src/utils/commitEntryAction.test.ts
Normal file
32
src/utils/commitEntryAction.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
21
src/utils/commitEntryAction.ts
Normal file
21
src/utils/commitEntryAction.ts
Normal 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()
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
27
tests/helpers/fixtureVaultDesktopBridge.ts
Normal file
27
tests/helpers/fixtureVaultDesktopBridge.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
81
tests/smoke/fresh-start-telemetry-onboarding.spec.ts
Normal file
81
tests/smoke/fresh-start-telemetry-onboarding.spec.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
168
tests/smoke/manual-commit-modal-autogit-off.spec.ts
Normal file
168
tests/smoke/manual-commit-modal-autogit-off.spec.ts
Normal 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)
|
||||
})
|
||||
184
tests/smoke/raw-yml-view-save.spec.ts
Normal file
184
tests/smoke/raw-yml-view-save.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
83
tests/smoke/save-before-note-switch.spec.ts
Normal file
83
tests/smoke/save-before-note-switch.spec.ts
Normal 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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user