Compare commits
1 Commits
alpha-v202
...
site
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c118472674 |
21
.claude/commands/start.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# /start
|
||||
|
||||
Start Tolaria in Tauri dev mode.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Change to the Tolaria workspace:
|
||||
|
||||
```bash
|
||||
cd /Users/luca/Workspace/tolaria
|
||||
```
|
||||
|
||||
2. Start the native development app:
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
3. Keep the command running. Report the Vite local URL once it appears and leave the dev process alive for the user.
|
||||
|
||||
This is a local utility command, not a Laputa task. Do not run `/laputa-next-task`, CodeScene gates, Todoist updates, commits, or pushes for this command unless the user asks separately.
|
||||
3
.github/workflows/ci.yml
vendored
@@ -65,6 +65,9 @@ jobs:
|
||||
- name: Vite build check
|
||||
run: pnpm build
|
||||
|
||||
- name: Docs build check
|
||||
run: pnpm docs:build
|
||||
|
||||
# ── 1. Coverage-backed tests ──────────────────────────────────────────
|
||||
# The coverage commands run the same frontend and Rust test suites, so keep
|
||||
# them as the canonical test lane instead of running every suite twice.
|
||||
|
||||
24
.github/workflows/release-stable.yml
vendored
@@ -689,10 +689,10 @@ jobs:
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages
|
||||
# Phase 4: Update GitHub Pages with docs, release history, and download assets
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
name: Update docs and release pages
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -703,23 +703,39 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Build release history page
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs and release pages
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VITEPRESS_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm docs:build
|
||||
mkdir -p _site/alpha _site/stable
|
||||
cp -R site/.vitepress/dist/. _site/
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
|
||||
24
.github/workflows/release.yml
vendored
@@ -740,10 +740,10 @@ jobs:
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages with release history
|
||||
# Phase 4: Update GitHub Pages with docs, release history, and download assets
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
name: Update docs and release pages
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -754,23 +754,39 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Build release history page
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs and release pages
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VITEPRESS_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm docs:build
|
||||
mkdir -p _site/alpha _site/stable
|
||||
cp -R site/.vitepress/dist/. _site/
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -10,6 +10,9 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
site/.vitepress/cache/
|
||||
site/.vitepress/dist/
|
||||
_site/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -33,10 +33,12 @@ You can find some Loom walkthroughs below — they are short and to the point:
|
||||
|
||||
## Getting started
|
||||
|
||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/).
|
||||
|
||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||
|
||||
The public user docs live in [`site/`](site/) and are intended for GitHub Pages. Start with [Install Tolaria](site/start/install.md), then [First Launch](site/start/first-launch.md).
|
||||
|
||||
## Open source and local setup
|
||||
|
||||
Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇
|
||||
|
||||
@@ -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`. 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.
|
||||
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`.
|
||||
|
||||
### 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. 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`.
|
||||
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`.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -377,7 +377,6 @@ 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) |
|
||||
|
||||
@@ -728,7 +727,6 @@ 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/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
|
||||
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
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
|
||||
34
docs/PUBLIC-DOCS-PLAN.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Public Docs Plan
|
||||
|
||||
This document records the phase 1 information architecture for public Tolaria documentation. The public docs source lives in `site/`; the existing `docs/` directory remains contributor, architecture, and agent context.
|
||||
|
||||
## Audiences
|
||||
|
||||
| Audience | Needs | Primary location |
|
||||
|---|---|---|
|
||||
| New users | Install, first launch, understand the app layout, clone the starter vault | `site/start/` |
|
||||
| Active users | Learn concrete workflows such as organizing, Git sync, custom views, and AI | `site/guides/` |
|
||||
| Power users | Understand file layout, frontmatter, filters, shortcuts, and platform support | `site/reference/` |
|
||||
| Contributors and agents | Architecture, abstractions, ADRs, development workflow | `docs/`, `AGENTS.md` |
|
||||
|
||||
## Hosting Shape
|
||||
|
||||
The GitHub Pages output should reserve the root for public docs and mount release assets underneath it:
|
||||
|
||||
```text
|
||||
/ public docs home
|
||||
/releases/ release history
|
||||
/download/ latest stable download redirect
|
||||
/stable/latest.json
|
||||
/alpha/latest.json
|
||||
/latest.json compatibility alias for alpha latest
|
||||
/latest-canary.json compatibility alias for alpha latest
|
||||
```
|
||||
|
||||
Every user-visible app change should answer:
|
||||
|
||||
```text
|
||||
Public docs impact:
|
||||
- updated: <pages>
|
||||
- not needed because: <reason>
|
||||
```
|
||||
@@ -8,6 +8,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"docs:dev": "vitepress dev site --host 127.0.0.1",
|
||||
"docs:build": "vitepress build site",
|
||||
"docs:preview": "vitepress preview site --host 127.0.0.1",
|
||||
"lint": "eslint .",
|
||||
"l10n:translate": "lara-cli translate",
|
||||
"l10n:translate:force": "lara-cli translate --force",
|
||||
@@ -17,7 +20,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"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: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:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
@@ -99,6 +102,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
|
||||
1014
pnpm-lock.yaml
generated
101
site/.vitepress/config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
const base = process.env.VITEPRESS_BASE ?? "/";
|
||||
|
||||
export default defineConfig({
|
||||
title: "Tolaria",
|
||||
description:
|
||||
"Tolaria is a local-first Markdown knowledge base with native relationships, Git history, and AI workflows.",
|
||||
base,
|
||||
cleanUrls: true,
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/png", href: `${base}landing/favicon.png` }],
|
||||
["meta", { property: "og:title", content: "Tolaria" }],
|
||||
[
|
||||
"meta",
|
||||
{
|
||||
property: "og:description",
|
||||
content:
|
||||
"A second brain for the AI era. Free forever, local-first, Markdown-based, Git-ready, and AI-friendly.",
|
||||
},
|
||||
],
|
||||
],
|
||||
themeConfig: {
|
||||
logo: { src: "/landing/tolaria-icon.png", alt: "Tolaria" },
|
||||
nav: [
|
||||
{ text: "Features", link: "/#features" },
|
||||
{ text: "Start", link: "/start/install" },
|
||||
{ text: "Concepts", link: "/concepts/vaults" },
|
||||
{ text: "Guides", link: "/guides/capture-a-note" },
|
||||
{ text: "Reference", link: "/reference/supported-platforms" },
|
||||
{ text: "Releases", link: "/releases/" },
|
||||
],
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
socialLinks: [{ icon: "github", link: "https://github.com/refactoringhq/tolaria" }],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Start Here",
|
||||
items: [
|
||||
{ text: "Install Tolaria", link: "/start/install" },
|
||||
{ text: "First Launch", link: "/start/first-launch" },
|
||||
{ text: "Getting Started Vault", link: "/start/getting-started-vault" },
|
||||
{ text: "Open Or Create A Vault", link: "/start/open-or-create-vault" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Concepts",
|
||||
items: [
|
||||
{ text: "Vaults", link: "/concepts/vaults" },
|
||||
{ text: "Notes", link: "/concepts/notes" },
|
||||
{ text: "Properties", link: "/concepts/properties" },
|
||||
{ text: "Types", link: "/concepts/types" },
|
||||
{ text: "Relationships", link: "/concepts/relationships" },
|
||||
{ text: "Inbox", link: "/concepts/inbox" },
|
||||
{ text: "Git", link: "/concepts/git" },
|
||||
{ text: "AI", link: "/concepts/ai" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Guides",
|
||||
items: [
|
||||
{ text: "Capture A Note", link: "/guides/capture-a-note" },
|
||||
{ text: "Organize The Inbox", link: "/guides/organize-inbox" },
|
||||
{ text: "Use Wikilinks", link: "/guides/use-wikilinks" },
|
||||
{ text: "Create Types", link: "/guides/create-types" },
|
||||
{ text: "Build Custom Views", link: "/guides/build-custom-views" },
|
||||
{ text: "Connect A Git Remote", link: "/guides/connect-a-git-remote" },
|
||||
{ text: "Commit And Push", link: "/guides/commit-and-push" },
|
||||
{ text: "Use The AI Panel", link: "/guides/use-ai-panel" },
|
||||
{ text: "Use The Command Palette", link: "/guides/use-command-palette" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
{ text: "Supported Platforms", link: "/reference/supported-platforms" },
|
||||
{ text: "File Layout", link: "/reference/file-layout" },
|
||||
{ text: "Frontmatter Fields", link: "/reference/frontmatter-fields" },
|
||||
{ text: "View Filters", link: "/reference/view-filters" },
|
||||
{ text: "Keyboard Shortcuts", link: "/reference/keyboard-shortcuts" },
|
||||
{ text: "Docs Maintenance", link: "/reference/docs-maintenance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Troubleshooting",
|
||||
items: [
|
||||
{ text: "Vault Not Loading", link: "/troubleshooting/vault-not-loading" },
|
||||
{ text: "Git Authentication", link: "/troubleshooting/git-auth" },
|
||||
{ text: "AI Agent Not Found", link: "/troubleshooting/ai-agent-not-found" },
|
||||
{ text: "Sync Conflicts", link: "/troubleshooting/sync-conflicts" },
|
||||
],
|
||||
},
|
||||
],
|
||||
footer: {
|
||||
message: "Free and open source. Local-first, Git-first, and Markdown-based.",
|
||||
copyright:
|
||||
"Tolaria is AGPL-3.0-or-later. The Tolaria name and logo remain covered by the project trademark policy.",
|
||||
},
|
||||
},
|
||||
});
|
||||
1061
site/.vitepress/theme/LandingHome.vue
Normal file
28
site/.vitepress/theme/Layout.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
import { useData } from "vitepress";
|
||||
|
||||
const { frontmatter } = useData();
|
||||
|
||||
const scrollClass = "tolaria-scrolled";
|
||||
const updateScrollClass = () => {
|
||||
document.documentElement.classList.toggle(scrollClass, window.scrollY > 8);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateScrollClass();
|
||||
window.addEventListener("scroll", updateScrollClass, { passive: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("scroll", updateScrollClass);
|
||||
document.documentElement.classList.remove(scrollClass);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'tolaria-landing-shell': frontmatter.landing }">
|
||||
<DefaultTheme.Layout />
|
||||
</div>
|
||||
</template>
|
||||
BIN
site/.vitepress/theme/assets/RefactoringSans.otf
Normal file
12
site/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import LandingHome from "./LandingHome.vue";
|
||||
import Layout from "./Layout.vue";
|
||||
import "./styles.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ app }) {
|
||||
app.component("LandingHome", LandingHome);
|
||||
},
|
||||
};
|
||||
171
site/.vitepress/theme/styles.css
Normal file
@@ -0,0 +1,171 @@
|
||||
@font-face {
|
||||
font-family: "RefactoringSans";
|
||||
src: url("./assets/RefactoringSans.otf") format("opentype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 400 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-font-family-base:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--vp-font-family-mono: "SF Mono", "Fira Code", ui-monospace, monospace;
|
||||
--tolaria-font-brand:
|
||||
"RefactoringSans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--tolaria-bg: #faf9f5;
|
||||
--tolaria-surface: #ffffff;
|
||||
--tolaria-surface-muted: #f7f6f3;
|
||||
--tolaria-text: #1a1a18;
|
||||
--tolaria-text-secondary: #6b6b60;
|
||||
--tolaria-text-muted: #9b9b90;
|
||||
--tolaria-border: #e5e5e0;
|
||||
--tolaria-blue: #155dff;
|
||||
--tolaria-blue-hover: #4a5ad6;
|
||||
--tolaria-blue-soft: #e8eeff;
|
||||
--vp-c-bg: var(--tolaria-surface);
|
||||
--vp-c-bg-alt: var(--tolaria-surface-muted);
|
||||
--vp-c-bg-elv: var(--tolaria-surface);
|
||||
--vp-c-bg-soft: var(--tolaria-surface-muted);
|
||||
--vp-c-text-1: var(--tolaria-text);
|
||||
--vp-c-text-2: var(--tolaria-text-secondary);
|
||||
--vp-c-text-3: var(--tolaria-text-muted);
|
||||
--vp-c-border: var(--tolaria-border);
|
||||
--vp-c-divider: var(--tolaria-border);
|
||||
--vp-c-brand-1: var(--tolaria-blue);
|
||||
--vp-c-brand-2: var(--tolaria-blue-hover);
|
||||
--vp-c-brand-3: var(--tolaria-blue);
|
||||
--vp-c-brand-soft: var(--tolaria-blue-soft);
|
||||
--vp-button-brand-bg: var(--tolaria-blue);
|
||||
--vp-button-brand-hover-bg: var(--tolaria-blue-hover);
|
||||
--vp-button-brand-border: var(--tolaria-blue);
|
||||
--vp-code-bg: #eeeeea;
|
||||
--vp-code-color: var(--tolaria-text-secondary);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: #1f1e1b;
|
||||
--vp-c-bg-alt: #191814;
|
||||
--vp-c-bg-elv: #23221f;
|
||||
--vp-c-bg-soft: #23221f;
|
||||
--vp-c-text-1: #e6e1d8;
|
||||
--vp-c-text-2: #b8b1a6;
|
||||
--vp-c-text-3: #7f776d;
|
||||
--vp-c-border: #34322d;
|
||||
--vp-c-divider: #34322d;
|
||||
--vp-c-brand-1: #78a4ff;
|
||||
--vp-c-brand-2: #9bbeff;
|
||||
--vp-c-brand-3: #78a4ff;
|
||||
--vp-c-brand-soft: rgba(120, 164, 255, 0.16);
|
||||
--vp-code-bg: #2d2b27;
|
||||
--vp-code-color: #d8d1c6;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .logo {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title {
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--tolaria-font-brand);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell {
|
||||
--vp-c-bg: var(--tolaria-bg);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPNav,
|
||||
.tolaria-landing-shell .VPNavBar,
|
||||
.tolaria-landing-shell .VPNavBar .content-body {
|
||||
background: color-mix(in srgb, var(--tolaria-bg) 94%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPNavBar .divider,
|
||||
.tolaria-landing-shell .VPNavBar .divider-line {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPNavBar .divider-line {
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPLocalNav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tolaria-scrolled .tolaria-landing-shell .VPNav {
|
||||
box-shadow: 0 10px 24px rgba(26, 26, 24, 0.06);
|
||||
}
|
||||
|
||||
.tolaria-scrolled .tolaria-landing-shell .VPNavBar .divider-line {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.DocSearch-Button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3 {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc a,
|
||||
.VPNavBarMenuLink.active,
|
||||
.VPLink.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.vp-doc td,
|
||||
.vp-doc th {
|
||||
border-color: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPContent,
|
||||
.tolaria-landing-shell .VPPage,
|
||||
.tolaria-landing-shell .VPDoc,
|
||||
.tolaria-landing-shell .VPDoc .container,
|
||||
.tolaria-landing-shell .VPDoc .content,
|
||||
.tolaria-landing-shell .VPDoc .content-container,
|
||||
.tolaria-landing-shell .VPDoc .main,
|
||||
.tolaria-landing-shell .vp-doc {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPPage {
|
||||
padding-top: var(--vp-nav-height);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPDoc {
|
||||
padding-top: var(--vp-nav-height);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .vp-doc > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .vp-doc a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPFooter {
|
||||
display: none;
|
||||
}
|
||||
20
site/concepts/ai.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# AI
|
||||
|
||||
Tolaria is designed for local AI agents that can work with files and tools rather than a hidden cloud-only notes API.
|
||||
|
||||
## Agent Panel
|
||||
|
||||
The AI panel streams messages from supported local CLI agents. It shows reasoning, tool activity, and file changes in the app.
|
||||
|
||||
## MCP Server
|
||||
|
||||
Tolaria exposes an MCP server so tools such as Claude Code and Codex can search, read, and edit vault notes through explicit tools.
|
||||
|
||||
## Why Git Matters For AI
|
||||
|
||||
AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work.
|
||||
|
||||
## Current Direction
|
||||
|
||||
Claude Code is the primary integration. Codex and other CLI agents are supported through the shared agent architecture as the app evolves.
|
||||
|
||||
21
site/concepts/git.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Git
|
||||
|
||||
Git is Tolaria's history and sync layer. It keeps the model local-first while still supporting remote backup and multi-device workflows.
|
||||
|
||||
## What Tolaria Uses Git For
|
||||
|
||||
- Local commit history.
|
||||
- Diff views.
|
||||
- Per-note history.
|
||||
- Pull and push.
|
||||
- Conflict detection and resolution.
|
||||
- Remote connection for local-only vaults.
|
||||
|
||||
## Local Commits
|
||||
|
||||
You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured.
|
||||
|
||||
## Remotes
|
||||
|
||||
Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work.
|
||||
|
||||
22
site/concepts/inbox.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Inbox
|
||||
|
||||
The Inbox is for notes that have been captured but not yet organized.
|
||||
|
||||
## Why It Exists
|
||||
|
||||
Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later.
|
||||
|
||||
## Organizing Inbox Notes
|
||||
|
||||
When reviewing the Inbox:
|
||||
|
||||
1. Give the note a clear H1.
|
||||
2. Set its `type`.
|
||||
3. Add status, dates, or URL if useful.
|
||||
4. Add relationships with wikilinks or frontmatter fields.
|
||||
5. Move it into a folder only if the folder adds value.
|
||||
|
||||
## Healthy Inbox Habit
|
||||
|
||||
Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate.
|
||||
|
||||
31
site/concepts/notes.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Notes
|
||||
|
||||
A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```md
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
belongs_to:
|
||||
- "[[workspace]]"
|
||||
---
|
||||
|
||||
# Launch Documentation
|
||||
|
||||
Draft the public Tolaria docs and keep them close to code changes.
|
||||
```
|
||||
|
||||
## Titles
|
||||
|
||||
The first H1 is the main title. Older notes can still use a `title:` frontmatter fallback, but new notes should rely on the H1.
|
||||
|
||||
## Body Links
|
||||
|
||||
Use `[[wikilinks]]` to connect notes from the body. Tolaria can resolve links by filename, title, and aliases.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body.
|
||||
|
||||
25
site/concepts/properties.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Properties
|
||||
|
||||
Properties are frontmatter fields that Tolaria can display, filter, and edit.
|
||||
|
||||
## Common Properties
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `type` | Groups the note into a type such as Project, Person, or Topic. |
|
||||
| `status` | Tracks lifecycle state such as Active, Done, or Blocked. |
|
||||
| `url` | Stores a canonical external link. |
|
||||
| `date` | Represents a single date. |
|
||||
| `start_date`, `end_date` | Represents a date range. |
|
||||
| `aliases` | Gives a note alternative names for wikilink resolution. |
|
||||
|
||||
## System Properties
|
||||
|
||||
Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing.
|
||||
|
||||
Examples include `_icon`, `_color`, `_order`, and `_pinned_properties` on type documents.
|
||||
|
||||
## Property Editing
|
||||
|
||||
The Inspector is the safest place to edit structured properties. Use raw Markdown mode when you need direct control over YAML.
|
||||
|
||||
27
site/concepts/relationships.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Relationships
|
||||
|
||||
Relationships make a vault feel like a graph instead of a pile of documents.
|
||||
|
||||
## Relationship Fields
|
||||
|
||||
Any frontmatter field containing wikilinks can become a relationship.
|
||||
|
||||
```yaml
|
||||
belongs_to:
|
||||
- "[[product-work]]"
|
||||
related_to:
|
||||
- "[[documentation]]"
|
||||
blocked_by:
|
||||
- "[[release-process]]"
|
||||
```
|
||||
|
||||
Tolaria does not need a hardcoded list of relationship names. It detects relationship fields dynamically.
|
||||
|
||||
## Body Links Versus Relationship Fields
|
||||
|
||||
Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, or the Inspector.
|
||||
|
||||
## Backlinks
|
||||
|
||||
Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context.
|
||||
|
||||
38
site/concepts/types.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Types
|
||||
|
||||
Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create.
|
||||
|
||||
## Type Field
|
||||
|
||||
The `type:` field assigns a note to a type.
|
||||
|
||||
```yaml
|
||||
type: Project
|
||||
```
|
||||
|
||||
Tolaria does not infer type from folder location. Moving a file into another folder does not change its type.
|
||||
|
||||
## Type Documents
|
||||
|
||||
Type documents live in the `type/` folder and describe how a type should appear.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: folder
|
||||
color: blue
|
||||
sidebar_label: Projects
|
||||
sort: modified:desc
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
## What Types Control
|
||||
|
||||
- Sidebar grouping.
|
||||
- Type icon and color.
|
||||
- Default sort.
|
||||
- Pinned properties.
|
||||
- New-note templates.
|
||||
|
||||
29
site/concepts/vaults.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Vaults
|
||||
|
||||
A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Notes are Markdown files.
|
||||
- YAML frontmatter provides structure.
|
||||
- Attachments are normal files inside the vault.
|
||||
- Type definitions and saved views are also files.
|
||||
- Git tracks history and supports remote sync.
|
||||
|
||||
## Why Local Files Matter
|
||||
|
||||
Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git.
|
||||
|
||||
Tolaria should never become the only way to read your data.
|
||||
|
||||
## App State Versus Vault State
|
||||
|
||||
Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation.
|
||||
|
||||
| Vault state | App state |
|
||||
| --- | --- |
|
||||
| Type icons and colors | Editor zoom |
|
||||
| Saved views | Window size |
|
||||
| Pinned properties | Recent vault list |
|
||||
| Relationship conventions | Local cache |
|
||||
|
||||
17
site/download/index.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Download
|
||||
|
||||
Download Tolaria from the latest stable release.
|
||||
|
||||
## macOS
|
||||
|
||||
[Download the latest stable build](https://refactoringhq.github.io/tolaria/download/)
|
||||
|
||||
macOS is the primary supported platform.
|
||||
|
||||
## Linux And Windows
|
||||
|
||||
Linux and Windows builds are best effort for now. Check the [latest GitHub release](https://github.com/refactoringhq/tolaria/releases/latest) for available artifacts.
|
||||
|
||||
## Verify The Version
|
||||
|
||||
After launching the app, check the version shown in Tolaria's status bar or release surface.
|
||||
20
site/guides/build-custom-views.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Build Custom Views
|
||||
|
||||
Custom views are saved filters for recurring questions.
|
||||
|
||||
## Good View Candidates
|
||||
|
||||
- Active projects.
|
||||
- People without a recent follow-up.
|
||||
- Drafts ready for review.
|
||||
- Notes changed this week.
|
||||
- Events in a date range.
|
||||
|
||||
## View Definition
|
||||
|
||||
Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data.
|
||||
|
||||
## Design The Question First
|
||||
|
||||
Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens.
|
||||
|
||||
20
site/guides/capture-a-note.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Capture A Note
|
||||
|
||||
Use capture when you need to get an idea into the vault before you know where it belongs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open the command palette with `Cmd+K` or `Ctrl+K`.
|
||||
2. Run `New Note`.
|
||||
3. Write a clear H1.
|
||||
4. Add the rough content.
|
||||
5. Leave structure for later if you are still thinking.
|
||||
|
||||
## Capture Well
|
||||
|
||||
Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review.
|
||||
|
||||
## When To Add Structure Immediately
|
||||
|
||||
Add structure while capturing if the note is obviously a Project, Person, Event, or Procedure and the context is already known.
|
||||
|
||||
19
site/guides/commit-and-push.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Commit And Push
|
||||
|
||||
Tolaria lets you commit and push vault changes from inside the app.
|
||||
|
||||
## Commit
|
||||
|
||||
1. Open the Git or changes surface.
|
||||
2. Review changed files.
|
||||
3. Write a short commit message.
|
||||
4. Commit locally.
|
||||
|
||||
## Push
|
||||
|
||||
Push after committing when a remote is configured. If the remote has changed, pull first and resolve any conflicts.
|
||||
|
||||
## Use Small Commits
|
||||
|
||||
Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits.
|
||||
|
||||
23
site/guides/connect-a-git-remote.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Connect A Git Remote
|
||||
|
||||
Connect a remote when you want backup or sync beyond the current machine.
|
||||
|
||||
## Before You Start
|
||||
|
||||
Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette.
|
||||
2. Paste the remote URL.
|
||||
3. Confirm the remote name.
|
||||
4. Fetch or push according to the app prompt.
|
||||
|
||||
## Recommended Auth
|
||||
|
||||
- SSH keys.
|
||||
- GitHub CLI authentication.
|
||||
- Existing Git credential helpers.
|
||||
|
||||
If authentication fails, see [Git Authentication](/troubleshooting/git-auth).
|
||||
|
||||
27
site/guides/create-types.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Create Types
|
||||
|
||||
Create a type when several notes share the same role in your system.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create a note in the `type/` folder.
|
||||
2. Set `type: Type` in frontmatter.
|
||||
3. Give the document a clear H1.
|
||||
4. Add optional icon, color, sort, and sidebar label.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: briefcase
|
||||
color: blue
|
||||
sidebar_label: Projects
|
||||
sort: modified:desc
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
## Use Types Sparingly
|
||||
|
||||
A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead.
|
||||
|
||||
26
site/guides/organize-inbox.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Organize The Inbox
|
||||
|
||||
Inbox review turns quick captures into usable knowledge.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Rename unclear notes.
|
||||
- Add or correct the first H1.
|
||||
- Set `type`.
|
||||
- Add `status` for actionable notes.
|
||||
- Add `belongs_to`, `related_to`, or other relationship fields when useful.
|
||||
- Archive or delete notes that no longer matter.
|
||||
|
||||
## Make Notes Navigable
|
||||
|
||||
A note is organized when you can answer:
|
||||
|
||||
- What kind of thing is this?
|
||||
- What is it connected to?
|
||||
- What should happen next?
|
||||
- Where would I expect to find it later?
|
||||
|
||||
## Avoid Over-Structuring
|
||||
|
||||
Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation.
|
||||
|
||||
19
site/guides/use-ai-panel.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Use The AI Panel
|
||||
|
||||
The AI panel connects Tolaria to local CLI agents.
|
||||
|
||||
## Before Using It
|
||||
|
||||
Install a supported agent such as Claude Code and make sure it is available from your shell. Tolaria detects common install locations, but explicit shell availability is still the simplest setup.
|
||||
|
||||
## Good Requests
|
||||
|
||||
- "Find notes related to this project."
|
||||
- "Summarize what changed in this note."
|
||||
- "Draft a weekly review from these linked notes."
|
||||
- "Update this checklist based on the current project status."
|
||||
|
||||
## Review Changes
|
||||
|
||||
AI edits are file edits. Review them with Tolaria's diff and Git history before committing.
|
||||
|
||||
24
site/guides/use-command-palette.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Use The Command Palette
|
||||
|
||||
The command palette is the fastest way to move around Tolaria.
|
||||
|
||||
Open it with:
|
||||
|
||||
- `Cmd+K` on macOS.
|
||||
- `Ctrl+K` on Linux and Windows.
|
||||
|
||||
## Common Commands
|
||||
|
||||
- New Note.
|
||||
- Search.
|
||||
- Open Settings.
|
||||
- Reload Vault.
|
||||
- Add Remote.
|
||||
- Open Getting Started Vault.
|
||||
- Toggle Raw Mode.
|
||||
- Open in New Window.
|
||||
|
||||
## Keyboard-First Workflow
|
||||
|
||||
Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows.
|
||||
|
||||
25
site/guides/use-wikilinks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Use Wikilinks
|
||||
|
||||
Wikilinks connect notes by name.
|
||||
|
||||
```md
|
||||
This project belongs to [[content-systems]] and is related to [[git-workflows]].
|
||||
```
|
||||
|
||||
## Link From The Body
|
||||
|
||||
Use body links when the connection is part of the sentence you are writing.
|
||||
|
||||
## Link From Frontmatter
|
||||
|
||||
Use frontmatter links when the relationship should become structured metadata.
|
||||
|
||||
```yaml
|
||||
related_to:
|
||||
- "[[git-workflows]]"
|
||||
```
|
||||
|
||||
## Keep Links Stable
|
||||
|
||||
Prefer clear note titles and filenames. Use aliases when people or projects are known by multiple names.
|
||||
|
||||
10
site/index.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
layout: page
|
||||
sidebar: false
|
||||
aside: false
|
||||
landing: true
|
||||
title: Tolaria
|
||||
description: A second brain for the AI era. Free forever.
|
||||
---
|
||||
|
||||
<LandingHome />
|
||||
BIN
site/public/landing/Ai chat 3.png
Normal file
|
After Width: | Height: | Size: 726 KiB |
BIN
site/public/landing/Block editor.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
site/public/landing/Luca hello.jpeg
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
site/public/landing/ananth.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/public/landing/favicon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
site/public/landing/git-history.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
site/public/landing/gregor.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
site/public/landing/jordan.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
site/public/landing/les-saintes.jpg
Normal file
|
After Width: | Height: | Size: 785 KiB |
BIN
site/public/landing/pulse.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
site/public/landing/relationships.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
site/public/landing/simply-files.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
site/public/landing/tolaria-full-layout.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
site/public/landing/tolaria-icon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/public/landing/track changes and push.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
site/public/landing/wikilinks.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
site/public/landing/yaml frontmatter.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
10
site/public/tolaria-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_111_151)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_151">
|
||||
<rect width="266" height="363" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 889 B |
38
site/reference/docs-maintenance.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Docs Maintenance
|
||||
|
||||
The public docs live in the app repo so documentation changes can ship with behavior changes.
|
||||
|
||||
## Update Docs When You Change
|
||||
|
||||
- A Tauri command.
|
||||
- A new component or hook that changes user behavior.
|
||||
- A data model or frontmatter convention.
|
||||
- Git, AI, onboarding, or release behavior.
|
||||
- Platform support.
|
||||
- Keyboard shortcuts.
|
||||
|
||||
## Suggested Workflow
|
||||
|
||||
1. Make the code change.
|
||||
2. Update the matching concept, guide, or reference page.
|
||||
3. Add a troubleshooting page if the change creates a new failure mode.
|
||||
4. Run `pnpm docs:build`.
|
||||
5. Check the home page, search, and changed docs pages in a browser.
|
||||
|
||||
## Page Types
|
||||
|
||||
| Type | Purpose |
|
||||
| --- | --- |
|
||||
| Start | Helps a new user get into the app. |
|
||||
| Concepts | Explains mental models. |
|
||||
| Guides | Teaches workflows. |
|
||||
| Reference | Gives stable facts and tables. |
|
||||
| Troubleshooting | Starts from a symptom and ends with recovery. |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does the page describe current behavior?
|
||||
- Does it mention macOS primary and Linux/Windows best effort when platform support matters?
|
||||
- Are links relative and VitePress-compatible?
|
||||
- Can a user discover the page with local search?
|
||||
|
||||
35
site/reference/file-layout.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# File Layout
|
||||
|
||||
A typical vault stays simple.
|
||||
|
||||
```txt
|
||||
my-vault/
|
||||
project-alpha.md
|
||||
weekly-review.md
|
||||
people/
|
||||
ada-lovelace.md
|
||||
attachments/
|
||||
diagram.png
|
||||
type/
|
||||
project.md
|
||||
person.md
|
||||
views/
|
||||
active-projects.yml
|
||||
```
|
||||
|
||||
## Root Notes
|
||||
|
||||
Tolaria can work with flat vaults and nested folders. Type is not inferred from folder location; it comes from frontmatter.
|
||||
|
||||
## Special Folders
|
||||
|
||||
| Folder | Purpose |
|
||||
| --- | --- |
|
||||
| `type/` | Type definition documents. |
|
||||
| `views/` | Saved custom views. |
|
||||
| `attachments/` | Images and other attached files. |
|
||||
|
||||
## Git Files
|
||||
|
||||
If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes.
|
||||
|
||||
26
site/reference/frontmatter-fields.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Frontmatter Fields
|
||||
|
||||
Tolaria uses conventions instead of a required schema.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `type` | The note's entity type. |
|
||||
| `status` | Lifecycle state. |
|
||||
| `icon` | Per-note icon. |
|
||||
| `url` | External URL. |
|
||||
| `date` | Single date. |
|
||||
| `start_date` | Start of a date range. |
|
||||
| `end_date` | End of a date range. |
|
||||
| `aliases` | Alternative names for link resolution. |
|
||||
| `belongs_to` | Parent relationship. |
|
||||
| `related_to` | Lateral relationship. |
|
||||
| `has` | Contained relationship. |
|
||||
|
||||
## Custom Fields
|
||||
|
||||
You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship.
|
||||
|
||||
## System Fields
|
||||
|
||||
Fields starting with `_` are reserved for system behavior and hidden from standard property editing.
|
||||
|
||||
15
site/reference/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `Cmd+K` / `Ctrl+K` | Open command palette. |
|
||||
| `Cmd+N` / `Ctrl+N` | Create a new note. |
|
||||
| `Cmd+S` / `Ctrl+S` | Save current note. |
|
||||
| `Cmd+[` / `Alt+Left` | Navigate back when available. |
|
||||
| `Cmd+]` / `Alt+Right` | Navigate forward when available. |
|
||||
| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. |
|
||||
|
||||
Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations.
|
||||
|
||||
Use the command palette to discover the current command set.
|
||||
|
||||
24
site/reference/supported-platforms.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Supported Platforms
|
||||
|
||||
Tolaria is a desktop app built with Tauri.
|
||||
|
||||
| Platform | Current support | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon is first-class. |
|
||||
| Linux | Best effort | Depends on distribution WebKitGTK packaging and desktop integration behavior. |
|
||||
| Windows | Best effort | Builds exist, but platform-specific behavior may lag macOS. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
Primary support means the platform is part of normal development and release validation. Best-effort support means builds may be available, but bugs can take longer to diagnose and fix.
|
||||
|
||||
## Reporting Platform Bugs
|
||||
|
||||
Include:
|
||||
|
||||
- Tolaria version.
|
||||
- Operating system and version.
|
||||
- CPU architecture.
|
||||
- Whether the vault is local-only or connected to a remote.
|
||||
- Steps to reproduce.
|
||||
|
||||
26
site/reference/view-filters.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# View Filters
|
||||
|
||||
View filters define saved lists of notes.
|
||||
|
||||
## Common Filter Ideas
|
||||
|
||||
| Goal | Filter direction |
|
||||
| --- | --- |
|
||||
| Active projects | `type` is Project and `status` is Active |
|
||||
| Drafts | `type` is Article and `status` is Draft |
|
||||
| People follow-up | `type` is Person and date is before today |
|
||||
| Recent work | modified date is within a recent range |
|
||||
|
||||
## Sorting
|
||||
|
||||
Useful sorts include:
|
||||
|
||||
- Recently modified first.
|
||||
- Title ascending.
|
||||
- Status ascending.
|
||||
- A custom property ascending or descending.
|
||||
|
||||
## Keep Views Focused
|
||||
|
||||
A view should answer one recurring question. If it becomes too broad, split it into two views.
|
||||
|
||||
16
site/releases/index.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Releases
|
||||
|
||||
Tolaria releases are published on GitHub.
|
||||
|
||||
- [Latest release](https://github.com/refactoringhq/tolaria/releases/latest)
|
||||
- [All releases](https://github.com/refactoringhq/tolaria/releases)
|
||||
- [Download page](/download/)
|
||||
|
||||
## Release Channels
|
||||
|
||||
Stable builds are intended for normal use. Pre-release builds may contain newer features and rougher edges.
|
||||
|
||||
## Before Updating
|
||||
|
||||
Commit or push important vault changes before updating the app. Your notes are local files, but having a clean Git state makes recovery easier.
|
||||
|
||||
32
site/start/first-launch.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# First Launch
|
||||
|
||||
The first launch flow is designed to get you into a real vault quickly without hiding the local-first model.
|
||||
|
||||
## What You Choose
|
||||
|
||||
Tolaria asks whether you want to:
|
||||
|
||||
- Create or clone the Getting Started vault.
|
||||
- Open an existing local vault.
|
||||
- Create a new empty vault.
|
||||
|
||||
The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes.
|
||||
|
||||
## What Tolaria Creates
|
||||
|
||||
Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose.
|
||||
|
||||
| Data | Stored in |
|
||||
| --- | --- |
|
||||
| Notes and attachments | Your vault folder |
|
||||
| Type definitions and saved views | Your vault folder |
|
||||
| Window size, zoom, recent vaults | Local app settings |
|
||||
| Cache data | Rebuildable local cache |
|
||||
|
||||
## First Commands To Try
|
||||
|
||||
- `Cmd+K` / `Ctrl+K`: open the command palette.
|
||||
- `New Note`: create a note in the current vault.
|
||||
- `Open Getting Started Vault`: clone the public sample vault.
|
||||
- `Reload Vault`: rescan files after external edits.
|
||||
|
||||
24
site/start/getting-started-vault.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Getting Started Vault
|
||||
|
||||
The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started).
|
||||
|
||||
It exists to show Tolaria's conventions without requiring you to restructure your own notes first.
|
||||
|
||||
## What It Demonstrates
|
||||
|
||||
- Markdown notes with YAML frontmatter.
|
||||
- Types such as Project, Person, Topic, and Procedure.
|
||||
- Wikilinks in note bodies.
|
||||
- Relationship fields in frontmatter.
|
||||
- A local Git repository that can be connected to a remote later.
|
||||
|
||||
## Local-Only By Default
|
||||
|
||||
When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later.
|
||||
|
||||
To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette.
|
||||
|
||||
## When To Move On
|
||||
|
||||
After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start.
|
||||
|
||||
28
site/start/install.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Install Tolaria
|
||||
|
||||
Tolaria is developed and tested primarily on macOS. Linux and Windows builds are published on a best-effort basis while the app matures.
|
||||
|
||||
## Download
|
||||
|
||||
Use the latest stable release unless you are intentionally testing pre-release builds:
|
||||
|
||||
- [Download the latest stable build](https://refactoringhq.github.io/tolaria/download/)
|
||||
- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases)
|
||||
- [Read the release notes](/releases/)
|
||||
|
||||
## Platform Status
|
||||
|
||||
| Platform | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon is the first-class desktop target. |
|
||||
| Linux | Best effort | Builds exist, but desktop integration varies by distribution and WebKitGTK packaging. |
|
||||
| Windows | Best effort | Builds exist, but behavior may lag macOS while the app is still early. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
## After Installing
|
||||
|
||||
1. Open Tolaria.
|
||||
2. Choose the Getting Started vault if you want a guided sample.
|
||||
3. Or open an existing folder of Markdown files as a vault.
|
||||
4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows.
|
||||
25
site/start/open-or-create-vault.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Open Or Create A Vault
|
||||
|
||||
A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata.
|
||||
|
||||
## Open An Existing Folder
|
||||
|
||||
Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists.
|
||||
|
||||
Good starting points:
|
||||
|
||||
- A folder of plain Markdown files.
|
||||
- An Obsidian-style vault.
|
||||
- A Git repository containing notes.
|
||||
- A copy of the Getting Started vault.
|
||||
|
||||
## Create A New Vault
|
||||
|
||||
Choose a new empty folder if you want Tolaria conventions from the start. New notes are created as Markdown files, and optional type definitions live in the `type/` folder.
|
||||
|
||||
## Git Repository Requirement
|
||||
|
||||
Tolaria's history and sync features expect the vault to be a Git repository. If a vault is not already a repository, Tolaria can initialize one for you.
|
||||
|
||||
Use Git because it gives Tolaria reliable local history, diff views, recovery, and remote sync without a proprietary backend.
|
||||
|
||||
23
site/troubleshooting/ai-agent-not-found.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AI Agent Not Found
|
||||
|
||||
Tolaria can only launch local CLI agents that are installed and discoverable.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- The AI panel says no supported agent is available.
|
||||
- Claude Code or another agent works in one shell but not in Tolaria.
|
||||
|
||||
## Checks
|
||||
|
||||
Open a terminal and run the agent command directly. For Claude Code:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
```
|
||||
|
||||
If the command fails, install or repair the agent first.
|
||||
|
||||
## Path Issues
|
||||
|
||||
Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell.
|
||||
|
||||
26
site/troubleshooting/git-auth.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Git Authentication
|
||||
|
||||
Tolaria uses system Git authentication. It does not manage provider passwords directly.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Push fails.
|
||||
- Pull asks for credentials repeatedly.
|
||||
- Remote fetch works in one terminal but not in Tolaria.
|
||||
|
||||
## Checks
|
||||
|
||||
1. Open a terminal.
|
||||
2. `cd` into the vault.
|
||||
3. Run `git remote -v`.
|
||||
4. Run `git fetch`.
|
||||
|
||||
If `git fetch` fails in the terminal, fix system Git auth first.
|
||||
|
||||
## Common Fixes
|
||||
|
||||
- Sign in with GitHub CLI.
|
||||
- Configure SSH keys.
|
||||
- Update the remote URL.
|
||||
- Check your credential helper.
|
||||
|
||||
20
site/troubleshooting/sync-conflicts.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sync Conflicts
|
||||
|
||||
Sync conflicts happen when local and remote changes touch the same content.
|
||||
|
||||
## What To Do
|
||||
|
||||
1. Stop editing the conflicted note.
|
||||
2. Open the conflict resolver if Tolaria presents it.
|
||||
3. Review both sides.
|
||||
4. Choose the correct content or merge manually.
|
||||
5. Commit the resolved file.
|
||||
6. Push again.
|
||||
|
||||
## Prevent Conflicts
|
||||
|
||||
- Pull before starting work on another device.
|
||||
- Push after meaningful sessions.
|
||||
- Keep AI-generated edits in small commits.
|
||||
- Avoid editing the same note on multiple devices at the same time.
|
||||
|
||||
25
site/troubleshooting/vault-not-loading.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Vault Not Loading
|
||||
|
||||
Use this checklist when Tolaria cannot open or refresh a vault.
|
||||
|
||||
## Check The Folder
|
||||
|
||||
- Confirm the folder exists.
|
||||
- Confirm the folder contains readable files.
|
||||
- Confirm Tolaria has permission to access the folder.
|
||||
- Try opening a smaller test vault to isolate the issue.
|
||||
|
||||
## Check Git
|
||||
|
||||
If the vault is a Git repository, verify it is not in a broken state:
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Resolve interrupted merges or corrupted repository state before retrying.
|
||||
|
||||
## Reload
|
||||
|
||||
Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem.
|
||||
|
||||
@@ -131,15 +131,6 @@ 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(
|
||||
@@ -176,12 +167,6 @@ 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,7 +435,6 @@ 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,28 +313,6 @@ 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 {
|
||||
@@ -540,22 +518,6 @@ 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
@@ -453,16 +453,7 @@ 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,
|
||||
mcpConfigSnippet,
|
||||
mcpConfigLoading,
|
||||
mcpConfigError,
|
||||
loadMcpConfigSnippet,
|
||||
copyMcpConfig,
|
||||
} = useMcpStatus(resolvedPath, setToastMessage)
|
||||
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
||||
const loadVaultModifiedFiles = vault.loadModifiedFiles
|
||||
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
|
||||
@@ -502,14 +493,6 @@ 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(() => {
|
||||
@@ -1618,7 +1601,6 @@ function App() {
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
onCopyMcpConfig={handleCopyMcpConfig}
|
||||
vaultPath={resolvedPath}
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
@@ -1703,7 +1685,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} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
|
||||
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
|
||||
@@ -85,15 +85,6 @@ 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,7 +21,6 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
|
||||
|
||||
interface AiPanelProps {
|
||||
onClose: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
@@ -43,7 +42,6 @@ interface AiPanelProps {
|
||||
interface AiPanelViewProps {
|
||||
controller: AiPanelController
|
||||
onClose: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
@@ -60,7 +58,6 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
|
||||
export function AiPanelView({
|
||||
controller,
|
||||
onClose,
|
||||
onCopyMcpConfig,
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
@@ -116,7 +113,6 @@ export function AiPanelView({
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
onClose={onClose}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
{activeEntry && (
|
||||
@@ -148,7 +144,6 @@ export function AiPanelView({
|
||||
|
||||
export function AiPanel({
|
||||
onClose,
|
||||
onCopyMcpConfig,
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
@@ -188,7 +183,6 @@ export function AiPanel({
|
||||
<AiPanelView
|
||||
controller={controller}
|
||||
onClose={onClose}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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'
|
||||
@@ -14,7 +12,6 @@ interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
onClose: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
onNewChat: () => void
|
||||
}
|
||||
|
||||
@@ -125,7 +122,6 @@ export function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
onClose,
|
||||
onCopyMcpConfig,
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
return (
|
||||
@@ -144,39 +140,21 @@ export function AiPanelHeader({
|
||||
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{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"
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onNewChat}
|
||||
aria-label="New AI chat"
|
||||
title="New AI chat"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
aria-label="Close AI panel"
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ interface EditorProps {
|
||||
onInitializeProperties?: (path: string) => void
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
vaultPath?: string
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
@@ -339,7 +338,6 @@ function EditorLayout({
|
||||
showDiffToggle,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
onCopyMcpConfig,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onNavigateWikilink,
|
||||
@@ -402,7 +400,6 @@ function EditorLayout({
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
@@ -523,7 +520,6 @@ function EditorLayout({
|
||||
noteListFilter={noteListFilter}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
@@ -555,7 +551,6 @@ 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,
|
||||
@@ -616,7 +611,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
|
||||
@@ -25,7 +25,6 @@ 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>
|
||||
@@ -49,7 +48,6 @@ 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,
|
||||
@@ -89,7 +87,6 @@ export function EditorRightPanel({
|
||||
<AiPanelView
|
||||
controller={aiPanelController}
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
|
||||
@@ -2,20 +2,6 @@ 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(
|
||||
@@ -23,7 +9,6 @@ describe('McpSetupDialog', () => {
|
||||
open={true}
|
||||
status="not_installed"
|
||||
busyAction={null}
|
||||
manualConfigSnippet={MANUAL_CONFIG}
|
||||
onClose={vi.fn()}
|
||||
onConnect={vi.fn()}
|
||||
onDisconnect={vi.fn()}
|
||||
@@ -33,9 +18,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.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.getByText(/type: stdio/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
|
||||
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
|
||||
@@ -64,7 +49,6 @@ 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(
|
||||
@@ -74,37 +58,16 @@ 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,5 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Copy, ShieldCheck } from 'lucide-react'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,29 +14,6 @@ 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
|
||||
@@ -65,100 +41,19 @@ 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">
|
||||
@@ -183,27 +78,43 @@ export function McpSetupDialog({
|
||||
<div>~/.cursor/mcp.json</div>
|
||||
<div>~/.config/mcp/mcp.json</div>
|
||||
</div>
|
||||
<ManualMcpConfigSection
|
||||
error={manualConfigError}
|
||||
loading={manualConfigLoading}
|
||||
onCopy={onCopyManualConfig}
|
||||
snippet={manualConfigSnippet}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<McpSetupActions
|
||||
buttonsDisabled={buttonsDisabled}
|
||||
connectBusy={connectBusy}
|
||||
disconnectBusy={disconnectBusy}
|
||||
primaryLabel={copy.primaryLabel}
|
||||
secondaryLabel={copy.secondaryLabel}
|
||||
onClose={onClose}
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -6,20 +6,14 @@ 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>
|
||||
),
|
||||
DragHandleButton: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
|
||||
capturedDragHandleMenu = dragHandleMenu ?? null
|
||||
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>,
|
||||
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
|
||||
capturedDragHandleMenu = dragHandleMenu ?? null
|
||||
return <div data-testid="side-menu" />
|
||||
},
|
||||
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
useDictionary: () => ({
|
||||
@@ -38,10 +32,6 @@ 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,7 +1,5 @@
|
||||
import {
|
||||
AddBlockButton,
|
||||
DragHandleMenu,
|
||||
DragHandleButton,
|
||||
RemoveBlockItem,
|
||||
SideMenu,
|
||||
TableColumnHeaderItem,
|
||||
@@ -23,10 +21,5 @@ function TolariaDragHandleMenu() {
|
||||
}
|
||||
|
||||
export function TolariaSideMenu(props: SideMenuProps) {
|
||||
return (
|
||||
<SideMenu {...props}>
|
||||
<DragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
|
||||
<AddBlockButton />
|
||||
</SideMenu>
|
||||
)
|
||||
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
|
||||
}
|
||||
|
||||
@@ -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', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
|
||||
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
|
||||
enabled: true,
|
||||
execute: () => onInstallMcp?.(),
|
||||
},
|
||||
|
||||
@@ -24,14 +24,6 @@ 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>> = {},
|
||||
@@ -84,7 +76,6 @@ async function runMutationScenario({
|
||||
describe('useMcpStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockClipboard()
|
||||
})
|
||||
|
||||
it('checks the active vault status without auto-registering on mount', async () => {
|
||||
@@ -169,47 +160,4 @@ 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,136 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } 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
|
||||
|
||||
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> {
|
||||
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus {
|
||||
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
|
||||
return value === 'installed' ? 'installed' : 'not_installed'
|
||||
}
|
||||
|
||||
async function fetchMcpStatus(vaultPath: VaultPath): Promise<McpStatus> {
|
||||
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
|
||||
try {
|
||||
const result = await tauriCall<McpStatusResponse>('check_mcp_status', { vaultPath })
|
||||
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
|
||||
return normalizeMcpStatus(result)
|
||||
} catch {
|
||||
return 'not_installed'
|
||||
}
|
||||
}
|
||||
|
||||
function connectSuccessToast(result: McpCommandResult): ToastMessage {
|
||||
function connectSuccessToast(result: string): string {
|
||||
return result === 'registered'
|
||||
? 'Tolaria external AI tools connected successfully'
|
||||
: 'Tolaria external AI tools setup refreshed successfully'
|
||||
}
|
||||
|
||||
function disconnectSuccessToast(result: McpCommandResult): ToastMessage {
|
||||
function disconnectSuccessToast(result: string): string {
|
||||
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: VaultPath,
|
||||
onToast: ToastHandler,
|
||||
vaultPath: string,
|
||||
onToast: (msg: string) => void,
|
||||
) {
|
||||
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)
|
||||
@@ -178,11 +91,5 @@ export function useMcpStatus(
|
||||
}
|
||||
}, [refreshMcpStatus])
|
||||
|
||||
return {
|
||||
mcpStatus: status,
|
||||
refreshMcpStatus,
|
||||
connectMcp,
|
||||
disconnectMcp,
|
||||
...manualConfigActions,
|
||||
}
|
||||
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
|
||||
}
|
||||
|
||||
@@ -74,17 +74,11 @@ describe('useNoteActions hook', () => {
|
||||
return renderHook(() => useNoteActions(makeConfig(entries)))
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
async function createImmediateEntry(type?: string) {
|
||||
function createImmediateEntry(type?: string) {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderActions()
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate(type)
|
||||
await flushAsyncWork()
|
||||
})
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
vi.restoreAllMocks()
|
||||
@@ -200,29 +194,25 @@ describe('useNoteActions hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate creates note with timestamp-based title', async () => {
|
||||
const createdEntry = await createImmediateEntry()
|
||||
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
|
||||
const createdEntry = 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', async () => {
|
||||
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
|
||||
vi.useFakeTimers()
|
||||
let ts = 1700000000000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
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)
|
||||
@@ -234,8 +224,8 @@ describe('useNoteActions hook', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate accepts custom type', async () => {
|
||||
const createdEntry = await createImmediateEntry('Project')
|
||||
it('handleCreateNoteImmediate accepts custom type', () => {
|
||||
const createdEntry = createImmediateEntry('Project')
|
||||
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
|
||||
expect(createdEntry.isA).toBe('Project')
|
||||
})
|
||||
@@ -265,29 +255,40 @@ describe('useNoteActions hook', () => {
|
||||
expect(tabContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
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) => {
|
||||
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
await act(async () => {
|
||||
expect(() => { result.current.handleCreateNoteImmediate(typeName) }).not.toThrow()
|
||||
await flushAsyncWork()
|
||||
})
|
||||
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()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.path).not.toContain('//')
|
||||
assertEntry(entry)
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', async () => {
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
await flushAsyncWork()
|
||||
})
|
||||
|
||||
const tabContent = result.current.tabs[0].content
|
||||
@@ -306,15 +307,7 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
describe('pending save lifecycle', () => {
|
||||
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,
|
||||
) => {
|
||||
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
|
||||
const addPendingSave = vi.fn()
|
||||
const removePendingSave = vi.fn()
|
||||
const config = makeConfig()
|
||||
@@ -324,12 +317,28 @@ describe('useNoteActions hook', () => {
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateNote(title, 'Note')
|
||||
await flushAsyncWork()
|
||||
result.current.handleCreateNote('Pending Test', 'Note')
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
const callback = callbackName === 'addPendingSave' ? addPendingSave : removePendingSave
|
||||
expect(callback).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
|
||||
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'))
|
||||
})
|
||||
|
||||
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
|
||||
@@ -354,34 +363,22 @@ describe('useNoteActions hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
|
||||
})
|
||||
|
||||
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()
|
||||
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
|
||||
const trackUnsaved = vi.fn()
|
||||
const markContentPending = vi.fn()
|
||||
const config = makeConfig()
|
||||
config.addPendingSave = addPendingSave
|
||||
config.removePendingSave = removePendingSave
|
||||
config.onNewNotePersisted = onNewNotePersisted
|
||||
config.trackUnsaved = trackUnsaved
|
||||
config.markContentPending = markContentPending
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
await flushAsyncWork()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
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$/)
|
||||
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
|
||||
expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note'))
|
||||
})
|
||||
|
||||
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
|
||||
@@ -457,24 +454,20 @@ describe('useNoteActions hook', () => {
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate writes each rapid note before opening it', async () => {
|
||||
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(invoke).mockResolvedValue(undefined)
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
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)
|
||||
expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'create_note_content')).toHaveLength(3)
|
||||
// No disk writes for immediate creation — notes are unsaved/in-memory
|
||||
expect(invoke).not.toHaveBeenCalled()
|
||||
expect(removeEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -211,10 +211,6 @@ describe('useNoteCreation hook', () => {
|
||||
})
|
||||
|
||||
const tabDeps = { openTabWithContent }
|
||||
const flushImmediateCreate = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -234,13 +230,10 @@ describe('useNoteCreation hook', () => {
|
||||
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntitle: Test Note\ntype: Note\n---\n')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate generates timestamp-based title', async () => {
|
||||
it('handleCreateNoteImmediate generates timestamp-based title', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
await act(async () => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
await flushImmediateCreate()
|
||||
})
|
||||
act(() => { result.current.handleCreateNoteImmediate() })
|
||||
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')
|
||||
@@ -249,21 +242,17 @@ describe('useNoteCreation hook', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
|
||||
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
|
||||
vi.useFakeTimers()
|
||||
let ts = 1700000000000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
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)
|
||||
@@ -273,20 +262,16 @@ describe('useNoteCreation hook', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', async () => {
|
||||
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
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([
|
||||
@@ -297,117 +282,66 @@ describe('useNoteCreation hook', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('serializes rapid immediate-create bursts after the first note', async () => {
|
||||
it('serializes rapid immediate-create bursts after the first note', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
result.current.handleCreateNoteImmediate()
|
||||
result.current.handleCreateNoteImmediate()
|
||||
await flushImmediateCreate()
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
|
||||
await flushImmediateCreate()
|
||||
})
|
||||
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
|
||||
expect(addEntry).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
|
||||
await flushImmediateCreate()
|
||||
})
|
||||
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
|
||||
expect(addEntry).toHaveBeenCalledTimes(3)
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate accepts custom type', async () => {
|
||||
it('handleCreateNoteImmediate accepts custom type', () => {
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
await act(async () => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
await flushImmediateCreate()
|
||||
})
|
||||
act(() => { result.current.handleCreateNoteImmediate('Project') })
|
||||
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', async () => {
|
||||
it('handleCreateNoteImmediate slugifies custom type names for filenames', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
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 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,
|
||||
}
|
||||
it('handleCreateNoteImmediate tracks unsaved state', async () => {
|
||||
const trackUnsaved = vi.fn()
|
||||
const markContentPending = vi.fn()
|
||||
const config = makeConfig()
|
||||
config.trackUnsaved = trackUnsaved
|
||||
config.markContentPending = markContentPending
|
||||
const { result } = renderHook(() => useNoteCreation(config, tabDeps))
|
||||
|
||||
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],
|
||||
)
|
||||
await act(async () => { result.current.handleCreateNoteImmediate() })
|
||||
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
|
||||
expect(markContentPending).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
|
||||
const focusListener = vi.fn()
|
||||
window.addEventListener('laputa:focus-editor', focusListener)
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
await flushImmediateCreate()
|
||||
})
|
||||
act(() => { result.current.handleCreateNoteImmediate() })
|
||||
|
||||
expect(focusListener).toHaveBeenCalledTimes(1)
|
||||
const event = focusListener.mock.calls[0][0] as CustomEvent
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { useCallback, useEffect, useRef } 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
|
||||
@@ -371,15 +370,13 @@ 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
|
||||
onNewNotePersisted?: () => void
|
||||
removePendingSave?: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
trackUnsaved?: (path: string) => void
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
interface ImmediateCreateRequest {
|
||||
@@ -387,14 +384,12 @@ interface ImmediateCreateRequest {
|
||||
}
|
||||
|
||||
interface ImmediateCreateQueueConfig {
|
||||
addPendingSave?: (path: string) => void
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
openTabWithContent: (entry: VaultEntry, content: string) => void
|
||||
onNewNotePersisted?: () => void
|
||||
removePendingSave?: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
trackUnsaved?: (path: string) => void
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/** Generate a unique untitled filename using a timestamp. */
|
||||
@@ -415,26 +410,8 @@ function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSl
|
||||
return candidate
|
||||
}
|
||||
|
||||
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> {
|
||||
/** Create an untitled note without persisting to disk (deferred save). */
|
||||
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
|
||||
const noteType = type || 'Note'
|
||||
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
|
||||
const title = slug_to_title(slug)
|
||||
@@ -442,68 +419,11 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
|
||||
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 {
|
||||
@@ -511,13 +431,40 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
|
||||
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
|
||||
const immediateCreateLockedRef = useRef(false)
|
||||
const immediateCreateTimerRef = useRef<number | null>(null)
|
||||
const { latestDepsRef, syncDeps } = useLatestImmediateCreateDeps(config, pendingSlugsRef)
|
||||
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 executeRequest = useCallback((request: ImmediateCreateRequest) => {
|
||||
const deps = latestDepsRef.current
|
||||
if (!deps) return
|
||||
void createNoteImmediate(deps, request.type).then((didCreate) => trackImmediateCreate(request, didCreate))
|
||||
}, [latestDepsRef])
|
||||
createNoteImmediate(deps, request.type)
|
||||
trackEvent('note_created', {
|
||||
has_type: request.type ? 1 : 0,
|
||||
creation_path: request.type ? 'type_section' : 'cmd_n',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
|
||||
if (immediateCreateTimerRef.current !== null) return
|
||||
@@ -630,11 +577,9 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries,
|
||||
vaultPath,
|
||||
addEntry,
|
||||
addPendingSave,
|
||||
openTabWithContent,
|
||||
onNewNotePersisted,
|
||||
removePendingSave,
|
||||
setToastMessage,
|
||||
trackUnsaved: config.trackUnsaved,
|
||||
markContentPending: config.markContentPending,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -486,19 +486,6 @@ 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,19 +252,6 @@ 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.getByTitle('New AI chat').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTitle('New AI chat')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByTestId('ai-message')).toHaveCount(0)
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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,11 +19,10 @@ 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 visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
|
||||
async function visibleDragHandle(page: Page, block: Locator): Promise<Locator> {
|
||||
await block.hover()
|
||||
const handle = page.locator('.bn-side-menu button').first()
|
||||
const handle = page.locator('.bn-side-menu [draggable="true"]').first()
|
||||
await expect(handle).toBeVisible({ timeout: 5_000 })
|
||||
await expect(handle).toHaveAttribute('draggable', 'true')
|
||||
return handle
|
||||
}
|
||||
|
||||
@@ -60,7 +59,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 visibleLeftBlockHandle(page, notesHeading)
|
||||
const handle = await visibleDragHandle(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/)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
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
@@ -14098,113 +14098,6 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
|
||||