Compare commits
3 Commits
site
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
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