Compare commits
5 Commits
site
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e66fb4c19 | ||
|
|
a2843a0cf3 | ||
|
|
38fb8a7370 | ||
|
|
be5a8bb300 | ||
|
|
ce040c75fd |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: <Tolaria resources>/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'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>
|
||||
)
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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?.(),
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
114
tests/smoke/create-note-backing-file.spec.ts
Normal file
114
tests/smoke/create-note-backing-file.spec.ts
Normal 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')
|
||||
})
|
||||
@@ -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/)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
47
tests/smoke/mcp-config-copy.spec.ts
Normal file
47
tests/smoke/mcp-config-copy.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
80
tests/smoke/table-hover-crash.spec.ts
Normal file
80
tests/smoke/table-hover-crash.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
107
ui-design.pen
107
ui-design.pen
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user