Compare commits

...

7 Commits

Author SHA1 Message Date
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
42 changed files with 1479 additions and 240 deletions

View File

@@ -345,7 +345,7 @@ Command-layer path access is fenced to the active vault before file operations r
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files.
### Vault Caching
@@ -743,9 +743,9 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
### Tauri Commands
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
@@ -771,6 +771,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -329,7 +329,7 @@ Tolaria can register itself as an MCP server in:
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
### Architecture
@@ -377,6 +377,7 @@ flowchart LR
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
@@ -727,6 +728,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -942,7 +944,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -425,6 +425,6 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -435,6 +435,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,

View File

@@ -313,6 +313,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
build_mcp_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
@@ -518,6 +540,22 @@ mod tests {
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
}
#[test]
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";

View File

@@ -1,5 +1,7 @@
use crate::settings;
use chrono::NaiveDate;
use regex::Regex;
use std::borrow::Cow;
use std::sync::Mutex;
/// Global Sentry guard — must live for the duration of the app.
@@ -44,6 +46,41 @@ fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
normalize_http_like_value(&normalized).parse().ok()
}
fn is_stable_calendar_release(version: &str) -> bool {
let mut parts = version.split('.');
let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
if parts.next().is_some() {
return false;
}
let (Ok(year), Ok(month), Ok(day)) = (
year.parse::<i32>(),
month.parse::<u32>(),
day.parse::<u32>(),
) else {
return false;
};
NaiveDate::from_ymd_opt(year, month, day).is_some()
}
fn sentry_release_for_version(version: &'static str) -> Option<Cow<'static, str>> {
is_stable_calendar_release(version).then(|| version.into())
}
fn sentry_release_kind(version: &str) -> &'static str {
if is_stable_calendar_release(version) {
"stable"
} else if version.contains('-') {
"prerelease"
} else {
"internal"
}
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -61,9 +98,10 @@ pub fn init_sentry_from_settings() -> bool {
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let build_version = env!("CARGO_PKG_VERSION");
let guard = sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
release: sentry_release_for_version(build_version),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
if let Some(ref mut msg) = event.message {
@@ -79,6 +117,8 @@ pub fn init_sentry_from_settings() -> bool {
id: Some(anonymous_id),
..Default::default()
}));
scope.set_tag("tolaria.build_version", build_version);
scope.set_tag("tolaria.release_kind", sentry_release_kind(build_version));
});
*SENTRY_GUARD.lock().unwrap() = Some(guard);
@@ -160,4 +200,26 @@ mod tests {
// Without SENTRY_DSN env var set at compile time, init should return false
assert!(!init_sentry_from_settings());
}
#[test]
fn test_sentry_release_for_stable_calendar_version() {
assert!(sentry_release_for_version("2026.4.28").is_some());
}
#[test]
fn test_sentry_release_for_alpha_version_is_none() {
assert!(sentry_release_for_version("2026.4.28-alpha.7").is_none());
}
#[test]
fn test_sentry_release_for_local_dev_version_is_none() {
assert!(sentry_release_for_version("0.1.0").is_none());
}
#[test]
fn test_sentry_release_kind_classifies_versions() {
assert_eq!(sentry_release_kind("2026.4.28"), "stable");
assert_eq!(sentry_release_kind("2026.4.28-alpha.7"), "prerelease");
assert_eq!(sentry_release_kind("0.1.0"), "internal");
}
}

View File

@@ -453,7 +453,16 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const {
mcpStatus,
connectMcp,
disconnectMcp,
mcpConfigSnippet,
mcpConfigLoading,
mcpConfigError,
loadMcpConfigSnippet,
copyMcpConfig,
} = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
@@ -493,6 +502,14 @@ function App() {
}
}, [disconnectMcp])
const handleCopyMcpConfig = useCallback(() => {
void copyMcpConfig()
}, [copyMcpConfig])
const handleLoadMcpConfigSnippet = useCallback(() => {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -1601,6 +1618,7 @@ function App() {
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
onCopyMcpConfig={handleCopyMcpConfig}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
@@ -1685,7 +1703,7 @@ function App() {
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog

View File

@@ -85,6 +85,15 @@ describe('AiPanel', () => {
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('copies the MCP config from the AI panel header action', () => {
const onCopyMcpConfig = vi.fn()
render(<AiPanel onClose={vi.fn()} onCopyMcpConfig={onCopyMcpConfig} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()

View File

@@ -21,6 +21,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
interface AiPanelProps {
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -42,6 +43,7 @@ interface AiPanelProps {
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -58,6 +60,7 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
export function AiPanelView({
controller,
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -113,6 +116,7 @@ export function AiPanelView({
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}
/>
{activeEntry && (
@@ -144,6 +148,7 @@ export function AiPanelView({
export function AiPanel({
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -183,6 +188,7 @@ export function AiPanel({
<AiPanelView
controller={controller}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}

View File

@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { Copy } from 'lucide-react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
@@ -12,6 +14,7 @@ interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
}
@@ -122,6 +125,7 @@ export function AiPanelHeader({
agentLabel,
agentReadiness,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
return (
@@ -140,21 +144,39 @@ export function AiPanelHeader({
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</button>
</Button>
</div>
)
}

View File

@@ -67,6 +67,7 @@ interface EditorProps {
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -338,6 +339,7 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
onCopyMcpConfig,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
@@ -400,6 +402,7 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
@@ -520,6 +523,7 @@ function EditorLayout({
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
@@ -551,6 +555,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
onCopyMcpConfig,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -611,6 +616,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -25,6 +25,7 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -48,6 +49,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onCopyMcpConfig,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -87,6 +89,7 @@ export function EditorRightPanel({
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}

View File

@@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
const MANUAL_CONFIG = JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
env: {
VAULT_PATH: '/Users/luca/Laputa',
WS_UI_PORT: '9711',
},
},
},
}, null, 2)
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
@@ -9,6 +23,7 @@ describe('McpSetupDialog', () => {
open={true}
status="not_installed"
busyAction={null}
manualConfigSnippet={MANUAL_CONFIG}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
@@ -18,9 +33,9 @@ describe('McpSetupDialog', () => {
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
expect(screen.getByText(/type: stdio/i)).toBeInTheDocument()
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
@@ -49,6 +64,7 @@ describe('McpSetupDialog', () => {
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onCopyManualConfig = vi.fn()
const onDisconnect = vi.fn()
render(
@@ -58,16 +74,37 @@ describe('McpSetupDialog', () => {
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onCopyManualConfig={onCopyManualConfig}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-copy-config'))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onCopyManualConfig).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
it('loads exact manual config when opened', () => {
const onLoadManualConfig = vi.fn()
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
onLoadManualConfig={onLoadManualConfig}
/>,
)
expect(onLoadManualConfig).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { Copy, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -14,6 +15,29 @@ interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
manualConfigError?: string | null
manualConfigLoading?: boolean
manualConfigSnippet?: string | null
onClose: () => void
onConnect: () => void
onCopyManualConfig?: () => void
onDisconnect: () => void
onLoadManualConfig?: () => void
}
interface ManualMcpConfigSectionProps {
error?: string | null
loading: boolean
onCopy?: () => void
snippet?: string | null
}
interface McpSetupActionsProps {
buttonsDisabled: boolean
connectBusy: boolean
disconnectBusy: boolean
primaryLabel: string
secondaryLabel: string | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
@@ -41,19 +65,100 @@ function actionCopy(status: McpStatus) {
}
}
function manualConfigText({ error, loading, snippet }: ManualMcpConfigSectionProps): string {
if (loading) return 'Loading exact MCP config...'
return error ?? snippet ?? 'Exact config is available after a vault is open.'
}
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="m-0 text-sm font-medium text-foreground">Manual MCP config</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={props.onCopy}
disabled={!props.onCopy || props.loading}
data-testid="mcp-copy-config"
>
<Copy size={14} />
Copy MCP config
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>
{manualConfigText(props)}
</pre>
</div>
)
}
function McpSetupActions({
buttonsDisabled,
connectBusy,
disconnectBusy,
primaryLabel,
secondaryLabel,
onClose,
onConnect,
onDisconnect,
}: McpSetupActionsProps) {
return (
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : primaryLabel}
</Button>
</DialogFooter>
)
}
export function McpSetupDialog({
open,
status,
busyAction,
manualConfigError,
manualConfigLoading = false,
manualConfigSnippet,
onClose,
onConnect,
onCopyManualConfig,
onDisconnect,
onLoadManualConfig,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
useEffect(() => {
if (open) onLoadManualConfig?.()
}, [open, onLoadManualConfig])
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
@@ -78,43 +183,27 @@ export function McpSetupDialog({
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
<div>type: stdio</div>
<div>command: node</div>
<div>args: &lt;Tolaria resources&gt;/mcp-server/index.js</div>
<div>VAULT_PATH: active vault</div>
<div>WS_UI_PORT: 9711</div>
</div>
<ManualMcpConfigSection
error={manualConfigError}
loading={manualConfigLoading}
onCopy={onCopyManualConfig}
snippet={manualConfigSnippet}
/>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
<McpSetupActions
buttonsDisabled={buttonsDisabled}
connectBusy={connectBusy}
disconnectBusy={disconnectBusy}
primaryLabel={copy.primaryLabel}
secondaryLabel={copy.secondaryLabel}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</DialogContent>
</Dialog>
)

View File

@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', async () => {
it('filters large datasets by search query', { timeout: 15000 }, async () => {
vi.useFakeTimers()
try {
const entries = [

View File

@@ -6,14 +6,20 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
let capturedDragHandleMenu: ComponentType | null = null
vi.mock('@blocknote/react', () => ({
AddBlockButton: () => <button type="button">Add block</button>,
DragHandleMenu: ({ children }: PropsWithChildren) => (
<div data-testid="drag-handle-menu">{children}</div>
),
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
DragHandleButton: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
capturedDragHandleMenu = dragHandleMenu ?? null
return <div data-testid="side-menu" />
return (
<button type="button" draggable>
Open block menu
</button>
)
},
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
useDictionary: () => ({
@@ -32,6 +38,10 @@ describe('TolariaSideMenu', () => {
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(capturedDragHandleMenu).not.toBeNull()
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Open block menu',
'Add block',
])
const DragHandleMenuComponent = capturedDragHandleMenu!
render(<DragHandleMenuComponent />)

View File

@@ -1,5 +1,7 @@
import {
AddBlockButton,
DragHandleMenu,
DragHandleButton,
RemoveBlockItem,
SideMenu,
TableColumnHeaderItem,
@@ -21,5 +23,10 @@ function TolariaDragHandleMenu() {
}
export function TolariaSideMenu(props: SideMenuProps) {
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
return (
<SideMenu {...props}>
<DragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
<AddBlockButton />
</SideMenu>
)
}

View File

@@ -149,7 +149,7 @@ function buildMaintenanceCommands({
id: 'install-mcp',
label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…',
group: 'Settings',
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
enabled: true,
execute: () => onInstallMcp?.(),
},

View File

@@ -24,6 +24,14 @@ function renderSubject(onToast = vi.fn()) {
return renderHook(() => useMcpStatus('/vault', onToast))
}
function mockClipboard(writeText = vi.fn(() => Promise.resolve())) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
function mockStatusFlow(
initialStatus: 'installed' | 'not_installed',
overrides: Partial<Record<'register_mcp_tools' | 'remove_mcp_tools', unknown>> = {},
@@ -76,6 +84,7 @@ async function runMutationScenario({
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
mockClipboard()
})
it('checks the active vault status without auto-registering on mount', async () => {
@@ -160,4 +169,47 @@ describe('useMcpStatus', () => {
expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs)
expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment))
})
it('loads the exact manual MCP config snippet for the active vault', async () => {
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject()
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
await act(async () => {
await result.current.loadMcpConfigSnippet()
})
expect(result.current.mcpConfigSnippet).toBe(snippet)
expect(result.current.mcpConfigError).toBeNull()
expect(mockInvoke).toHaveBeenCalledWith('get_mcp_config_snippet', { vaultPath: '/vault' })
})
it('copies the manual MCP config snippet to the clipboard', async () => {
const writeText = mockClipboard()
const onToast = vi.fn()
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'not_installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject(onToast)
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await expect(result.current.copyMcpConfig()).resolves.toBe(true)
})
expect(writeText).toHaveBeenCalledWith(snippet)
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
})
})

View File

@@ -1,49 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed'
type ManualConfigSnippet = string
type McpCommand =
| 'check_mcp_status'
| 'get_mcp_config_snippet'
| 'register_mcp_tools'
| 'remove_mcp_tools'
type McpCommandResult = string
type McpStatusResponse = string
type ToastHandler = (msg: ToastMessage) => void
type ToastMessage = string
type VaultPath = string
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
interface ManualMcpConfigState {
error: ToastMessage | null
loading: boolean
snippet: ManualConfigSnippet | null
vaultPath: VaultPath
}
const EMPTY_MANUAL_CONFIG: ManualMcpConfigState = {
error: null,
loading: false,
snippet: null,
vaultPath: '',
}
function tauriCall<T>(command: McpCommand, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus {
return value === 'installed' ? 'installed' : 'not_installed'
}
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
async function fetchMcpStatus(vaultPath: VaultPath): Promise<McpStatus> {
try {
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
const result = await tauriCall<McpStatusResponse>('check_mcp_status', { vaultPath })
return normalizeMcpStatus(result)
} catch {
return 'not_installed'
}
}
function connectSuccessToast(result: string): string {
function connectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'registered'
? 'Tolaria external AI tools connected successfully'
: 'Tolaria external AI tools setup refreshed successfully'
}
function disconnectSuccessToast(result: string): string {
function disconnectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'removed'
? 'Tolaria external AI tools disconnected successfully'
: 'Tolaria external AI tools were already disconnected'
}
function errorMessage(error: unknown): ToastMessage {
return error instanceof Error ? error.message : String(error)
}
function visibleManualConfig(
state: ManualMcpConfigState,
vaultPath: VaultPath,
): ManualMcpConfigState {
return state.vaultPath === vaultPath ? state : EMPTY_MANUAL_CONFIG
}
async function writeClipboardText(value: ManualConfigSnippet): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(value)
}
function useManualMcpConfig(
vaultPath: VaultPath,
onToastRef: MutableRefObject<ToastHandler>,
) {
const [manualConfig, setManualConfig] = useState<ManualMcpConfigState>(EMPTY_MANUAL_CONFIG)
const loadMcpConfigSnippet = useCallback(async () => {
setManualConfig({ error: null, loading: true, snippet: null, vaultPath })
try {
const snippet = await tauriCall<ManualConfigSnippet>('get_mcp_config_snippet', { vaultPath })
setManualConfig({ error: null, loading: false, snippet, vaultPath })
return snippet
} catch (error) {
const message = errorMessage(error)
setManualConfig({ error: message, loading: false, snippet: null, vaultPath })
throw error
}
}, [vaultPath])
const copyMcpConfig = useCallback(async () => {
try {
const snippet = await loadMcpConfigSnippet()
await writeClipboardText(snippet)
onToastRef.current('Tolaria MCP config copied to clipboard')
return true
} catch (error) {
onToastRef.current(`Copy MCP config failed: ${errorMessage(error)}`)
return false
}
}, [loadMcpConfigSnippet, onToastRef])
const currentManualConfig = visibleManualConfig(manualConfig, vaultPath)
return {
mcpConfigSnippet: currentManualConfig.snippet,
mcpConfigLoading: currentManualConfig.loading,
mcpConfigError: currentManualConfig.error,
loadMcpConfigSnippet,
copyMcpConfig,
}
}
/**
* Detects whether the active vault is explicitly connected to external MCP
* clients and exposes connect / disconnect actions.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
vaultPath: VaultPath,
onToast: ToastHandler,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
const manualConfigActions = useManualMcpConfig(vaultPath, onToastRef)
const refreshMcpStatus = useCallback(async () => {
const nextStatus = await fetchMcpStatus(vaultPath)
@@ -91,5 +178,11 @@ export function useMcpStatus(
}
}, [refreshMcpStatus])
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
return {
mcpStatus: status,
refreshMcpStatus,
connectMcp,
disconnectMcp,
...manualConfigActions,
}
}

View File

@@ -74,11 +74,17 @@ describe('useNoteActions hook', () => {
return renderHook(() => useNoteActions(makeConfig(entries)))
}
function createImmediateEntry(type?: string) {
async function flushAsyncWork() {
await Promise.resolve()
await Promise.resolve()
}
async function createImmediateEntry(type?: string) {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderActions()
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate(type)
await flushAsyncWork()
})
const [createdEntry] = addEntry.mock.calls[0]
vi.restoreAllMocks()
@@ -194,25 +200,29 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
const createdEntry = createImmediateEntry()
it('handleCreateNoteImmediate creates note with timestamp-based title', async () => {
const createdEntry = await createImmediateEntry()
expect(createdEntry.title).toBe('Untitled Note 1700000000')
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
expect(createdEntry.isA).toBe('Note')
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
@@ -224,8 +234,8 @@ describe('useNoteActions hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
const createdEntry = createImmediateEntry('Project')
it('handleCreateNoteImmediate accepts custom type', async () => {
const createdEntry = await createImmediateEntry('Project')
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
expect(createdEntry.isA).toBe('Project')
})
@@ -255,40 +265,29 @@ describe('useNoteActions hook', () => {
expect(tabContent).toContain('## Steps')
})
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
it.each([
['Q&A', (entry: VaultEntry) => { expect(entry.isA).toBe('Q&A') }],
['+++', (entry: VaultEntry) => { expect(entry.filename).not.toBe('.md') }],
])('handleCreateNoteImmediate handles custom type "%s"', async (typeName, assertEntry) => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('Q&A')
})
}).not.toThrow()
const [entry] = addEntry.mock.calls[0]
expect(entry.isA).toBe('Q&A')
expect(entry.path).not.toContain('//')
})
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('+++')
})
}).not.toThrow()
await act(async () => {
expect(() => { result.current.handleCreateNoteImmediate(typeName) }).not.toThrow()
await flushAsyncWork()
})
const [entry] = addEntry.mock.calls[0]
expect(entry.path).not.toContain('//')
expect(entry.filename).not.toBe('.md')
assertEntry(entry)
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
it('handleCreateNoteImmediate uses template for typed notes', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushAsyncWork()
})
const tabContent = result.current.tabs[0].content
@@ -307,7 +306,15 @@ describe('useNoteActions hook', () => {
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
it.each([
['start', 'Pending Test', 'pending-test.md', 'addPendingSave'],
['completion', 'Persist OK', 'persist-ok.md', 'removePendingSave'],
])('createAndPersist calls pending-save callback on %s (non-Tauri)', async (
_phase,
title,
pathFragment,
callbackName,
) => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
@@ -317,28 +324,12 @@ describe('useNoteActions hook', () => {
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Pending Test', 'Note')
await new Promise((r) => setTimeout(r, 0))
result.current.handleCreateNote(title, 'Note')
await flushAsyncWork()
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md'))
})
it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist OK', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md'))
const callback = callbackName === 'addPendingSave' ? addPendingSave : removePendingSave
expect(callback).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
})
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
@@ -363,22 +354,34 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNoteImmediate()
await new Promise((r) => setTimeout(r, 0))
await flushAsyncWork()
})
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note'))
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(result.current.tabs[0].entry.path).toMatch(/untitled-note-\d+\.md$/)
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
@@ -454,20 +457,24 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
it('handleCreateNoteImmediate writes each rapid note before opening it', async () => {
vi.useFakeTimers()
vi.mocked(invoke).mockResolvedValue(undefined)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
// No disk writes for immediate creation — notes are unsaved/in-memory
expect(invoke).not.toHaveBeenCalled()
expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'create_note_content')).toHaveLength(3)
expect(removeEntry).not.toHaveBeenCalled()
})

View File

@@ -211,6 +211,10 @@ describe('useNoteCreation hook', () => {
})
const tabDeps = { openTabWithContent }
const flushImmediateCreate = async () => {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(() => {
vi.clearAllMocks()
@@ -230,10 +234,13 @@ describe('useNoteCreation hook', () => {
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntitle: Test Note\ntype: Note\n---\n')
})
it('handleCreateNoteImmediate generates timestamp-based title', () => {
it('handleCreateNoteImmediate generates timestamp-based title', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
@@ -242,17 +249,21 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness
expect(new Set(filenames).size).toBe(3)
@@ -262,16 +273,20 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename)
expect(filenames).toEqual([
@@ -282,66 +297,117 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('serializes rapid immediate-create bursts after the first note', () => {
it('serializes rapid immediate-create bursts after the first note', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(2)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(3)
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
it('handleCreateNoteImmediate accepts custom type', async () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate('Project') })
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].isA).toBe('Project')
expect(addEntry.mock.calls[0][0].status).toBeNull()
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntype: Project\n---\n\n# \n\n## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n')
})
it('handleCreateNoteImmediate slugifies custom type names for filenames', () => {
it('handleCreateNoteImmediate slugifies custom type names for filenames', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Q&A / Ops')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-q-a-ops-1700000000.md')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate tracks unsaved state', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = {
...makeConfig(),
addPendingSave,
removePendingSave,
onNewNotePersisted,
}
const { result } = renderHook(() => useNoteCreation(config, tabDeps))
await act(async () => { result.current.handleCreateNoteImmediate() })
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalled()
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(openTabWithContent).toHaveBeenCalledTimes(1)
expect(vi.mocked(invoke).mock.invocationCallOrder[0]).toBeLessThan(
openTabWithContent.mock.invocationCallOrder[0],
)
})
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
it('handleCreateNoteImmediate does not open an optimistic note when disk creation fails', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).not.toHaveBeenCalled()
expect(openTabWithContent).not.toHaveBeenCalled()
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate requests editor focus for the new path', async () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { slugifyNoteStem as slugify } from '../utils/noteSlug'
import { resolveEntry } from '../utils/wikilink'
import { trackEvent } from '../lib/telemetry'
import { cacheNoteContent } from './useTabManagement'
export interface NewEntryParams {
path: string
@@ -370,13 +371,15 @@ async function createTypeSilently({
}
interface ImmediateCreateDeps {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
pendingSlugs: Set<string>
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
interface ImmediateCreateRequest {
@@ -384,12 +387,14 @@ interface ImmediateCreateRequest {
}
interface ImmediateCreateQueueConfig {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
addEntry: (entry: VaultEntry) => void
openTabWithContent: (entry: VaultEntry, content: string) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
/** Generate a unique untitled filename using a timestamp. */
@@ -410,8 +415,26 @@ function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSl
return candidate
}
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
async function persistImmediateEntry(
deps: ImmediateCreateDeps,
entry: VaultEntry,
content: string,
): Promise<boolean> {
try {
await persistOptimistic(entry.path, content, {
onStart: deps.addPendingSave,
onEnd: deps.removePendingSave,
onPersisted: deps.onNewNotePersisted,
})
return true
} catch (error) {
deps.setToastMessage(createPersistFailureMessage(entry, error))
return false
}
}
/** Create an untitled note and write its backing file before opening it. */
async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Promise<boolean> {
const noteType = type || 'Note'
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
@@ -419,11 +442,68 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const status = null
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
const didPersist = await persistImmediateEntry(deps, entry, content)
if (!didPersist) return false
cacheNoteContent(entry.path, content)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor({ path: entry.path, selectTitle: true })
return true
}
function trackImmediateCreate(request: ImmediateCreateRequest, didCreate: boolean): void {
if (!didCreate) return
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}
function useLatestImmediateCreateDeps(
config: ImmediateCreateQueueConfig,
pendingSlugsRef: MutableRefObject<Set<string>>,
) {
const {
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
} = config
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries,
vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
}
}, [
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
pendingSlugsRef,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
return { latestDepsRef, syncDeps }
}
function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: string) => void {
@@ -431,40 +511,13 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries: config.entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent: config.openTabWithContent,
addEntry: config.addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [
config.entries,
config.vaultPath,
config.openTabWithContent,
config.addEntry,
config.trackUnsaved,
config.markContentPending,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
const { latestDepsRef, syncDeps } = useLatestImmediateCreateDeps(config, pendingSlugsRef)
const executeRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}, [])
void createNoteImmediate(deps, request.type).then((didCreate) => trackImmediateCreate(request, didCreate))
}, [latestDepsRef])
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
if (immediateCreateTimerRef.current !== null) return
@@ -577,9 +630,11 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
entries,
vaultPath,
addEntry,
addPendingSave,
openTabWithContent,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
onNewNotePersisted,
removePendingSave,
setToastMessage,
})
return {

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
const sentryMocks = vi.hoisted(() => ({
close: vi.fn(),
init: vi.fn(),
setTag: vi.fn(),
setUser: vi.fn(),
}))
@@ -22,6 +23,7 @@ afterEach(() => {
vi.unstubAllEnvs()
sentryMocks.close.mockClear()
sentryMocks.init.mockClear()
sentryMocks.setTag.mockClear()
sentryMocks.setUser.mockClear()
})
@@ -69,17 +71,23 @@ describe('trackEvent', () => {
})
describe('initSentry', () => {
it('passes the configured build release to Sentry', () => {
it.each([
['stable builds', '2026.4.23', '2026.4.23', 'stable'],
['alpha builds', '2026.4.28-alpha.7', undefined, 'prerelease'],
['local builds', '0.1.0', undefined, 'internal'],
])('sets release metadata for %s', (_name, buildVersion, sentryRelease, releaseKind) => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://public@example.ingest.sentry.io/123456')
vi.stubEnv('VITE_SENTRY_RELEASE', '2026.4.23')
vi.stubEnv('VITE_SENTRY_RELEASE', buildVersion)
initSentry('anonymous-user')
expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({
dsn: 'https://public@example.ingest.sentry.io/123456',
release: '2026.4.23',
release: sentryRelease,
}))
expect(sentryMocks.setUser).toHaveBeenCalledWith({ id: 'anonymous-user' })
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.build_version', buildVersion)
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.release_kind', releaseKind)
})
})

View File

@@ -25,7 +25,7 @@ let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: string): void {
if (sentryInitialized) return
const { sentryDsn, sentryRelease } = resolveFrontendTelemetryConfig()
const { sentryDsn, sentryBuildVersion, sentryRelease } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
@@ -35,6 +35,14 @@ export function initSentry(anonymousId: string): void {
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
if (sentryBuildVersion) {
const releaseKind = sentryRelease
? 'stable'
: sentryBuildVersion.includes('-') ? 'prerelease' : 'internal'
Sentry.setTag('tolaria.build_version', sentryBuildVersion)
Sentry.setTag('tolaria.release_kind', releaseKind)
}
sentryInitialized = true
}

View File

@@ -48,6 +48,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -62,6 +63,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -83,6 +85,24 @@ describe('resolveFrontendTelemetryConfig', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: 'false' }).sentryRelease).toBe('')
})
it('keeps stable calendar versions as Sentry releases', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28' }).sentryRelease).toBe('2026.4.28')
})
it('drops prerelease versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28-alpha.7' })).toMatchObject({
sentryBuildVersion: '2026.4.28-alpha.7',
sentryRelease: '',
})
})
it('drops local development versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '0.1.0' })).toMatchObject({
sentryBuildVersion: '0.1.0',
sentryRelease: '',
})
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
})
@@ -93,6 +113,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'false',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -105,6 +126,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'https://le',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -115,6 +137,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'http://localhost:8010',
})).toEqual({
sentryDsn: 'http://public@localhost:9000/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'http://localhost:8010',

View File

@@ -17,6 +17,7 @@ type TelemetryEnv = {
export type FrontendTelemetryConfig = {
sentryDsn: string
sentryBuildVersion: string
sentryRelease: string
posthogKey: string
posthogHost: string | null
@@ -87,8 +88,18 @@ function normalizeSentryDsn(value: string): string {
}
function normalizeSentryRelease(value: string): string {
if (!value) return ''
return DISALLOWED_TELEMETRY_VALUES.has(value.toLowerCase()) ? '' : value
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/.exec(value)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
const validDate = date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
return validDate ? value : ''
}
function normalizePostHogHost(value: string): string | null {
@@ -103,15 +114,17 @@ export function resolveFrontendTelemetryConfig(
const sentryDsn = normalizeSentryDsn(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
)
const sentryRelease = normalizeSentryRelease(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE),
)
const sanitizedSentryVersion = sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE)
const sentryBuildVersion = DISALLOWED_TELEMETRY_VALUES.has(sanitizedSentryVersion.toLowerCase())
? ''
: sanitizedSentryVersion
const sentryRelease = normalizeSentryRelease(sentryBuildVersion)
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost(
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
)
return { sentryDsn, sentryRelease, posthogKey, posthogHost }
return { sentryDsn, sentryBuildVersion, sentryRelease, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View File

@@ -486,6 +486,19 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_mcp_config_snippet: (args: { vaultPath?: string }) => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/mock/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: args.vaultPath ?? '/Users/mock/Documents/Getting Started',
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
repair_vault: (): string => {
mockVaultAiGuidanceStatus = {

View File

@@ -95,6 +95,58 @@ describe('math markdown round-trip', () => {
)
})
it('round-trips inline math inside table cells', () => {
const tableCellMath = preProcessMathMarkdown({ markdown: '$a+b$' })
const blocks = [{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [{ type: 'text', text: tableCellMath, styles: {} }],
}],
}],
},
children: [],
}]
const [tableBlock] = injectMathInBlocks(blocks) as Array<{
content: { rows: Array<{ cells: Array<{ content: unknown[] }> }> }
}>
expect(tableBlock.content.rows[0].cells[0].content).toEqual([{
type: MATH_INLINE_TYPE,
props: { latex: 'a+b' },
content: undefined,
}])
const editor = {
blocksToMarkdownLossy: vi.fn(() => '| Formula |\n| --- |\n| $a+b$ |'),
}
expect(serializeMathAwareBlocks(editor, [tableBlock])).toBe('| Formula |\n| --- |\n| $a+b$ |')
expect(editor.blocksToMarkdownLossy).toHaveBeenCalledWith([{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [{ type: 'text', text: '$a+b$' }],
}],
}],
},
children: [],
}])
})
it('leaves display-style math inside table cells as Markdown source', () => {
expect(preProcessMathMarkdown({ markdown: '| Formula |\n| --- |\n| $$c$$ |' })).toBe(
'| Formula |\n| --- |\n| $$c$$ |',
)
})
it('leaves inline code and fenced code math-looking text untouched', () => {
const markdown = [
'Keep `$not_math$` literal.',

View File

@@ -19,12 +19,33 @@ interface InlineItem {
interface BlockLike {
type?: string
content?: InlineItem[]
content?: BlockContent
props?: Record<string, string>
children?: BlockLike[]
[key: string]: unknown
}
type BlockContent = InlineItem[] | TableContentLike | unknown
interface TableContentLike {
type?: string
rows?: TableRowLike[]
[key: string]: unknown
}
interface TableRowLike {
cells?: TableCellValue[]
[key: string]: unknown
}
interface TableCellLike {
content?: InlineItem[]
[key: string]: unknown
}
type TableCellValue = TableCellLike | string
type InlineContentTransform = (content: InlineItem[]) => InlineItem[]
interface MarkdownSerializer {
blocksToMarkdownLossy: (blocks: unknown[]) => string
}
@@ -111,7 +132,7 @@ function isCodeFence({ text: line }: { text: string }): boolean {
}
function isSingleDollar({ text, index }: TextPosition): boolean {
return text[index] === '$' && text[index + 1] !== '$'
return text[index] === '$' && text[index - 1] !== '$' && text[index + 1] !== '$'
}
function isInlineMathEnd(position: TextPosition): boolean {
@@ -274,10 +295,47 @@ function restoreInlineMath(content: InlineItem[]): InlineItem[] {
})
}
function isTableContent(content: BlockContent): content is TableContentLike {
return Boolean(
content
&& typeof content === 'object'
&& !Array.isArray(content)
&& (content as TableContentLike).type === 'tableContent'
&& Array.isArray((content as TableContentLike).rows),
)
}
function transformTableCell(cell: TableCellValue, transform: InlineContentTransform): TableCellValue {
if (typeof cell === 'string' || !Array.isArray(cell.content)) return cell
return { ...cell, content: transform(cell.content) }
}
function transformTableContent(
content: TableContentLike,
transform: InlineContentTransform,
): TableContentLike {
return {
...content,
rows: content.rows?.map((row) => ({
...row,
cells: row.cells?.map((cell) => transformTableCell(cell, transform)),
})),
}
}
function transformBlockContent(
content: BlockContent,
transform: InlineContentTransform,
): BlockContent {
if (Array.isArray(content)) return transform(content)
if (isTableContent(content)) return transformTableContent(content, transform)
return content
}
function injectMathInBlock(block: BlockLike): BlockLike {
const content = Array.isArray(block.content) ? expandInlineMath(block.content) : block.content
const content = transformBlockContent(block.content, expandInlineMath)
const children = Array.isArray(block.children) ? block.children.map(injectMathInBlock) : block.children
const latex = readDisplayMathToken(content)
const latex = Array.isArray(content) ? readDisplayMathToken(content) : null
if (latex !== null) {
return buildMathBlock({ block, latex })
@@ -303,7 +361,7 @@ function buildMathBlock({ block, latex }: { block: BlockLike } & LatexPayload):
}
function restoreInlineMathInBlock(block: BlockLike): BlockLike {
const content = Array.isArray(block.content) ? restoreInlineMath(block.content) : block.content
const content = transformBlockContent(block.content, restoreInlineMath)
const children = Array.isArray(block.children) ? block.children.map(restoreInlineMathInBlock) : block.children
return { ...block, content, children }
}

View File

@@ -34,6 +34,23 @@ describe('preProcessWikilinks', () => {
expect(preProcessWikilinks(input)).toBe(input)
})
it('preserves wikilinks and inline formatting inside markdown tables', () => {
const input = [
'| Topic | Reference |',
'| --- | --- |',
'| [[Project Alpha]] | **bold** and [[Project Beta]] |',
'',
'Outside [[Project Gamma]]',
].join('\n')
const result = preProcessWikilinks(input)
expect(result).toContain('| [[Project Alpha]] | **bold** and [[Project Beta]] |')
expect(result).toContain('WIKILINK:Project Gamma')
expect(result).not.toContain('WIKILINK:Project Alpha')
expect(result).not.toContain('WIKILINK:Project Beta')
})
it('handles empty string', () => {
expect(preProcessWikilinks('')).toBe('')
})

View File

@@ -2,10 +2,16 @@
const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
const WL_RE = new RegExp(`${WL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^${WL_END}]+)${WL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g')
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g
const FORMAT_MARKERS = new Set(['*', '_', '`', '~'])
/** Pre-process markdown: replace [[target]] with placeholder tokens */
export function preProcessWikilinks(md: string): string {
return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`)
const lines = md.split('\n')
const tableLines = findMarkdownTableLines(lines)
return lines.map((line, index) => (
tableLines[index] ? line : replaceWikilinksWithPlaceholders(line)
)).join('\n')
}
// Minimal shape of a BlockNote block for wikilink processing
@@ -25,6 +31,54 @@ interface InlineItem {
type ContentTransform = (content: InlineItem[]) => InlineItem[]
function replaceWikilinksWithPlaceholders(line: string): string {
return line.replace(WIKILINK_RE, (_match, target) => `${WL_START}${target}${WL_END}`)
}
function findMarkdownTableLines(lines: string[]): boolean[] {
const tableLines = lines.map(() => false)
for (let index = 0; index < lines.length - 1; index++) {
if (!isPotentialTableRow(lines[index]) || !isMarkdownTableSeparator(lines[index + 1])) {
continue
}
tableLines[index] = true
tableLines[index + 1] = true
index = markTableBodyLines(lines, tableLines, index + 2) - 1
}
return tableLines
}
function markTableBodyLines(lines: string[], tableLines: boolean[], start: number): number {
let index = start
while (index < lines.length && isPotentialTableRow(lines[index])) {
tableLines[index] = true
index++
}
return index
}
function isPotentialTableRow(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('|') && trimmed !== '|'
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = splitTableCells(line)
return cells.length > 1 && cells.every(isMarkdownTableSeparatorCell)
}
function splitTableCells(line: string): string[] {
let trimmed = line.trim()
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
return trimmed.split('|').map((cell) => cell.trim()).filter(Boolean)
}
function isMarkdownTableSeparatorCell(cell: string): boolean {
return /^:?-{3,}:?$/.test(cell)
}
/** Walk blocks recursively, applying a transform to each block's inline content */
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
return (blocks as BlockLike[]).map(block => {
@@ -188,21 +242,15 @@ function stripMarkdownChars(s: string): string {
let result = ''
let i = 0
while (i < s.length) {
if (s[i] === '[' && s[i + 1] === '[') {
i += 2
let inner = ''
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
if (i < s.length - 1) i += 2
const pipe = inner.indexOf('|')
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
if (s.startsWith('[[', i)) {
const parsed = readUntilSequence(s, i + 2, ']]')
result += wikilinkDisplayText(parsed.text)
i = parsed.nextIndex
} else if (s[i] === '[') {
i++
let text = ''
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
if (i < s.length) i++
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
result += text
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
const parsed = readUntilChar(s, i + 1, ']')
result += parsed.text
i = skipMarkdownLinkDestination(s, parsed.nextIndex)
} else if (FORMAT_MARKERS.has(s[i])) {
i++
} else {
result += s[i]
@@ -212,6 +260,30 @@ function stripMarkdownChars(s: string): string {
return result
}
function readUntilSequence(value: string, start: number, sequence: string): { text: string, nextIndex: number } {
const end = value.indexOf(sequence, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + sequence.length }
}
function readUntilChar(value: string, start: number, char: string): { text: string, nextIndex: number } {
const end = value.indexOf(char, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + 1 }
}
function skipMarkdownLinkDestination(value: string, start: number): number {
if (value[start] !== '(') return start
const end = value.indexOf(')', start + 1)
return end === -1 ? value.length : end + 1
}
function wikilinkDisplayText(inner: string): string {
const pipe = inner.indexOf('|')
return pipe === -1 ? inner : inner.slice(pipe + 1)
}
/** Extract sub-heading text (## , ### , etc.) stripped of the # prefix. */
function extractSubheadingText(line: string): string | null {
const t = line.trim()

View File

@@ -252,6 +252,19 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix
save_vault_list: () => null,
save_settings: () => null,
register_mcp_tools: () => null,
get_mcp_config_snippet: () => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/fixture/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: resolvedVaultPath,
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
reinit_telemetry: () => null,
update_menu_state: () => null,
get_settings: () => ({

View File

@@ -100,7 +100,7 @@ test.describe('AI chat conversation history', () => {
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await page.keyboard.press('Tab')
await page.getByTitle('New AI chat').focus()
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)

View File

@@ -0,0 +1,114 @@
import fs from 'fs'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
interface CreateNoteProbe {
createCalls: string[]
getBeforeCreate: string[]
}
interface ProbeWindow {
__mockHandlers?: Record<string, (args?: unknown) => unknown>
__createNoteBackingFileProbe?: CreateNoteProbe
}
let tempVaultDir: string
async function pinFixtureHandlers(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers
if (!handlers?.create_note_content || !handlers.get_note_content) {
throw new Error('Fixture vault handlers are missing create/read commands')
}
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
get: () => handlers,
set: (nextHandlers) => Object.assign(handlers, nextHandlers),
})
})
}
async function recordCreateNoteCalls(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalCreate = handlers.create_note_content.bind(handlers)
const probe: CreateNoteProbe = { createCalls: [], getBeforeCreate: [] }
probeWindow.__createNoteBackingFileProbe = probe
handlers.create_note_content = async (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
await new Promise((resolve) => setTimeout(resolve, 100))
const result = await originalCreate(args)
probe.createCalls.push(notePath)
return result
}
})
}
async function rejectReadsBeforeCreate(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalGet = handlers.get_note_content.bind(handlers)
const probe = probeWindow.__createNoteBackingFileProbe as CreateNoteProbe
handlers.get_note_content = (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
if (notePath.includes('untitled-note-') && !probe.createCalls.includes(notePath)) {
probe.getBeforeCreate.push(notePath)
throw new Error(`File does not exist: ${notePath}`)
}
return originalGet(args)
}
})
}
async function installCreateNoteBackingFileProbe(page: Page): Promise<void> {
await pinFixtureHandlers(page)
await recordCreateNoteCalls(page)
await rejectReadsBeforeCreate(page)
}
async function readProbe(page: Page): Promise<CreateNoteProbe> {
return page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
return probeWindow.__createNoteBackingFileProbe ?? { createCalls: [], getBeforeCreate: [] }
})
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultTauri(page, tempVaultDir)
await installCreateNoteBackingFileProbe(page)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke creating a note writes its backing file before reload can read it', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await triggerMenuCommand(page, 'file-new-note')
await triggerMenuCommand(page, 'vault-reload')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, {
timeout: 5_000,
})
await expect.poll(() => readProbe(page), { timeout: 5_000 }).toMatchObject({
createCalls: [expect.stringMatching(/untitled-note-\d+\.md$/)],
getBeforeCreate: [],
})
const { createCalls, getBeforeCreate } = await readProbe(page)
expect(getBeforeCreate).toEqual([])
expect(errors.filter((message) => message.includes('File does not exist'))).toEqual([])
expect(fs.readFileSync(createCalls[0], 'utf8')).toContain('type: Note')
})

View File

@@ -19,10 +19,11 @@ async function blockOuterForText(page: Page, text: string): Promise<Locator> {
return textNode.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " bn-block-outer ")][1]')
}
async function visibleDragHandle(page: Page, block: Locator): Promise<Locator> {
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const handle = page.locator('.bn-side-menu [draggable="true"]').first()
const handle = page.locator('.bn-side-menu button').first()
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).toHaveAttribute('draggable', 'true')
return handle
}
@@ -59,7 +60,7 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
const handle = await visibleDragHandle(page, notesHeading)
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/)

View File

@@ -9,6 +9,8 @@ let tempVaultDir: string
const INLINE_LATEX = 'E=mc^2'
const DISPLAY_LATEX = '\\int_0^1 x^2 \\, dx = \\frac{1}{3}'
const MALFORMED_LATEX = '\\frac{'
const TABLE_INLINE_LATEX = '\\frac{a}{b}+c'
const TABLE_DISPLAY_STYLE_LATEX = '\\sum_{i=1}^{n} i'
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
@@ -107,6 +109,11 @@ ${DISPLAY_LATEX}
$$
Malformed math $${MALFORMED_LATEX}$ stays visible.
| Kind | Formula |
| --- | --- |
| inline | $${TABLE_INLINE_LATEX}$ |
| display-style | $$${TABLE_DISPLAY_STYLE_LATEX}$$ |
`
await setRawEditorContent(page, nextContent)
@@ -117,15 +124,20 @@ Malformed math $${MALFORMED_LATEX}$ stays visible.
await toggleRawMode(page, '.bn-editor')
await expectMathNode(page, '.math--inline', INLINE_LATEX)
await expectMathNode(page, '.math--inline', TABLE_INLINE_LATEX)
await expectMathNode(page, '.math--block', DISPLAY_LATEX)
await expectMathNode(page, '.math--inline', MALFORMED_LATEX)
await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(3)
await expect(page.locator('table')).toHaveCount(1)
await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(4)
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
expect(rawAfterRichMode).toContain(`$${INLINE_LATEX}$`)
expect(rawAfterRichMode).toContain(`$$\n${DISPLAY_LATEX}\n$$`)
expect(rawAfterRichMode).toContain(`$${MALFORMED_LATEX}$`)
expect(rawAfterRichMode).toContain(`$${TABLE_INLINE_LATEX}$`)
expect(rawAfterRichMode).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`)
expect(rawAfterRichMode).not.toContain('@@TOLARIA_MATH')
await toggleRawMode(page, '.bn-editor')
await openNote(page, 'Note C')
@@ -136,4 +148,7 @@ Malformed math $${MALFORMED_LATEX}$ stays visible.
expect(reopenedRaw).toContain(`$${INLINE_LATEX}$`)
expect(reopenedRaw).toContain(`$$\n${DISPLAY_LATEX}\n$$`)
expect(reopenedRaw).toContain(`$${MALFORMED_LATEX}$`)
expect(reopenedRaw).toContain(`$${TABLE_INLINE_LATEX}$`)
expect(reopenedRaw).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`)
expect(reopenedRaw).not.toContain('@@TOLARIA_MATH')
})

View File

@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('MCP config copy', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
})
test('copies the active-vault MCP config from the AI panel using only the keyboard', async ({ context, page }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.locator('.app__note-list .cursor-pointer').first().click()
await page.locator('.bn-editor').click()
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
await page.getByTestId('agent-input').focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
const copyButton = page.getByRole('button', { name: 'Copy MCP config' })
await expect(copyButton).toBeFocused()
await copyButton.press('Enter')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mcpServers"')
const copiedConfig = await page.evaluate(() => navigator.clipboard.readText())
const parsedConfig = JSON.parse(copiedConfig) as {
mcpServers: {
tolaria: {
args: string[]
command: string
env: Record<string, string>
type: string
}
}
}
const tolariaServer = parsedConfig.mcpServers.tolaria
expect(tolariaServer.type).toBe('stdio')
expect(tolariaServer.command).toBe('node')
expect(tolariaServer.args[0]).toContain('mcp-server/index.js')
expect(tolariaServer.env.VAULT_PATH).toBeTruthy()
expect(tolariaServer.env.WS_UI_PORT).toBe('9711')
})
})

View File

@@ -0,0 +1,80 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() !== 'error') return
const text = message.text()
if (text.includes('ws://localhost:9711')) return
errors.push(text)
})
return errors
}
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function moveAcrossElement(page: Page, selector: string): Promise<void> {
const target = page.locator(selector).first()
await expect(target).toBeVisible({ timeout: 5_000 })
const box = await target.boundingBox()
expect(box).not.toBeNull()
if (!box) return
const points = [
{ x: box.x + 2, y: box.y + 2 },
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
{ x: box.x + Math.max(2, box.width - 2), y: box.y + Math.max(2, box.height - 2) },
]
for (const point of points) {
await page.mouse.move(point.x, point.y, { steps: 4 })
}
}
test.describe('table hover crash regression', () => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('moving through table wrappers, cells, and nearby text keeps the editor stable', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await seedBlockNoteTable(page, [180, 120, 120])
await expect(page.locator('div.tableWrapper')).toBeVisible({ timeout: 5_000 })
await moveAcrossElement(page, 'div.tableWrapper')
await page.locator('table th').first().hover()
await page.locator('table td').first().hover()
const trailingParagraph = page.locator('.bn-editor [data-content-type="paragraph"]').last()
await trailingParagraph.hover()
await trailingParagraph.click()
await page.keyboard.type('stable after table hover')
const editor = page.getByRole('textbox').last()
await expect(editor).toContainText('stable after table hover')
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
})

View File

@@ -14098,6 +14098,113 @@
]
}
]
},
{
"type": "frame",
"id": "mcp_config_copy_design",
"x": 0,
"y": 12580,
"name": "MCP Config Copy — Light",
"theme": {
"Mode": "Light"
},
"width": 1440,
"fill": "$--background",
"layout": "vertical",
"gap": 20,
"padding": 40,
"children": [
{
"type": "text",
"id": "mcp_config_copy_title",
"fill": "$--foreground",
"content": "MCP Config Copy",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700",
"letterSpacing": 0
},
{
"type": "text",
"id": "mcp_config_copy_description",
"fill": "$--muted-foreground",
"content": "AI panel exposes a compact Copy MCP config action; setup dialog shows the exact generated JSON with a keyboard-focusable code block and copy button.",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal",
"letterSpacing": 0
},
{
"type": "frame",
"id": "mcp_config_copy_ai_header",
"name": "AI panel header action",
"width": 520,
"height": 52,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "mcp_config_copy_ai_label",
"fill": "$--muted-foreground",
"content": "AI Agent · Claude Code",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "rectangle",
"id": "mcp_config_copy_icon_button",
"name": "Copy MCP config icon button",
"x": 440,
"y": 14,
"width": 24,
"height": 24,
"cornerRadius": 6,
"fill": "$--muted"
}
]
},
{
"type": "frame",
"id": "mcp_config_copy_manual_block",
"name": "Manual MCP config block",
"width": 520,
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "mcp_config_copy_manual_title",
"fill": "$--foreground",
"content": "Manual MCP config",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "rectangle",
"id": "mcp_config_copy_code_block",
"width": 520,
"height": 148,
"cornerRadius": 8,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
}
}
]
}
]
}
],
"themes": {