Compare commits

..

9 Commits

Author SHA1 Message Date
lucaronin
c118472674 docs: add public site 2026-04-28 23:04:22 +02:00
lucaronin
ced89a42bd fix: render mermaid diagrams with lightbox 2026-04-28 10:32:37 +02:00
lucaronin
20a960551f fix: update fallback vault git email 2026-04-28 10:08:18 +02:00
lucaronin
2e331315e8 fix: refine Italian locale labels 2026-04-28 09:43:44 +02:00
lucaronin
d2c15b8469 feat: hide gitignored vault content 2026-04-28 09:07:21 +02:00
lucaronin
2fe6c5b580 fix: respect selected ai agent in right panel 2026-04-28 07:36:22 +02:00
lucaronin
2a224f78f8 refactor: simplify note mutation flushing 2026-04-28 06:25:39 +02:00
lucaronin
b85d54bb5d fix: preserve ai agent final result text 2026-04-28 06:17:33 +02:00
lucaronin
9c05e17d37 fix: sanitize custom view filenames 2026-04-28 05:39:41 +02:00
115 changed files with 5069 additions and 218 deletions

21
.claude/commands/start.md Normal file
View 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View File

@@ -10,6 +10,9 @@ lerna-debug.log*
node_modules
dist
dist-ssr
site/.vitepress/cache/
site/.vitepress/dist/
_site/
*.local
# Editor directories and files

View File

@@ -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 👇

View File

@@ -323,20 +323,21 @@ type SidebarSelection =
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive)
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`:
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
3. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()`
- Parses frontmatter with `gray_matter::Matter::<YAML>`
- Extracts title from first `#` heading
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
@@ -634,7 +635,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
### Search
Keyword-based search scans all vault `.md` files using `walkdir`:
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
```typescript
interface SearchResult {
@@ -726,10 +727,11 @@ interface Settings {
theme_mode: 'light' | 'dark' | null
ui_language: AppLocale | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | null
hide_gitignored_files: boolean | null // null = default true
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Pinned properties per type | API keys (OpenAI, Google) |
| Sidebar label overrides | Auto-sync interval |
| Property display order | Window size / position |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
@@ -82,6 +83,7 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
#### External Change Detection
@@ -468,7 +470,7 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.app>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, and Pi are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
@@ -618,6 +620,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
@@ -632,7 +635,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
@@ -649,7 +652,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
@@ -661,7 +664,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
| `batch_archive_notes` | Archive multiple notes |
| `batch_delete_notes` | Permanently delete notes from disk |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
| `check_vault_exists` | Check if vault path exists |
@@ -788,7 +791,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |

34
docs/PUBLIC-DOCS-PLAN.md Normal file
View 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>
```

View File

@@ -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",
@@ -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

File diff suppressed because it is too large Load Diff

101
site/.vitepress/config.ts Normal file
View 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.",
},
},
});

File diff suppressed because it is too large Load Diff

View 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>

Binary file not shown.

View 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);
},
};

View 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
View 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
View 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
View 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
View 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.

View 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.

View 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
View 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
View 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
View 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.

View 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.

View 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.

View 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.

View 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).

View 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.

View 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.

View 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.

View 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.

View 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
View 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 />

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View 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

View 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?

View 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.

View 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.

View 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.

View 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.

View 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
View 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.

View 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.

View 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
View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -489,6 +489,9 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => {
Some(AiAgentStreamEvent::TextDelta { text })
}
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
@@ -691,4 +694,17 @@ mod tests {
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
#[test]
fn map_claude_result_event_preserves_final_text() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
text: "Final answer from Claude".into(),
session_id: "session-1".into(),
});
assert!(matches!(
mapped,
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
));
}
}

View File

@@ -165,6 +165,24 @@ fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), St
Ok(())
}
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
}
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
let folders = vault::scan_vault_folders(vault_path)?;
Ok(vault::filter_gitignored_folders(
vault_path,
folders,
crate::settings::hide_gitignored_files_enabled(),
))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
@@ -207,12 +225,12 @@ pub fn copy_image_to_vault(
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
}
#[cfg(test)]

View File

@@ -83,8 +83,14 @@ pub async fn reload_vault(
let path = expand_tilde(&path).into_owned();
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(Path::new(&path));
vault::scan_vault_cached(Path::new(&path))
let vault_path = Path::new(&path);
vault::invalidate_cache(vault_path);
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?

View File

@@ -130,10 +130,7 @@ fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
/// Set local user.name and user.email if not already configured.
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
let local = git_command()
.args(["config", "--local", key])
.current_dir(dir)

View File

@@ -1,5 +1,5 @@
use serde::Serialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;
@@ -20,6 +20,14 @@ pub struct SearchResponse {
pub mode: String,
}
pub struct SearchOptions<'a> {
pub vault_path: &'a str,
pub query: &'a str,
pub mode: &'a str,
pub limit: usize,
pub hide_gitignored_files: bool,
}
fn extract_snippet(content: &str, query_lower: &str) -> String {
let content_lower = content.to_lowercase();
let pos = match content_lower.find(query_lower) {
@@ -63,26 +71,45 @@ pub fn search_vault(
_mode: &str,
limit: usize,
) -> Result<SearchResponse, String> {
search_vault_with_options(SearchOptions {
vault_path,
query,
mode: _mode,
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
})
}
fn is_markdown_search_candidate(path: &Path) -> bool {
if !path.extension().is_some_and(|ext| ext == "md") {
return false;
}
!path
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
}
fn collect_markdown_paths(vault_dir: &Path, hide_gitignored_files: bool) -> Vec<PathBuf> {
let paths = WalkDir::new(vault_dir)
.into_iter()
.filter_map(|entry| entry.ok())
.map(|entry| entry.into_path())
.filter(|path| is_markdown_search_candidate(path))
.collect::<Vec<_>>();
crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files)
}
pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchResponse, String> {
let start = Instant::now();
let query_lower = query.to_lowercase();
let vault_dir = Path::new(vault_path);
let query_lower = options.query.to_lowercase();
let vault_dir = Path::new(options.vault_path);
let mut results: Vec<SearchResult> = Vec::new();
for entry in WalkDir::new(vault_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.extension().is_some_and(|ext| ext == "md") {
continue;
}
// Skip hidden dirs and .laputa config
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
continue;
}
let content = match std::fs::read_to_string(path) {
for path in collect_markdown_paths(vault_dir, options.hide_gitignored_files) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
@@ -117,15 +144,15 @@ pub fn search_vault(
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit);
results.truncate(options.limit);
let elapsed_ms = start.elapsed().as_millis() as u64;
Ok(SearchResponse {
results,
elapsed_ms,
query: query.to_string(),
mode: "keyword".to_string(),
query: options.query.to_string(),
mode: options.mode.to_string(),
})
}
@@ -135,6 +162,14 @@ mod tests {
use std::fs;
use tempfile::Builder;
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
#[test]
fn test_extract_snippet_basic() {
let content = "line one\nline with keyword here\nline three";
@@ -188,4 +223,38 @@ mod tests {
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Updated Display Title");
}
#[test]
fn test_search_vault_hides_gitignored_notes_when_enabled() {
let dir = Builder::new()
.prefix("search-gitignored-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
init_git_repo(dir.path());
fs::create_dir_all(dir.path().join("ignored")).unwrap();
fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
fs::write(dir.path().join("visible.md"), "# Visible\n\nneedle").unwrap();
fs::write(dir.path().join("ignored/hidden.md"), "# Hidden\n\nneedle").unwrap();
let hidden = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "needle",
mode: "keyword",
limit: 10,
hide_gitignored_files: true,
})
.unwrap();
let shown = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "needle",
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
})
.unwrap();
assert_eq!(hidden.results.len(), 1);
assert_eq!(hidden.results[0].title, "Visible");
assert_eq!(shown.results.len(), 2);
}
}

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
@@ -22,6 +23,7 @@ pub struct Settings {
pub ui_language: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub hide_gitignored_files: Option<bool>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
@@ -63,6 +65,18 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
}
}
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
settings
.hide_gitignored_files
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
}
pub fn hide_gitignored_files_enabled() -> bool {
get_settings()
.map(|settings| should_hide_gitignored_files(&settings))
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
}
fn canonical_language_code(value: &str) -> Option<String> {
let code = value.trim().replace('_', "-").to_ascii_lowercase();
if code.is_empty() {
@@ -111,6 +125,7 @@ fn normalize_settings(settings: Settings) -> Settings {
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
hide_gitignored_files: settings.hide_gitignored_files,
}
}
@@ -253,6 +268,7 @@ mod tests {
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
@@ -280,6 +296,7 @@ mod tests {
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
..Default::default()
});
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
@@ -292,6 +309,20 @@ mod tests {
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.hide_gitignored_files, Some(false));
}
#[test]
fn test_gitignored_files_are_hidden_by_default() {
assert!(should_hide_gitignored_files(&Settings::default()));
assert!(should_hide_gitignored_files(&Settings {
hide_gitignored_files: Some(true),
..Default::default()
}));
assert!(!should_hide_gitignored_files(&Settings {
hide_gitignored_files: Some(false),
..Default::default()
}));
}
#[test]

View File

@@ -0,0 +1,300 @@
use super::{FolderNode, VaultEntry};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use walkdir::{DirEntry, WalkDir};
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(vault_path).ok()?;
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
(!normalized.is_empty()).then_some(normalized)
}
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
}
fn has_gitignore_file(vault_path: &Path) -> bool {
WalkDir::new(vault_path)
.follow_links(false)
.into_iter()
.filter_entry(should_descend_for_gitignore)
.filter_map(Result::ok)
.any(|entry| {
entry.file_type().is_file() && entry.file_name().to_string_lossy() == ".gitignore"
})
}
fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<String> {
let mut child = crate::hidden_command("git")
.args(["check-ignore", "--no-index", "--stdin"])
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
for path in relative_paths {
writeln!(stdin, "{path}").ok()?;
}
}
let output = child.wait_with_output().ok()?;
if output.status.success() || output.status.code() == Some(1) {
return Some(String::from_utf8_lossy(&output.stdout).to_string());
}
None
}
fn ignored_relative_paths(vault_path: &Path, relative_paths: &[String]) -> HashSet<String> {
if relative_paths.is_empty() || !has_gitignore_file(vault_path) {
return HashSet::new();
}
let mut candidates = relative_paths
.iter()
.map(|path| normalize_relative_path(path))
.filter(|path| !path.is_empty())
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
run_git_check_ignore(vault_path, &candidates)
.unwrap_or_default()
.lines()
.map(normalize_relative_path)
.filter(|path| !path.is_empty())
.collect()
}
fn filter_gitignored_items<T>(
vault_path: &Path,
items: Vec<T>,
hide_enabled: bool,
relative_for: impl Fn(&T) -> Option<String>,
) -> Vec<T> {
if !hide_enabled || items.is_empty() {
return items;
}
let relative_paths = items.iter().filter_map(&relative_for).collect::<Vec<_>>();
let ignored = ignored_relative_paths(vault_path, &relative_paths);
if ignored.is_empty() {
return items;
}
items
.into_iter()
.filter(|item| {
relative_for(item)
.map(|relative| !ignored.contains(&relative))
.unwrap_or(true)
})
.collect()
}
pub fn filter_gitignored_paths(
vault_path: &Path,
paths: Vec<PathBuf>,
hide_enabled: bool,
) -> Vec<PathBuf> {
filter_gitignored_items(vault_path, paths, hide_enabled, |path| {
relative_path(vault_path, path)
})
}
pub fn filter_gitignored_entries(
vault_path: &Path,
entries: Vec<VaultEntry>,
hide_enabled: bool,
) -> Vec<VaultEntry> {
filter_gitignored_items(vault_path, entries, hide_enabled, |entry| {
relative_path(vault_path, Path::new(&entry.path))
})
}
fn collect_folder_queries(nodes: &[FolderNode], queries: &mut Vec<String>) {
for node in nodes {
let relative = normalize_relative_path(&node.path);
if !relative.is_empty() {
queries.push(relative.clone());
queries.push(format!("{relative}/"));
}
collect_folder_queries(&node.children, queries);
}
}
fn path_or_parent_is_ignored(relative_path: &str, ignored: &HashSet<String>) -> bool {
if ignored.contains(relative_path) {
return true;
}
let mut current = relative_path;
while let Some((parent, _)) = current.rsplit_once('/') {
if ignored.contains(parent) {
return true;
}
current = parent;
}
false
}
fn filter_folder_nodes(nodes: Vec<FolderNode>, ignored: &HashSet<String>) -> Vec<FolderNode> {
nodes
.into_iter()
.filter_map(|mut node| {
let relative = normalize_relative_path(&node.path);
if path_or_parent_is_ignored(&relative, ignored) {
return None;
}
node.children = filter_folder_nodes(node.children, ignored);
Some(node)
})
.collect()
}
pub fn filter_gitignored_folders(
vault_path: &Path,
folders: Vec<FolderNode>,
hide_enabled: bool,
) -> Vec<FolderNode> {
if !hide_enabled || folders.is_empty() {
return folders;
}
let mut queries = Vec::new();
collect_folder_queries(&folders, &mut queries);
let ignored = ignored_relative_paths(vault_path, &queries);
if ignored.is_empty() {
return folders;
}
filter_folder_nodes(folders, &ignored)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(root: &Path, relative: &str, content: &str) {
let path = root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
fn entry(root: &Path, relative: &str) -> VaultEntry {
VaultEntry {
path: root.join(relative).to_string_lossy().to_string(),
filename: relative.rsplit('/').next().unwrap().to_string(),
title: relative.to_string(),
..VaultEntry::default()
}
}
fn entry_paths(root: &Path, entries: &[VaultEntry]) -> Vec<String> {
entries
.iter()
.map(|entry| relative_path(root, Path::new(&entry.path)).unwrap())
.collect()
}
#[test]
fn filters_ignored_entries_with_git_style_negation() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/*\n!ignored/keep.md\n");
write_file(dir.path(), "visible.md", "# Visible\n");
write_file(dir.path(), "ignored/hidden.md", "# Hidden\n");
write_file(dir.path(), "ignored/keep.md", "# Keep\n");
let filtered = filter_gitignored_entries(
dir.path(),
vec![
entry(dir.path(), "visible.md"),
entry(dir.path(), "ignored/hidden.md"),
entry(dir.path(), "ignored/keep.md"),
],
true,
);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["visible.md", "ignored/keep.md"]
);
}
#[test]
fn keeps_ignored_entries_when_visibility_is_enabled() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/\n");
let entries = vec![entry(dir.path(), "ignored/hidden.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, false);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["ignored/hidden.md"]
);
}
#[test]
fn filters_ignored_folder_trees() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
fs::create_dir_all(dir.path().join("generated/nested")).unwrap();
fs::create_dir_all(dir.path().join("notes")).unwrap();
let folders = vec![
FolderNode {
name: "generated".to_string(),
path: "generated".to_string(),
children: vec![FolderNode {
name: "nested".to_string(),
path: "generated/nested".to_string(),
children: vec![],
}],
},
FolderNode {
name: "notes".to_string(),
path: "notes".to_string(),
children: vec![],
},
];
let filtered = filter_gitignored_folders(dir.path(), folders, true);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, "notes");
}
#[test]
fn has_no_effect_without_gitignore_file() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let entries = vec![entry(dir.path(), "notes/local.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, true);
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
}
}

View File

@@ -6,6 +6,7 @@ pub(crate) mod filename_rules;
mod folders;
mod frontmatter;
mod getting_started;
mod ignored;
mod image;
mod migration;
mod parsing;
@@ -24,6 +25,7 @@ pub use entry::{FolderNode, VaultEntry};
pub use file::{create_note_content, get_note_content, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{

View File

@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
import { invoke } from '@tauri-apps/api/core'
import type { Settings, ViewDefinition, ViewFile } from './types'
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
const localStorageMock = (() => {
@@ -316,10 +317,19 @@ vi.mock('./mock-tauri', () => ({
}))
// Mock ai-chat utilities
vi.mock('./utils/ai-chat', () => ({
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
checkClaudeCli: vi.fn(async () => ({ installed: false })),
streamClaudeChat: vi.fn(async () => 'mock-session'),
vi.mock('./utils/ai-chat', async () => {
const actual = await vi.importActual<typeof import('./utils/ai-chat')>('./utils/ai-chat')
return {
...actual,
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
checkClaudeCli: vi.fn(async () => ({ installed: false })),
streamClaudeChat: vi.fn(async () => 'mock-session'),
}
})
vi.mock('./utils/streamAiAgent', () => ({
streamAiAgent: vi.fn(async () => {}),
}))
vi.mock('./hooks/useUpdater', async () => {
@@ -422,6 +432,7 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
import App from './App'
import { useUpdater } from './hooks/useUpdater'
import { isTauri } from './mock-tauri'
import { streamAiAgent } from './utils/streamAiAgent'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
@@ -457,6 +468,35 @@ describe('App', () => {
expect(await screen.findByText('All Notes', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('creates custom views with a portable fallback filename for symbol-only names', async () => {
const savedViews: ViewFile[] = []
const saveView = vi.fn(({ filename, definition }: { filename: string; definition: ViewDefinition }) => {
if (filename === '.yml') throw new Error('Invalid view filename')
savedViews.push({ filename, definition })
return null
})
mockCommandResults.save_view_cmd = saveView
mockCommandResults.list_views = () => savedViews
mockCommandResults.reload_vault = mockEntries
render(<App />)
await screen.findByText('All Notes')
fireEvent.click(screen.getByRole('button', { name: 'Create view' }))
const dialog = await screen.findByRole('dialog')
fireEvent.change(within(dialog).getByPlaceholderText(/Active Projects|Reading List/i), {
target: { value: '🚀' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(saveView).toHaveBeenCalledWith(expect.objectContaining({
filename: 'view.yml',
definition: expect.objectContaining({ name: '🚀' }),
}))
})
})
it('loads and displays vault entries in sidebar', async () => {
render(<App />)
await waitFor(() => {
@@ -619,6 +659,98 @@ describe('App', () => {
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
})
it('routes right-panel AI chat messages to the selected default agent', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
}
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
}
render(<App />)
await screen.findByText('All Notes')
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
}))
})
})
it('waits for saved AI agent settings before sending right-panel messages', async () => {
let resolveSettings: ((settings: Settings) => void) | null = null
mockCommandResults.get_settings = () => new Promise((resolve) => {
resolveSettings = resolve
})
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
}
render(<App />)
await screen.findByText('All Notes')
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await act(async () => {
await Promise.resolve()
})
expect(streamAiAgent).not.toHaveBeenCalled()
await act(async () => {
resolveSettings?.({
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
})
})
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
}))
})
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,

View File

@@ -72,6 +72,7 @@ import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import { createViewFilename } from './utils/viewFilename'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -438,6 +439,7 @@ function App() {
}, [saveSettings, settings])
const aiAgentPreferences = useAiAgentPreferences({
settings,
settingsLoaded,
saveSettings,
aiAgentsStatus,
onToast: setToastMessage,
@@ -540,8 +542,7 @@ function App() {
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforeNoteSwitch: flushEditorStateBeforeAction,
flushBeforeFrontmatterChange: flushEditorStateBeforeAction,
flushBeforePathRename: flushEditorStateBeforeAction,
flushBeforeNoteMutation: flushEditorStateBeforeAction,
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -1057,17 +1058,24 @@ function App() {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
return true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`)
return false
}
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
const existing = vault.views.find((view) => view.filename === filename)
@@ -1578,6 +1586,7 @@ function App() {
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onUnsupportedAiPaste={setToastMessage}
onInspectorResize={layout.handleInspectorResize}

View File

@@ -164,6 +164,21 @@ describe('AiPanel', () => {
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
it('disables sending while the selected AI agent is still loading', () => {
render(
<AiPanel
onClose={vi.fn()}
vaultPath="/tmp/vault"
defaultAiAgent="codex"
defaultAiAgentReadiness="checking"
/>,
)
expect(screen.getByText('Checking availability')).toBeTruthy()
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Checking AI agent availability...')
expect(screen.getByTestId('agent-send')).toBeDisabled()
})
it('auto-focuses input on mount', async () => {
vi.useFakeTimers()
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)

View File

@@ -5,7 +5,12 @@ import {
AiPanelHeader,
AiPanelMessageHistory,
} from './AiPanelChrome'
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import {
DEFAULT_AI_AGENT,
getAiAgentDefinition,
type AiAgentId,
type AiAgentReadiness,
} from '../lib/aiAgents'
import { type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
@@ -19,6 +24,7 @@ interface AiPanelProps {
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -39,23 +45,30 @@ interface AiPanelViewProps {
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
}
function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
return (ready ?? true) ? 'ready' : 'missing'
}
export function AiPanelView({
controller,
onClose,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
activeEntry,
entries,
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
@@ -98,7 +111,7 @@ export function AiPanelView({
>
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
agentReadiness={defaultAiAgentReadiness}
onClose={onClose}
onNewChat={handleNewChat}
/>
@@ -107,7 +120,7 @@ export function AiPanelView({
)}
<AiPanelMessageHistory
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
agentReadiness={defaultAiAgentReadiness}
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
@@ -117,7 +130,7 @@ export function AiPanelView({
<AiPanelComposer
entries={entries ?? []}
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
agentReadiness={defaultAiAgentReadiness}
input={input}
inputRef={inputRef}
isActive={isActive}
@@ -134,6 +147,7 @@ export function AiPanel({
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
@@ -146,10 +160,13 @@ export function AiPanel({
noteList,
noteListFilter,
}: AiPanelProps) {
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const controller = useAiPanelController({
vaultPath,
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
defaultAiAgentReadiness,
activeEntry,
activeNoteContent,
entries,
@@ -169,6 +186,7 @@ export function AiPanel({
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}
entries={entries}

View File

@@ -4,12 +4,13 @@ import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReady: boolean
agentReadiness: AiAgentReadiness
onClose: () => void
onNewChat: () => void
}
@@ -21,7 +22,7 @@ interface AiPanelContextBarProps {
interface AiPanelMessageHistoryProps {
agentLabel: string
agentReady: boolean
agentReadiness: AiAgentReadiness
messages: AiAgentMessage[]
isActive: boolean
onOpenNote?: (path: string) => void
@@ -32,7 +33,7 @@ interface AiPanelMessageHistoryProps {
interface AiPanelComposerProps {
entries: VaultEntry[]
agentLabel: string
agentReady: boolean
agentReadiness: AiAgentReadiness
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
@@ -43,9 +44,13 @@ interface AiPanelComposerProps {
function getComposerPlaceholder(
agentLabel: string,
agentReady: boolean,
agentReadiness: AiAgentReadiness,
): string {
if (!agentReady) {
if (agentReadiness === 'checking') {
return 'Checking AI agent availability...'
}
if (agentReadiness === 'missing') {
return `${agentLabel} is not installed. Open AI Agents in Settings.`
}
@@ -54,10 +59,27 @@ function getComposerPlaceholder(
function AiPanelEmptyState({
agentLabel,
agentReady,
agentReadiness,
hasContext,
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext'>) {
if (!agentReady) {
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext'>) {
if (agentReadiness === 'checking') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
Checking AI agent availability
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Messages can be sent when the selected agent is ready
</p>
</div>
)
}
if (agentReadiness === 'missing') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
@@ -98,7 +120,7 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReady,
agentReadiness,
onClose,
onNewChat,
}: AiPanelHeaderProps) {
@@ -113,8 +135,9 @@ export function AiPanelHeader({
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentLabel}
{!agentReady ? ' · not installed' : ''}
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
<button
@@ -154,7 +177,7 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa
export function AiPanelMessageHistory({
agentLabel,
agentReady,
agentReadiness,
messages,
isActive,
onOpenNote,
@@ -172,7 +195,7 @@ export function AiPanelMessageHistory({
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
agentReady={agentReady}
agentReadiness={agentReadiness}
hasContext={hasContext}
/>
)}
@@ -192,7 +215,7 @@ export function AiPanelMessageHistory({
export function AiPanelComposer({
entries,
agentLabel,
agentReady,
agentReadiness,
input,
inputRef,
isActive,
@@ -200,9 +223,9 @@ export function AiPanelComposer({
onSend,
onUnsupportedAiPaste,
}: AiPanelComposerProps) {
const composerDisabled = isActive || !agentReady
const composerDisabled = isActive || agentReadiness !== 'ready'
const canSend = !composerDisabled && input.trim().length > 0
const placeholder = getComposerPlaceholder(agentLabel, agentReady)
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
@@ -43,7 +43,7 @@ describe('CreateViewDialog', () => {
expect(input).toHaveValue('Active Projects')
})
it('preserves emoji icon when editing a view', () => {
it('preserves emoji icon when editing a view', async () => {
const onCreate = vi.fn()
const editingView: ViewDefinition = {
name: 'Monday',
@@ -55,12 +55,14 @@ describe('CreateViewDialog', () => {
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
// Submit the form without changing anything
fireEvent.submit(screen.getByText('Save').closest('form')!)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: '🗂️' })
)
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: '🗂️' })
)
})
})
it('passes selected emoji icon when creating a view', () => {
it('passes selected emoji icon when creating a view', async () => {
const onCreate = vi.fn()
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
@@ -72,21 +74,37 @@ describe('CreateViewDialog', () => {
fireEvent.click(emojiButtons[0])
// Submit the form
fireEvent.click(screen.getByText('Create'))
expect(onCreate).toHaveBeenCalledTimes(1)
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
const definition = onCreate.mock.calls[0][0] as ViewDefinition
expect(definition.icon).not.toBeNull()
expect(typeof definition.icon).toBe('string')
expect(definition.icon!.length).toBeGreaterThan(0)
})
it('passes null icon when no emoji is selected', () => {
it('passes null icon when no emoji is selected', async () => {
const onCreate = vi.fn()
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
fireEvent.change(input, { target: { value: 'No Icon View' } })
fireEvent.submit(screen.getByText('Create').closest('form')!)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: null })
)
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: null })
)
})
})
it('keeps the dialog open when async save reports failure', async () => {
const onClose = vi.fn()
const onCreate = vi.fn(async () => false)
render(<CreateViewDialog {...defaultProps} onClose={onClose} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
fireEvent.change(input, { target: { value: 'Unsaveable View' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByText('Create View')).toBeInTheDocument()
})
})

View File

@@ -6,10 +6,13 @@ import { FilterBuilder } from './FilterBuilder'
import { EmojiPicker } from './EmojiPicker'
import type { FilterGroup, ViewDefinition } from '../types'
type SaveViewResult = boolean | void
type SaveViewHandler = (definition: ViewDefinition) => SaveViewResult | Promise<SaveViewResult>
interface CreateViewDialogProps {
open: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
onCreate: SaveViewHandler
availableFields: string[]
/** When provided, the dialog operates in edit mode with pre-populated fields. */
editingView?: ViewDefinition | null
@@ -22,7 +25,7 @@ interface CreateViewDialogFormProps {
initialFilters: FilterGroup
isEditing: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
onCreate: SaveViewHandler
}
function CreateViewDialogForm({
@@ -38,6 +41,8 @@ function CreateViewDialogForm({
const [icon, setIcon] = useState(initialIcon)
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
const [saveError, setSaveError] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -45,8 +50,9 @@ function CreateViewDialogForm({
return () => window.clearTimeout(timeoutId)
}, [])
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (isSaving) return
const trimmed = name.trim()
if (!trimmed) return
const definition: ViewDefinition = {
@@ -56,8 +62,23 @@ function CreateViewDialogForm({
sort: null,
filters,
}
onCreate(definition)
onClose()
setSaveError(null)
setIsSaving(true)
let shouldClose = false
try {
const result = await onCreate(definition)
if (result === false) {
setSaveError('Could not save view.')
} else {
shouldClose = true
}
} catch {
setSaveError('Could not save view.')
}
setIsSaving(false)
if (shouldClose) onClose()
}
const handleSelectEmoji = useCallback((emoji: string) => {
@@ -76,14 +97,15 @@ function CreateViewDialogForm({
<div className="flex gap-2">
<div className="w-16 space-y-1.5 relative">
<label className="text-xs font-medium text-muted-foreground">Icon</label>
<button
<Button
type="button"
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
variant="outline"
className="flex h-9 w-full p-0 text-xl hover:bg-accent"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Pick icon"
>
{icon || <span className="text-sm text-muted-foreground">📋</span>}
</button>
</Button>
{showEmojiPicker && (
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
)}
@@ -94,10 +116,16 @@ function CreateViewDialogForm({
ref={inputRef}
placeholder="e.g. Active Projects, Reading List..."
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value)
if (saveError) setSaveError(null)
}}
/>
</div>
</div>
{saveError && (
<p role="alert" className="text-xs text-destructive">{saveError}</p>
)}
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
<label className="text-xs font-medium text-muted-foreground">Filters</label>
<FilterBuilder
@@ -107,8 +135,8 @@ function CreateViewDialogForm({
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={isCreateDisabled}>{isEditing ? 'Save' : 'Create'}</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>Cancel</Button>
<Button type="submit" disabled={isCreateDisabled || isSaving}>{isEditing ? 'Save' : 'Create'}</Button>
</DialogFooter>
</form>
)

View File

@@ -4,7 +4,7 @@ import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import 'katex/dist/katex.min.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
@@ -53,6 +53,7 @@ interface EditorProps {
onToggleInspector: () => void
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onInspectorResize: (delta: number) => void
inspectorEntry: VaultEntry | null
@@ -362,6 +363,7 @@ function EditorLayout({
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReadiness,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
@@ -423,6 +425,7 @@ function EditorLayout({
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
@@ -505,6 +508,7 @@ function EditorLayout({
inspectorCollapsed={inspectorCollapsed}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}
@@ -541,7 +545,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
@@ -632,6 +636,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
@@ -13,6 +13,7 @@ interface EditorRightPanelProps {
inspectorCollapsed: boolean
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onUnsupportedAiPaste?: (message: string) => void
inspectorEntry: VaultEntry | null
@@ -42,7 +43,7 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
@@ -55,6 +56,7 @@ export function EditorRightPanel({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
defaultAiAgentReadiness,
activeEntry: inspectorEntry,
activeNoteContent: inspectorContent,
entries,
@@ -88,6 +90,7 @@ export function EditorRightPanel({
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
activeEntry={inspectorEntry}
entries={entries}

View File

@@ -273,6 +273,7 @@
}
.editor__blocknote-container .mermaid-diagram {
position: relative;
width: 100%;
margin: 10px 0;
color: var(--colors-text);
@@ -287,6 +288,20 @@
background: var(--surface-editor);
}
.editor__blocknote-container .mermaid-diagram__expand-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
opacity: 0;
background: var(--surface-editor);
box-shadow: 0 6px 16px rgb(15 23 42 / 0.14);
}
.editor__blocknote-container .mermaid-diagram:is(:hover, :focus-within) .mermaid-diagram__expand-button {
opacity: 1;
}
.editor__blocknote-container .mermaid-diagram__viewport:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
@@ -300,6 +315,42 @@
margin: 0 auto;
}
@media (hover: none) {
.editor__blocknote-container .mermaid-diagram__expand-button {
opacity: 1;
}
}
.mermaid-diagram__dialog {
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
height: calc(100dvh - 32px);
max-height: calc(100dvh - 32px);
overflow: hidden;
padding: 0;
}
.mermaid-diagram__dialog-viewport {
width: 100%;
height: 100%;
overflow: auto;
padding: 24px;
background: #fff;
}
.mermaid-diagram__dialog-viewport:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
.mermaid-diagram__dialog-viewport svg {
display: block;
max-width: none;
min-width: min-content;
height: auto;
margin: auto;
}
.editor__blocknote-container .mermaid-diagram--error {
padding: 12px;
border: 1px solid var(--destructive);

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MermaidDiagram } from './MermaidDiagram'
@@ -31,6 +31,23 @@ describe('MermaidDiagram', () => {
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
})
expect(mermaidMock.render).toHaveBeenCalledWith(expect.stringMatching(/^tolaria-mermaid-/), 'flowchart LR\nA --> B')
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'default' }))
})
it('opens the rendered SVG in a lightbox', async () => {
render(
<MermaidDiagram
diagram={'flowchart LR\nA --> B'}
source={'```mermaid\nflowchart LR\nA --> B\n```'}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
})
fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid diagram' }))
expect(screen.getByTestId('mermaid-diagram-dialog-viewport').querySelector('svg')).not.toBeNull()
})
it('falls back to the original source when Mermaid cannot render', async () => {

View File

@@ -1,4 +1,13 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Maximize2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
type MermaidApi = typeof import('mermaid')['default']
@@ -7,6 +16,13 @@ interface MermaidDiagramProps {
source: string
}
interface MermaidSvgViewportProps {
ariaLabel: string
className: string
svg: string
testId: string
}
interface RenderState {
diagram: string
svg: string
@@ -27,15 +43,8 @@ function initializeMermaid(mermaid: MermaidApi) {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'base',
theme: 'default',
themeVariables: {
background: '#ffffff',
primaryColor: '#f8fafc',
primaryTextColor: '#111827',
primaryBorderColor: '#94a3b8',
lineColor: '#64748b',
secondaryColor: '#f1f5f9',
tertiaryColor: '#e0f2fe',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
},
})
@@ -60,6 +69,50 @@ async function renderMermaidDiagram({
return nextRender
}
function MermaidSvgViewport({ ariaLabel, className, svg, testId }: MermaidSvgViewportProps) {
return (
<div
aria-label={ariaLabel}
className={className}
data-testid={testId}
role="img"
tabIndex={0}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}
function MermaidLightbox({ svg }: { svg: string }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button
aria-label="Open Mermaid diagram"
className="mermaid-diagram__expand-button"
size="icon-sm"
title="Open diagram"
type="button"
variant="outline"
>
<Maximize2 aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="mermaid-diagram__dialog" showCloseButton>
<DialogTitle className="sr-only">Mermaid diagram</DialogTitle>
<DialogDescription className="sr-only">
Expanded view of the rendered Mermaid diagram.
</DialogDescription>
<MermaidSvgViewport
ariaLabel="Expanded Mermaid diagram"
className="mermaid-diagram__dialog-viewport"
svg={svg}
testId="mermaid-diagram-dialog-viewport"
/>
</DialogContent>
</Dialog>
)
}
export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
const reactId = useId()
const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId])
@@ -92,13 +145,12 @@ export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
return (
<figure className="mermaid-diagram" data-testid="mermaid-diagram">
<div
aria-label="Mermaid diagram"
<MermaidLightbox svg={currentState.svg} />
<MermaidSvgViewport
ariaLabel="Mermaid diagram"
className="mermaid-diagram__viewport"
data-testid="mermaid-diagram-viewport"
role="img"
tabIndex={0}
dangerouslySetInnerHTML={{ __html: currentState.svg }}
svg={currentState.svg}
testId="mermaid-diagram-viewport"
/>
</figure>
)

View File

@@ -17,6 +17,8 @@ const emptySettings: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
function installPointerCapturePolyfill() {
@@ -102,6 +104,21 @@ describe('SettingsPanel', () => {
autogit_inactive_threshold_seconds: 30,
release_channel: null,
theme_mode: 'light',
hide_gitignored_files: true,
}))
expect(onClose).toHaveBeenCalled()
})
it('saves Gitignored content visibility immediately for keyboard close', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('settings-hide-gitignored-files'))
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
hide_gitignored_files: false,
}))
expect(onClose).toHaveBeenCalled()
})

View File

@@ -33,6 +33,7 @@ import {
type ThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
import { Checkbox, type CheckedState } from './ui/checkbox'
@@ -70,6 +71,7 @@ interface SettingsDraft {
themeMode: ThemeMode
uiLanguage: UiLanguagePreference
initialH1AutoRename: boolean
hideGitignoredFiles: boolean
crashReporting: boolean
analytics: boolean
explicitOrganization: boolean
@@ -101,6 +103,8 @@ interface SettingsBodyProps {
systemLocale: AppLocale
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
hideGitignoredFiles: boolean
setHideGitignoredFiles: (value: boolean) => void
explicitOrganization: boolean
setExplicitOrganization: (value: boolean) => void
crashReporting: boolean
@@ -139,6 +143,7 @@ function createSettingsDraft(
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
hideGitignoredFiles: shouldHideGitignoredFiles(settings),
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
explicitOrganization: explicitOrganizationEnabled,
@@ -180,6 +185,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
ui_language: serializeUiLanguagePreference(draft.uiLanguage),
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
hide_gitignored_files: draft.hideGitignoredFiles,
}
}
@@ -268,6 +274,11 @@ function SettingsPanelInner({
[],
)
const handleGitignoredVisibilityChange = useCallback((value: boolean) => {
updateDraft('hideGitignoredFiles', value)
onSave({ ...settings, hide_gitignored_files: value })
}, [onSave, settings, updateDraft])
const handleSave = useCallback(() => {
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
onSave(buildSettingsFromDraft(settings, draft))
@@ -338,6 +349,8 @@ function SettingsPanelInner({
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
hideGitignoredFiles={draft.hideGitignoredFiles}
setHideGitignoredFiles={handleGitignoredVisibilityChange}
explicitOrganization={draft.explicitOrganization}
setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)}
crashReporting={draft.crashReporting}
@@ -397,6 +410,8 @@ function SettingsBody({
setUiLanguage,
initialH1AutoRename,
setInitialH1AutoRename,
hideGitignoredFiles,
setHideGitignoredFiles,
explicitOrganization,
setExplicitOrganization,
crashReporting,
@@ -455,6 +470,14 @@ function SettingsBody({
/>
</SettingsSection>
<SettingsSection>
<VaultContentSettingsSection
t={t}
hideGitignoredFiles={hideGitignoredFiles}
setHideGitignoredFiles={setHideGitignoredFiles}
/>
</SettingsSection>
<SettingsSection>
<AiAgentSettingsSection
t={t}
@@ -733,6 +756,29 @@ function TitleSettingsSection({
)
}
function VaultContentSettingsSection({
t,
hideGitignoredFiles,
setHideGitignoredFiles,
}: Pick<SettingsBodyProps, 't' | 'hideGitignoredFiles' | 'setHideGitignoredFiles'>) {
return (
<>
<SectionHeading
title={t('settings.vaultContent.title')}
description={t('settings.vaultContent.description')}
/>
<SettingsSwitchRow
label={t('settings.vaultContent.hideGitignored')}
description={t('settings.vaultContent.hideGitignoredDescription')}
checked={hideGitignoredFiles}
onChange={setHideGitignoredFiles}
testId="settings-hide-gitignored-files"
/>
</>
)
}
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> {
return AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import type { AiAgentId } from '../lib/aiAgents'
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent'
import type { VaultEntry } from '../types'
import {
@@ -12,6 +12,7 @@ interface UseAiPanelControllerArgs {
vaultPath: string
defaultAiAgent: AiAgentId
defaultAiAgentReady: boolean
defaultAiAgentReadiness?: AiAgentReadiness
activeEntry?: VaultEntry | null
activeNoteContent?: string | null
entries?: VaultEntry[]
@@ -36,10 +37,18 @@ export interface AiPanelController {
handleNewChat: () => void
}
function resolveAgentReady(
readiness: AiAgentReadiness | undefined,
ready: boolean,
): boolean {
return (readiness ?? (ready ? 'ready' : 'missing')) === 'ready'
}
export function useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
defaultAiAgentReadiness,
activeEntry,
activeNoteContent,
entries,
@@ -70,7 +79,7 @@ export function useAiPanelController({
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
agent: defaultAiAgent,
agentReady: defaultAiAgentReady,
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
})
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'

View File

@@ -59,6 +59,7 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
'restore-getting-started': 'command.settings.restoreGettingStarted',
'reload-vault': 'command.settings.reloadVault',
'repair-vault': 'command.settings.repairVault',
'toggle-gitignored-files-visibility': 'command.settings.toggleGitignoredFilesVisibility',
'open-ai-agents': 'command.ai.openAgents',
'restore-vault-ai-guidance': 'command.ai.restoreGuidance',
}

View File

@@ -1,22 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { formatShortcutDisplay } from '../appCommandCatalog'
import { TOGGLE_GITIGNORED_VISIBILITY_EVENT } from '../../lib/gitignoredVisibilityEvents'
import { buildSettingsCommands } from './settingsCommands'
function findCommand(id: string, commands = buildSettingsCommands({ onOpenSettings: vi.fn() })) {
return commands.find((item) => item.id === id)
}
function expectOpenSettingsCommand(id: string, label: string) {
const onOpenSettings = vi.fn()
const command = findCommand(id, buildSettingsCommands({ onOpenSettings }))
expect(command).toMatchObject({
label,
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
}
describe('buildSettingsCommands', () => {
it('adds a discoverable H1 auto-rename settings command', () => {
const onOpenSettings = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings })
const command = commands.find((item) => item.id === 'open-h1-auto-rename-setting')
expect(command).toMatchObject({
label: 'Open H1 Auto-Rename Setting',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
expectOpenSettingsCommand('open-h1-auto-rename-setting', 'Open H1 Auto-Rename Setting')
})
it('keeps the general settings command available', () => {
@@ -32,19 +39,7 @@ describe('buildSettingsCommands', () => {
})
it('adds a discoverable language settings command', () => {
const onOpenSettings = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings })
const command = commands.find((item) => item.id === 'open-language-settings')
expect(command).toMatchObject({
label: 'Open Language Settings',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
expectOpenSettingsCommand('open-language-settings', 'Open Language Settings')
})
it('adds language switch commands when a setter is available', () => {
@@ -101,4 +96,34 @@ describe('buildSettingsCommands', () => {
command?.execute()
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
})
it('adds a command palette toggle for Gitignored file visibility', () => {
const onOpenSettings = vi.fn()
const onToggleGitignoredFilesVisibility = vi.fn()
const commands = buildSettingsCommands({
onOpenSettings,
onToggleGitignoredFilesVisibility,
})
const command = commands.find((item) => item.id === 'toggle-gitignored-files-visibility')
expect(command).toMatchObject({
label: 'Toggle Gitignored Files Visibility',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onToggleGitignoredFilesVisibility).toHaveBeenCalledTimes(1)
})
it('dispatches the Gitignored visibility event when no direct handler is provided', () => {
const listener = vi.fn()
window.addEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
findCommand('toggle-gitignored-files-visibility')?.execute()
expect(listener).toHaveBeenCalledTimes(1)
window.removeEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
})
})

View File

@@ -1,6 +1,7 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
import { requestGitignoredVisibilityToggle } from '../../lib/gitignoredVisibilityEvents'
import {
APP_LOCALES,
SYSTEM_UI_LANGUAGE,
@@ -25,6 +26,7 @@ interface SettingsCommandsConfig {
onInstallMcp?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onToggleGitignoredFilesVisibility?: () => void
locale?: AppLocale
systemLocale?: AppLocale
selectedUiLanguage?: UiLanguagePreference
@@ -140,7 +142,8 @@ function buildMaintenanceCommands({
onInstallMcp,
onReloadVault,
onRepairVault,
}: Pick<SettingsCommandsConfig, 'mcpStatus' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'>): CommandAction[] {
onToggleGitignoredFilesVisibility,
}: Pick<SettingsCommandsConfig, 'mcpStatus' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault' | 'onToggleGitignoredFilesVisibility'>): CommandAction[] {
return [
{
id: 'install-mcp',
@@ -150,6 +153,14 @@ function buildMaintenanceCommands({
enabled: true,
execute: () => onInstallMcp?.(),
},
{
id: 'toggle-gitignored-files-visibility',
label: 'Toggle Gitignored Files Visibility',
group: 'Settings',
keywords: ['gitignore', 'ignored', 'files', 'folders', 'visibility', 'hide', 'show', 'generated', 'local'],
enabled: true,
execute: onToggleGitignoredFilesVisibility ?? requestGitignoredVisibilityToggle,
},
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
]
@@ -159,7 +170,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, onToggleGitignoredFilesVisibility,
locale = 'en', systemLocale = locale, selectedUiLanguage = SYSTEM_UI_LANGUAGE, onSetUiLanguage,
} = config
@@ -180,6 +191,12 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
onRemoveActiveVault,
onRestoreGettingStarted,
}),
...buildMaintenanceCommands({ mcpStatus, onInstallMcp, onReloadVault, onRepairVault }),
...buildMaintenanceCommands({
mcpStatus,
onInstallMcp,
onReloadVault,
onRepairVault,
onToggleGitignoredFilesVisibility,
}),
]
}

View File

@@ -27,21 +27,36 @@ describe('useAiAgentPreferences', () => {
it('resolves the selected label and readiness', () => {
const { result } = renderHook(() => useAiAgentPreferences({
settings,
settingsLoaded: true,
saveSettings: vi.fn(),
aiAgentsStatus,
}))
expect(result.current.defaultAiAgent).toBe('claude_code')
expect(result.current.defaultAiAgentLabel).toBe('Claude Code')
expect(result.current.defaultAiAgentReadiness).toBe('ready')
expect(result.current.defaultAiAgentReady).toBe(true)
})
it('keeps the selected agent unavailable while settings are loading', () => {
const { result } = renderHook(() => useAiAgentPreferences({
settings,
settingsLoaded: false,
saveSettings: vi.fn(),
aiAgentsStatus,
}))
expect(result.current.defaultAiAgentReadiness).toBe('checking')
expect(result.current.defaultAiAgentReady).toBe(false)
})
it('cycles to the next agent and persists the selection', () => {
const saveSettings = vi.fn()
const onToast = vi.fn()
const { result } = renderHook(() => useAiAgentPreferences({
settings,
settingsLoaded: true,
saveSettings,
aiAgentsStatus,
onToast,
@@ -61,6 +76,7 @@ describe('useAiAgentPreferences', () => {
it('keeps the browser mock agent composer enabled when no CLI is installed', () => {
const { result } = renderHook(() => useAiAgentPreferences({
settings,
settingsLoaded: true,
saveSettings: vi.fn(),
aiAgentsStatus: {
claude_code: { status: 'missing', version: null },

View File

@@ -3,8 +3,8 @@ import { isTauri } from '../mock-tauri'
import {
getAiAgentDefinition,
getNextAiAgentId,
isAiAgentInstalled,
resolveDefaultAiAgent,
type AiAgentReadiness,
type AiAgentId,
type AiAgentsStatus,
} from '../lib/aiAgents'
@@ -12,13 +12,28 @@ import type { Settings } from '../types'
interface UseAiAgentPreferencesArgs {
settings: Settings
settingsLoaded: boolean
saveSettings: (settings: Settings) => void
aiAgentsStatus: AiAgentsStatus
onToast?: (message: string) => void
}
function getDefaultAiAgentReadiness(
settingsLoaded: boolean,
aiAgentsStatus: AiAgentsStatus,
defaultAiAgent: AiAgentId,
): AiAgentReadiness {
if (!settingsLoaded) return 'checking'
if (!isTauri()) return 'ready'
const status = aiAgentsStatus[defaultAiAgent].status
if (status === 'checking') return 'checking'
return status === 'installed' ? 'ready' : 'missing'
}
export function useAiAgentPreferences({
settings,
settingsLoaded,
saveSettings,
aiAgentsStatus,
onToast,
@@ -29,7 +44,12 @@ export function useAiAgentPreferences({
)
const defaultAiAgentLabel = getAiAgentDefinition(defaultAiAgent).label
const defaultAiAgentReady = !isTauri() || isAiAgentInstalled(aiAgentsStatus, defaultAiAgent)
const defaultAiAgentReadiness = getDefaultAiAgentReadiness(
settingsLoaded,
aiAgentsStatus,
defaultAiAgent,
)
const defaultAiAgentReady = defaultAiAgentReadiness === 'ready'
const setDefaultAiAgent = useCallback((agent: AiAgentId) => {
saveSettings({
@@ -46,6 +66,7 @@ export function useAiAgentPreferences({
return {
defaultAiAgent,
defaultAiAgentLabel,
defaultAiAgentReadiness,
defaultAiAgentReady,
setDefaultAiAgent,
cycleDefaultAiAgent,

View File

@@ -70,16 +70,16 @@ describe('useNoteActions frontmatter persistence', () => {
},
},
])('flushes pending raw content before a frontmatter $label', async ({ run }) => {
const flushBeforeFrontmatterChange = vi.fn().mockResolvedValue(undefined)
const flushBeforeNoteMutation = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() => useNoteActions({
...makeConfig(vi.fn()),
flushBeforeFrontmatterChange,
flushBeforeNoteMutation,
}))
await act(async () => {
await run(result)
})
expect(flushBeforeFrontmatterChange).toHaveBeenCalledWith('/vault/note.md')
expect(flushBeforeNoteMutation).toHaveBeenCalledWith('/vault/note.md')
})
})

View File

@@ -77,7 +77,7 @@ describe('useNoteActions title rename guard', () => {
it('flushes pending editor work before renaming through the title property', async () => {
const entry = makeEntry()
const events: string[] = []
const flushBeforePathRename = vi.fn(async (path: string) => {
const flushBeforeNoteMutation = vi.fn(async (path: string) => {
events.push(`flush:${path}`)
})
@@ -90,7 +90,7 @@ describe('useNoteActions title rename guard', () => {
return ''
})
const { result } = renderHook(() => useNoteActions(makeConfig(entry, { flushBeforePathRename })))
const { result } = renderHook(() => useNoteActions(makeConfig(entry, { flushBeforeNoteMutation })))
await act(async () => {
result.current.handleSelectNote(entry)
@@ -100,17 +100,18 @@ describe('useNoteActions title rename guard', () => {
await result.current.handleUpdateFrontmatter(entry.path, 'title', 'New Name')
})
expect(flushBeforePathRename).toHaveBeenCalledWith(entry.path)
expect(flushBeforeNoteMutation).toHaveBeenCalledTimes(1)
expect(flushBeforeNoteMutation).toHaveBeenCalledWith(entry.path)
expect(events).toEqual([`flush:${entry.path}`, 'rename'])
})
it('stops the title rename flow when pending editor work fails to flush', async () => {
const entry = makeEntry()
const flushBeforePathRename = vi.fn().mockRejectedValue(new Error('disk full'))
const flushBeforeNoteMutation = vi.fn().mockRejectedValue(new Error('disk full'))
const updateEntry = vi.fn()
const { result } = renderHook(() => useNoteActions(makeConfig(entry, {
flushBeforePathRename,
flushBeforeNoteMutation,
updateEntry,
})))

View File

@@ -1,7 +1,11 @@
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { useTabManagement } from './useTabManagement'
import {
GITIGNORED_VISIBILITY_APPLIED_EVENT,
type GitignoredVisibilityAppliedEvent,
} from '../lib/gitignoredVisibilityEvents'
import { resolveEntry } from '../utils/wikilink'
import { useNoteCreation } from './useNoteCreation'
import {
@@ -15,8 +19,7 @@ export interface NoteActionsConfig {
removeEntry: (path: string) => void
entries: VaultEntry[]
flushBeforeNoteSwitch?: (path: string) => Promise<void>
flushBeforeFrontmatterChange?: (path: string) => Promise<void>
flushBeforePathRename?: (path: string) => Promise<void>
flushBeforeNoteMutation?: (path: string) => Promise<void>
reloadVault?: () => Promise<unknown>
setToastMessage: (msg: string | null) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
@@ -127,30 +130,14 @@ interface MaybeRenameAfterFrontmatterUpdateParams {
deps: TitleRenameDeps
}
async function flushBeforeTitleRename(
async function flushBeforeNoteMutation(
path: string,
key: string,
value: FrontmatterValue,
flushBeforePathRename?: (path: string) => Promise<void>,
flushBeforeMutation?: (path: string) => Promise<void>,
): Promise<boolean> {
if (!shouldRenameOnTitleUpdate(key, value) || !flushBeforePathRename) return true
if (!flushBeforeMutation) return true
try {
await flushBeforePathRename(path)
return true
} catch {
return false
}
}
async function flushBeforeFrontmatterMutation(
path: string,
flushBeforeFrontmatterChange?: (path: string) => Promise<void>,
): Promise<boolean> {
if (!flushBeforeFrontmatterChange) return true
try {
await flushBeforeFrontmatterChange(path)
await flushBeforeMutation(path)
return true
} catch {
return false
@@ -196,12 +183,9 @@ async function updateFrontmatterAndMaybeRename({
runFrontmatterOp,
value,
}: UpdateFrontmatterAndMaybeRenameParams): Promise<void> {
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
const canFlush = await flushBeforeNoteMutation(path, config.flushBeforeNoteMutation)
if (!canFlush) return
const canRename = await flushBeforeTitleRename(path, key, value, config.flushBeforePathRename)
if (!canRename) return
const newContent = await runFrontmatterOp('update', path, key, value, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
@@ -235,6 +219,33 @@ function buildTabManagementOptions(
return options
}
function useGitignoredVisibilityTabCleanup({
activeTabPathRef,
closeAllTabs,
setToastMessage,
}: {
activeTabPathRef: React.MutableRefObject<string | null>
closeAllTabs: () => void
setToastMessage: (msg: string | null) => void
}) {
useEffect(() => {
if (typeof window === 'undefined') return
const handleVisibilityApplied = (event: Event) => {
const { hide, visiblePaths } = (event as GitignoredVisibilityAppliedEvent).detail
const activePath = activeTabPathRef.current
if (!hide || !activePath || visiblePaths.includes(activePath)) return
closeAllTabs()
setToastMessage('Closed hidden Gitignored file')
}
window.addEventListener(GITIGNORED_VISIBILITY_APPLIED_EVENT, handleVisibilityApplied)
return () => {
window.removeEventListener(GITIGNORED_VISIBILITY_APPLIED_EVENT, handleVisibilityApplied)
}
}, [activeTabPathRef, closeAllTabs, setToastMessage])
}
function useFrontmatterActionHandlers({
config,
renameTabsRef,
@@ -289,7 +300,7 @@ function useFrontmatterActionHandlers({
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
const canFlush = await flushBeforeNoteMutation(path, config.flushBeforeNoteMutation)
if (!canFlush) return
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
@@ -298,7 +309,7 @@ function useFrontmatterActionHandlers({
}, [config, runFrontmatterOp])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
const canFlush = await flushBeforeNoteMutation(path, config.flushBeforeNoteMutation)
if (!canFlush) return
const newContent = await runFrontmatterOp('update', path, key, value)
@@ -317,6 +328,11 @@ export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement(buildTabManagementOptions(config))
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
useGitignoredVisibilityTabCleanup({
activeTabPathRef,
closeAllTabs: tabMgmt.closeAllTabs,
setToastMessage,
})
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))

Some files were not shown because too many files have changed in this diff Show More