Compare commits

...

79 Commits

Author SHA1 Message Date
lucaronin
c4c6bb6c57 fix: restore folder row toggle 2026-04-23 09:27:17 +02:00
lucaronin
35dd2dd47b docs: add/update ADR for note retargeting (guard — from commit 2c9361d7) 2026-04-23 08:07:56 +02:00
lucaronin
89141bae14 chore: ratchet codescene thresholds 2026-04-23 00:53:45 +02:00
lucaronin
2b9294f884 style: format note move backend 2026-04-23 00:41:04 +02:00
lucaronin
a756af54ec fix: keep note drop targets active during drag 2026-04-23 00:27:09 +02:00
lucaronin
b01b156dfb fix: align folder rows with sidebar items 2026-04-23 00:21:20 +02:00
lucaronin
2c9361d704 feat: retarget notes to types and folders 2026-04-22 23:56:00 +02:00
lucaronin
cea71a4fd9 Merge branch 'main' of https://github.com/refactoringhq/tolaria 2026-04-22 22:54:40 +02:00
lucaronin
8b73ef5836 feat: add sidebar folder rename and delete actions 2026-04-22 22:49:16 +02:00
lucaronin
a6a727a4c0 fix: align inspector create-type typing 2026-04-22 22:42:04 +02:00
lucaronin
bf13eed3ab fix: handle filename collisions in create flows 2026-04-22 22:37:01 +02:00
lucaronin
a27a70e552 fix: allow PostHog and Google Fonts in app CSP 2026-04-22 22:24:45 +02:00
lucaronin
ca92bf4f77 fix: restore keyboard shortcut typing 2026-04-22 21:22:35 +02:00
lucaronin
9fda0a67ec fix: make note renames crash-safe 2026-04-22 21:18:38 +02:00
lucaronin
0ffb7c65a9 Ignore history shortcuts while editing 2026-04-22 21:06:27 +02:00
lucaronin
633d9f1496 Add security policy and private reporting guidance 2026-04-22 20:46:24 +02:00
lucaronin
ba3d413b94 fix: recover from missing note paths 2026-04-22 20:18:26 +02:00
lucaronin
f3286922ad fix: satisfy clippy for MCP status 2026-04-22 19:28:58 +02:00
lucaronin
b0b476c9e5 fix: harden external AI tool setup 2026-04-22 19:18:52 +02:00
lucaronin
dac696b752 fix: guard toolbar popover anchors 2026-04-22 17:57:30 +02:00
lucaronin
cda87ee6c0 feat: add sidebar folder rename and delete actions 2026-04-22 17:16:56 +02:00
lucaronin
33324a5c79 fix: harden mcp bridge and vault boundaries 2026-04-22 17:14:42 +02:00
lucaronin
f28626bbfe fix: finish claude agent streams 2026-04-22 15:59:12 +02:00
lucaronin
1bc2e3d1e0 docs: add ADRs for startup and linkify lifecycles (guard — from commits 59268acd, bf442646) 2026-04-22 15:09:45 +02:00
lucaronin
8e6bdab3e6 docs: ratchet codescene thresholds 2026-04-22 00:31:54 +02:00
lucaronin
d52362ad9b fix: repair tiptap patch integrity 2026-04-22 00:21:46 +02:00
lucaronin
bf442646f5 fix: silence duplicate linkify init warnings 2026-04-22 00:05:32 +02:00
lucaronin
1b3a444368 fix: show welcome on fresh install without missing vault 2026-04-21 23:34:07 +02:00
lucaronin
c4da7f1f2f fix: narrow raw mode sync override 2026-04-21 19:04:02 +02:00
lucaronin
c5fe291a9a fix: sync inspector edits into raw editor 2026-04-21 19:04:02 +02:00
lucaronin
59268acd04 fix: guard empty vault path during startup 2026-04-21 19:04:01 +02:00
lucaronin
3e4e2ebdc6 refactor: simplify breadcrumb and note-list search ui 2026-04-21 19:03:11 +02:00
Luca Rossi
05375258a2 Rename 'Documentation' section to 'Tech Docs' 2026-04-21 18:06:30 +02:00
Luca Rossi
0c0354bfb8 Updated README badges 2026-04-21 18:05:54 +02:00
lucaronin
6023e95d5b fix: serialize vitest coverage files 2026-04-21 17:38:44 +02:00
lucaronin
88d5694353 fix: retry flaky vitest coverage teardown 2026-04-21 17:35:00 +02:00
lucaronin
db6b38843b fix: keep vitest coverage shards off workspace 2026-04-21 17:21:35 +02:00
lucaronin
ad9901bbf5 fix: stabilize coverage suite reruns 2026-04-21 17:08:43 +02:00
lucaronin
b785c53ef9 test: increase strategic coverage 2026-04-21 16:54:52 +02:00
lucaronin
8ae1ade220 test: cover live sidebar type picker sync 2026-04-21 15:16:39 +02:00
lucaronin
35b035643e fix: adapt save reload callback 2026-04-21 15:09:27 +02:00
lucaronin
d3d663af0b fix: reload vault entries on tauri startup 2026-04-21 15:02:42 +02:00
lucaronin
8f624350d8 test: cover journal vault scan 2026-04-21 12:37:46 +02:00
lucaronin
0e54173834 test: cover explicit journal visibility 2026-04-21 12:27:06 +02:00
lucaronin
a6f1524a78 ci: reject placeholder telemetry secrets 2026-04-21 11:06:16 +02:00
lucaronin
4cd5baeb55 fix: sync sidebar types with live vault state 2026-04-21 10:45:39 +02:00
lucaronin
fb1265e088 test: stabilize note list search timing 2026-04-21 10:14:18 +02:00
lucaronin
ca89662231 fix: sync raw editor frontmatter state 2026-04-21 10:06:54 +02:00
lucaronin
97f285e3cb ci: upload coverage to codecov 2026-04-21 10:06:54 +02:00
Luca Rossi
4bd4ef43dc Added release badge in README 2026-04-21 09:43:40 +02:00
Luca Rossi
be29880a3e Added walkthroughs to the README 2026-04-21 09:22:51 +02:00
lucaronin
7be5519f61 fix: sort alpha releases chronologically 2026-04-21 09:10:16 +02:00
lucaronin
8cefbe949a fix: refine derived inverse relationship labels 2026-04-21 09:08:25 +02:00
lucaronin
dd0a55b8e8 docs: add ADR for external vault refresh after pulls and agent edits (guard — from commit 0020ade6) 2026-04-21 08:03:02 +02:00
lucaronin
610276b1bc docs: ratchet codescene thresholds 2026-04-21 00:56:59 +02:00
lucaronin
e6c968e37e fix: satisfy note list keyboard build 2026-04-21 00:50:44 +02:00
lucaronin
367a66f32a fix: debounce note list search 2026-04-21 00:47:45 +02:00
lucaronin
6a4046915c fix: harden startup telemetry urls 2026-04-20 23:38:41 +02:00
lucaronin
ec8b637c84 fix: hide uncloned getting started vault 2026-04-20 23:04:34 +02:00
lucaronin
3ee42fb856 test: align create-note smoke placeholder 2026-04-20 22:13:19 +02:00
lucaronin
eacf05a9f8 fix: reload notes after agent edits 2026-04-20 22:04:38 +02:00
lucaronin
a199fad803 fix: widen editor focus block typing 2026-04-20 18:38:51 +02:00
lucaronin
e956c806e5 fix: keep editor sync from clobbering live inserts 2026-04-20 18:35:50 +02:00
lucaronin
7a6e3863ad fix: reopen active note after pull updates 2026-04-20 16:04:11 +02:00
lucaronin
e0e2dc4063 test: add pull refresh regression 2026-04-20 15:53:56 +02:00
lucaronin
0020ade6d7 fix: tighten pulled note refresh flow 2026-04-20 14:43:52 +02:00
lucaronin
503e482c7d fix: normalize inverse relationship labels 2026-04-20 12:49:57 +02:00
lucaronin
0d0e6af4c9 fix: keep vault menu remove action in row 2026-04-20 12:19:10 +02:00
lucaronin
dbaf361878 test: fix starter-vault app shell coverage 2026-04-20 11:32:44 +02:00
lucaronin
d3741c82f5 test: fix starter-vault shell regression 2026-04-20 11:20:27 +02:00
lucaronin
b90d2f8612 fix: reorder welcome onboarding actions 2026-04-20 11:04:29 +02:00
lucaronin
9536efee0e fix: persist AI panel chats across reopen 2026-04-20 10:28:48 +02:00
lucaronin
c86a6ab9c5 docs: add/update ADR for starter vault local-first remote flow (guard — from commit 374b4f66) 2026-04-20 10:28:00 +02:00
Luca Rossi
1d7c027700 Updated Tolaria Icon 2026-04-20 10:25:46 +02:00
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
lucaronin
08855cee92 fix: defer git history during note switching 2026-04-20 00:09:52 +02:00
lucaronin
03042bb49c fix: normalize shipped telemetry secrets 2026-04-19 23:27:57 +02:00
lucaronin
cee4bef179 fix: harden shipped telemetry config 2026-04-19 23:16:34 +02:00
245 changed files with 20901 additions and 2939 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.89
AVERAGE_THRESHOLD=9.75
HOTSPOT_THRESHOLD=9.94
AVERAGE_THRESHOLD=9.81

View File

@@ -10,6 +10,7 @@ Il workflow `ci.yml` esegue i seguenti check automatici:
### 2. Test Coverage
- Frontend: vitest con coverage reporting
- Upload automatico su Codecov dai report LCOV frontend + Rust
- Threshold configurabile in `vitest.config.ts`
### 3. Code Health (CodeScene)
@@ -40,6 +41,11 @@ CODESCENE_PROJECT_ID=<your-project-id>
Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token).
Il project ID lo trovi nella dashboard CodeScene.
### Codecov Setup
- Installa/attiva il repo in Codecov una volta sola tramite GitHub App / import del repository.
- Nessun `CODECOV_TOKEN` richiesto in GitHub Actions: `ci.yml` usa OIDC (`id-token: write` + `use_oidc: true`).
- Il workflow carica `coverage/lcov.info` (Vitest) e `coverage/rust.lcov` (cargo-llvm-cov).
### Telemetry Secrets For Release Builds
Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`:
@@ -97,8 +103,11 @@ codescene delta-analysis --base-revision origin/main
## Workflow Triggers
- **Push**: su `main` e branch `experiment/*`
- **Push**: su `main`
- **Pull Request**: verso `main`
- **Manuale**: `workflow_dispatch`
Nota: l'upload a Codecov gira su push a `main` e sulle PR dello stesso repository. Le PR da fork saltano l'upload per evitare problemi di permessi OIDC.
## Status Checks

View File

@@ -3,6 +3,13 @@ name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
@@ -78,10 +85,23 @@ jobs:
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
--lcov \
--output-path coverage/rust.lcov \
--fail-under-lines 85
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
- name: Upload coverage to Codecov
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: codecov/codecov-action@v5
with:
use_oidc: true
fail_ci_if_error: true
disable_search: true
files: ./coverage/lcov.info,./coverage/rust.lcov
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
# ── 3. Code Health (CodeScene — Hotspot + Average Code Health gates) ──
# Enforces minimum floors on BOTH hotspot and average code health.
# Thresholds come from .codescene-thresholds so CI and local hooks match.

View File

@@ -127,6 +127,95 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}

View File

@@ -42,6 +42,21 @@ jobs:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$")
def parse_alpha_tag(tag: str) -> tuple[str, int] | None:
match = alpha_pattern.fullmatch(tag)
if not match:
return None
calendar_version, sequence = match.groups()
return calendar_version, int(sequence)
def alpha_version(calendar_version: str, sequence: int) -> str:
return f"{calendar_version}-alpha.{sequence}"
def alpha_tag(calendar_version: str, sequence: int) -> str:
return f"alpha-v{calendar_version}-alpha.{sequence:04d}"
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
@@ -49,7 +64,8 @@ jobs:
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
parsed = parse_alpha_tag(tag)
version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v")
else:
today = datetime.now(timezone.utc).date()
stable_date = None
@@ -71,8 +87,8 @@ jobs:
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
version = alpha_version(calendar_version, sequence)
tag = alpha_tag(calendar_version, sequence)
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
@@ -170,6 +186,95 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -218,7 +323,27 @@ jobs:
- name: Generate release notes
run: |
PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "")
PREV_TAG=$(python3 <<'PY'
import re
import subprocess
current_tag = '${{ needs.version.outputs.tag }}'
pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$')
output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip()
tags = [line for line in output.splitlines() if line and line != current_tag]
parsed_tags = []
for tag in tags:
match = pattern.fullmatch(tag)
if not match:
continue
year, month, day, sequence = map(int, match.groups())
parsed_tags.append(((year, month, day, sequence), tag))
print(max(parsed_tags)[1] if parsed_tags else '')
PY
)
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
else

View File

@@ -1,4 +1,4 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Build](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865)
# 💧 Tolaria
@@ -12,6 +12,13 @@ Personally, I use it to **run my life** (hey 👋 [Luca here](http://x.com/lucar
<img width="1000" height="656" alt="1776506856823-CleanShot_2026-04-18_at_12 06 57_2x" src="https://github.com/user-attachments/assets/8aeafb0a-b236-43c2-a083-ec111f903c38" />
## Walkthroughs
You can find some Loom walkthroughs below — they are short and to the point:
- [How I Organize My Own Tolaria Workspace](https://www.loom.com/share/bb3aaffa238b4be0bd62e4464bca2528)
- [My Inbox Workflow](https://www.loom.com/share/dffda263317b4fa8b47b59cdf9330571)
- [How I Save Web Resources to Tolaria](https://www.loom.com/share/8a3c1776f801402ebbf4d7b0f31e9882)
## Principles
- 📑 **Files-first** — Your notes are plain markdown files. They're portable, work with any editor, and require no export step. Your data belongs to you, not to any app.
@@ -54,13 +61,17 @@ Open `http://localhost:5173` for the browser-based mock mode, or run the native
pnpm tauri dev
```
## Documentation
## Tech Docs
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
- 📚 [ADRs](docs/adr) — Architecture Decision Records
## Security
If you believe you have found a security issue, please report it privately as described in [SECURITY.md](./SECURITY.md).
## License
Tolaria is licensed under AGPL-3.0-or-later. The Tolaria name and logo remain covered by the projects trademark policy.

55
SECURITY.md Normal file
View File

@@ -0,0 +1,55 @@
# Security Policy
Thanks for helping keep Tolaria safe.
If you believe you have found a security vulnerability, **please do not open a public GitHub issue**. Report it privately instead.
## Supported versions
We currently support security fixes for:
| Version | Supported |
| --- | --- |
| Latest stable release | ✅ |
| `main` branch | Best effort |
| Older releases / prereleases | ❌ |
## Reporting a vulnerability
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
Include as much of the following as you can:
- a short description of the issue
- reproduction steps or a proof of concept
- affected version / commit, if known
- impact assessment
- any suggested mitigation
If the issue involves sensitive user data, credentials, or a working exploit, keep the report private and do not post details publicly.
## What to expect
We will try to:
- acknowledge receipt within a few business days
- reproduce and assess the report
- work on a fix or mitigation if the issue is valid
- coordinate public disclosure after users have had a reasonable chance to update
## Disclosure guidelines
Please give us a reasonable amount of time to investigate and ship a fix before publishing details.
We appreciate responsible disclosure and good-faith research.
## Out of scope
The following are generally out of scope unless they demonstrate a real security impact:
- missing best-practice headers or hardening with no practical exploit
- self-XSS or editor behavior that requires unrealistic user actions
- reports that only affect unsupported old builds
- purely theoretical issues with no plausible attack path
If you are unsure whether something qualifies, please still report it privately.

View File

@@ -153,7 +153,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
└── type/ ← type definition documents
```
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
@@ -239,7 +239,7 @@ Tolaria separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -266,6 +266,14 @@ type SidebarSelection =
| { kind: 'view'; filename: string }
```
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -273,7 +281,7 @@ type SidebarSelection =
- The selected `entry` is the neighborhood source note.
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
- Outgoing relationship groups render first using the note's `relationships` map.
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
- Inverse groups (`Children`, `Events`, `Referenced by`) and `Backlinks` render after the outgoing groups.
- Empty groups stay visible with count `0`.
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
@@ -301,6 +309,8 @@ type SidebarSelection =
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`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, and folder mutations cannot step outside the active vault.
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
@@ -395,6 +405,8 @@ interface PulseCommit {
`useAutoSync` hook handles automatic git sync:
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
- Reopens the clean active tab from disk after a successful pull update so the editor and note list stay aligned
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status
@@ -508,6 +520,7 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
### Raw Editor Mode
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
## Styling
@@ -667,6 +680,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -176,7 +176,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
@@ -199,7 +199,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
- Secondary windows are sized 800×700 with overlay title bar
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
@@ -211,7 +211,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json`
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
#### Agent Event Flow
@@ -305,13 +305,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
### Auto-Registration
### Explicit External Tool Setup
On app startup, Tolaria automatically registers itself as an MCP server in:
Tolaria can register itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
### Architecture
@@ -330,7 +330,7 @@ flowchart TD
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
TAURI -->|"auto-register"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
```
### WebSocket Bridge
@@ -357,10 +357,11 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs on explicit user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code and Cursor configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
## Search
@@ -381,6 +382,8 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
### Three Cache Strategies
```mermaid
@@ -486,7 +489,7 @@ sequenceDiagram
participant MCP as MCP Server
participant U as User
T->>T: run_startup_tasks()<br/>(register MCP)
T->>T: run_startup_tasks()<br/>(migrate + seed only)
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
T->>A: App mounts
@@ -495,10 +498,10 @@ sequenceDiagram
A-->>U: WelcomeScreen
else Vault found
A->>VL: useVaultLoader fires
VL->>T: invoke('list_vault') → scan_vault_cached()
VL->>T: invoke('reload_vault') → sync active vault asset scope + scan_vault_cached()
T-->>VL: VaultEntry[]
VL->>T: invoke('get_modified_files')
VL->>T: useMcpStatus — register if needed
A->>T: useMcpStatus — check explicit MCP setup state
VL-->>A: entries ready
end
@@ -531,7 +534,11 @@ flowchart TD
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
PULL --> PC{Result?}
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
PC -->|Fast-forward| RV["reload vault"]
PC -->|Fast-forward| RV["reload vault + folders/views"]
RV --> TAB{"clean active tab?"}
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
TAB -->|No| DONE["idle"]
RT --> DONE
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
@@ -579,7 +586,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 |
| `rename.rs` | `rename_note` renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `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` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
@@ -595,7 +602,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `mcp.rs` | MCP server spawning + config registration |
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
| `commands/` | Tauri command handlers (split into submodules) |
| `settings.rs` | App settings persistence |
| `vault_config.rs` | Per-vault UI config |
@@ -612,11 +619,15 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `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) |
| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks |
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
| `create_vault_folder` | Create a folder relative to the active vault root |
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
| `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` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
@@ -675,8 +686,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
| `check_mcp_status` | Check MCP registration state |
| `register_mcp_tools` | Register MCP in Claude/Cursor config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor config |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
### Settings & Config
@@ -724,7 +738,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming with wikilink update |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
@@ -755,7 +770,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:
@@ -778,6 +793,7 @@ Every push to `main` triggers `.github/workflows/release.yml`:
```
push to main
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
@@ -785,7 +801,7 @@ push to main
→ upload signed .app.tar.gz + .sig updater artifacts
→ release job:
→ generate alpha-latest.json
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
→ pages job:
→ build static HTML release history page
→ publish alpha/latest.json
@@ -815,7 +831,7 @@ push stable-vYYYY.M.D tag
### Versioning
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. The GitHub release tag zero-pads the sequence as `alpha-vYYYY.M.D-alpha.NNNN` so GitHub release ordering remains chronological.
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.

View File

@@ -111,7 +111,7 @@ tolaria/
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
│ │ ├── useMcpBridge.ts # MCP WebSocket client
│ │ ├── useMcpStatus.ts # MCP registration status
│ │ ├── useMcpStatus.ts # Explicit external AI tool connection status + connect/disconnect actions
│ │ ├── useUpdater.ts # In-app updates
│ │ └── ...
│ │
@@ -165,7 +165,7 @@ tolaria/
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── mcp.rs # MCP server lifecycle + registration
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
│ │ ├── settings.rs # App settings persistence
│ │ ├── vault_config.rs # Per-vault UI config
@@ -234,7 +234,7 @@ tolaria/
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, safe-default Codex adapter, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
@@ -389,4 +389,4 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs`
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex on the normal approval/sandbox path unless you are intentionally designing an advanced mode)

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0070"
title: "Starter vaults are local-first with explicit remote connection"
status: active
date: 2026-04-19
---
## Context
ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error.
That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started.
## Decision
**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette.
**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo.
## Options considered
- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step.
- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state.
- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story.
## Consequences
- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later.
- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows.
- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote.
- The starter repo remains a distribution source only, not an ongoing sync destination.
- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step.

View File

@@ -0,0 +1,48 @@
---
type: ADR
id: "0071"
title: "External vault updates reload derived state and reopen the clean active note"
status: active
date: 2026-04-21
---
## Context
ADR-0002 makes the filesystem the source of truth, and ADR-0043 keeps locally edited frontmatter reactive inside the running UI. But external vault mutations still had a gap. A `git pull` or an AI agent edit could change notes on disk while the app kept showing stale note-list state, stale derived relationships, or an editor surface that still rendered the pre-refresh BlockNote document.
The fix needed to satisfy a few constraints at once:
- refresh all vault-derived UI, not just the main note list
- preserve unsaved local edits instead of clobbering them with disk state
- reopen the active note from disk when it is safe, even if another file changed, because backlinks, inverse relationships, and other derived surfaces can depend on the whole vault
- handle the native editor case where an in-place file update requires a full tab reopen to show the fresh document reliably
## Decision
**All external vault mutations now reconcile through one shared refresh path that reloads vault-derived state and then conditionally reopens the active note from disk.**
Tolaria now routes post-pull refreshes and AI-agent file modifications through the same `refreshPulledVaultState()` helper.
That shared path does the following:
1. Reload `vault.entries`, folders, and saved views together.
2. If there is no active note, stop after the reload.
3. If the active note has unsaved local edits, keep the current editor buffer and do not replace it from disk.
4. Otherwise, find the refreshed `VaultEntry` for the active note and replace the active tab with freshly loaded disk content.
5. If the active file itself changed in place during the external update, close the tab before reopening it so BlockNote fully remounts onto the new document.
6. If the active file no longer exists after the reload, close the open tab state instead of leaving a stale editor behind.
## Options considered
- **Shared external-refresh reconciler** (chosen): one policy for pulls and agent edits, consistent vault-derived UI, and explicit protection for unsaved local edits. Cons: more coupling between sync flows and tab management.
- **Patch only the changed surfaces ad hoc**: smaller individual fixes, but high risk of drift between pull handling, agent handling, and future external-write paths.
- **Always force a full app-level reload**: simplest correctness story, but too disruptive and more likely to throw away user context unnecessarily.
## Consequences
- Any workflow that mutates the vault externally, such as git pulls or agent writes, should go through the shared refresh reconciler rather than reloading a single surface in isolation.
- Clean active notes now converge back to on-disk truth automatically after external updates.
- Unsaved local edits remain protected from external refreshes, even when the rest of the vault reloads.
- Folder, saved-view, backlink, and inverse-relationship surfaces stay aligned with the refreshed vault, not just the editor tab.
- Tolaria now treats "refresh after external mutation" as a first-class synchronization concern rather than a per-feature fix.
- Re-evaluate if the editor gains a reliable in-place document reset API, because that could remove the need for the close-and-reopen step when the active file itself changed.

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0072"
title: "Confirmed vault paths gate startup state"
status: active
date: 2026-04-22
---
## Context
Tolaria's startup path was assuming that any incoming `vaultPath` was authoritative immediately. In practice, boot can pass through transient empty paths and stale paths that no longer correspond to the persisted active vault. That produced two classes of regressions:
1. `useVaultLoader` fired `reload_vault` and `get_modified_files` before a real vault path existed, generating avoidable warnings and backend calls for `""`.
2. On fresh install or other non-persisted startup cases, a missing path could incorrectly render `vault-missing` instead of the intended welcome flow.
Tolaria's onboarding and vault-loading surfaces need the same invariant: only a confirmed vault identity should drive startup side effects or missing-vault error UI.
## Decision
**Tolaria now treats a vault path as authoritative at startup only after it is confirmed.** Vault-loading side effects no-op until the path is non-empty, and the `vault-missing` onboarding state is shown only when the missing path was the persisted active vault recorded in `load_vault_list`. Otherwise, startup falls back to `welcome`.
## Options considered
- **Option A** (chosen): gate startup effects and missing-vault UI on confirmed vault identity. This keeps boot deterministic, avoids empty-path backend calls, and preserves the product rule that fresh installs should land in Welcome rather than an error state.
- **Option B**: treat any startup `vaultPath` as authoritative immediately. Simpler branching, but it keeps the existing race where transient or stale paths trigger warnings and the wrong onboarding state.
- **Option C**: special-case each startup surface independently. Lower immediate churn, but it would duplicate boot logic and let `useOnboarding` and `useVaultLoader` drift again.
## Consequences
- `useVaultLoader` must guard all startup work behind a real non-empty vault path.
- `useOnboarding` must consult persisted vault state before deciding that a missing path represents a deleted active vault.
- Fresh installs, cleared vault lists, and other startup flows without a confirmed active vault should resolve to `welcome`, even if an initial path probe fails.
- Re-evaluate if Tolaria introduces deeper startup routing (for example multiple launch intents or restored workspaces) that needs a richer boot-state model than the current confirmed-path gate.

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0073"
title: "Persistent linkify protocol registry across editor remounts"
status: active
date: 2026-04-22
---
## Context
Tolaria keeps a single editor shell alive while users swap notes and toggle between BlockNote and raw mode. The upstream BlockNote/Tiptap link stack assumes linkify protocol registration is effectively one-shot per editor lifetime, and Tiptap's link extension resets that registry on destroy.
In Tolaria's lifecycle, that behavior caused duplicate `linkifyjs: already initialized` warnings during note-open and editor-remount flows. The problem is cross-cutting: BlockNote and Tiptap both participate, and the failure only disappears when protocol registration survives teardown/remount cycles instead of being repeated opportunistically.
## Decision
**Tolaria patches the upstream BlockNote and Tiptap link packages so custom linkify protocols are pre-registered once per app runtime and are not reset on editor teardown.** The patched packages coordinate through `globalThis` flags, and Tolaria tracks them via `pnpm` patched dependencies rather than ad hoc runtime monkey-patching inside app code.
## Options considered
- **Option A** (chosen): maintain explicit `pnpm` patches for the affected upstream packages, pre-register the needed protocols once, and preserve the registry across remounts. This matches Tolaria's persistent editor shell and keeps the behavior deterministic in both dev and packaged builds.
- **Option B**: keep upstream behavior and tolerate or suppress the warnings locally. Lower maintenance, but it leaves editor lifecycle correctness dependent on noisy duplicate initialization and makes future regressions harder to reason about.
- **Option C**: add Tolaria-side runtime monkey-patches around editor mount/unmount. Avoids vendoring patches, but spreads dependency-specific lifecycle logic into application code and is more fragile across package upgrades.
## Consequences
- `pnpm-workspace.yaml` now treats the relevant BlockNote and Tiptap link packages as patched dependencies, so upgrades must preserve or consciously replace those patches.
- Editor teardown in Tolaria must not assume ownership of the global linkify protocol registry.
- Smoke coverage for note open, editor remount, and raw-mode toggling must stay in place because the failure mode is lifecycle-specific rather than feature-specific.
- Re-evaluate this ADR if upstream BlockNote/Tiptap gains a supported lifecycle-safe protocol-registration model that makes the Tolaria patches unnecessary.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0074"
title: "Explicit external AI tool setup and least-privilege desktop scope"
status: active
date: 2026-04-22
---
## Context
Tolaria's first MCP integration optimized for zero setup: desktop startup auto-registered the Tolaria MCP server in Claude Code and Cursor config files, the Tauri asset protocol allowed every local path, and app-managed Codex sessions launched with the CLI's dangerous bypass flag. That made the product feel convenient, but it also widened trust by default in places that users could not see or consent to clearly.
The product direction now favors least-privilege defaults. Fresh installs should not silently edit third-party config files, external AI tool setup must be intentional and reversible, and the desktop shell should only expose the filesystem paths that the active vault actually needs.
## Decision
**Tolaria now treats external AI tool wiring as an explicit user action and keeps the desktop shell scoped to the active vault.**
- The app still spawns its local MCP WebSocket bridge on desktop startup, but it no longer auto-registers third-party MCP config files.
- External MCP registration is exposed through a keyboard-accessible setup flow reachable from the command palette and status surfaces. Confirming the flow upserts Tolaria's MCP entry for the current vault; cancel leaves external config untouched; disconnect removes Tolaria's entry again.
- The Tauri asset protocol remains enabled for local vault images, but its static config scope is empty. Tolaria grants recursive asset access only to the active vault at runtime when that vault is reloaded.
- App-managed Codex sessions use the CLI's normal approval and sandbox path by default instead of opting into the dangerous bypass mode automatically.
## Options considered
- **Explicit setup + runtime vault-only scope** (chosen): aligns with least-privilege defaults, keeps command-palette discoverability, preserves image loading and external-tool support, and makes every privileged step visible and reversible.
- **Keep startup auto-registration and global asset scope**: lowest friction, but it silently mutates third-party config and leaves the desktop shell effectively open to every local file path.
- **Disable external MCP registration entirely**: safest on paper, but it removes a valuable workflow for Claude Code, Cursor, and other MCP-compatible tools that Tolaria intentionally supports.
## Consequences
- Fresh installs no longer modify `~/.claude/mcp.json` or `~/.cursor/mcp.json` until the user confirms setup.
- Switching vaults does not silently retarget external MCP clients; users reconnect explicitly when they want a different vault exposed.
- Desktop asset access is constrained to the active vault instead of all filesystem paths, while note images and attachments continue to load normally.
- The command palette and status bar now expose an explicit external AI tools setup/remove flow that supports keyboard-only QA.
- Codex agent sessions are safer by default, at the cost of relying on the CLI's normal approval path instead of bypassing it automatically.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0075"
title: "Crash-safe note rename transactions"
status: active
date: 2026-04-22
---
## Context
Tolaria's note rename path used a simple "write new file, then delete old file" flow. That was easy to implement, but it had three integrity problems called out by issue #205: it could leave a visible duplicate note if the app crashed between those steps, destination-path selection depended on check-then-use races, and backlink rewrite failures were collapsed into a generic updated-files count that made partial success look clean.
Rename is a core vault integrity operation. The app needs a flow that preserves a trustworthy visible state even when the process dies mid-rename, and it needs to surface any partial backlink rewrite failures clearly enough that users are not told everything updated when some linked files still need manual repair.
## Decision
**Tolaria now stages note renames through a hidden per-vault transaction directory and recovers unfinished transactions on the next vault scan.**
- A rename that changes the file path first writes a transaction manifest plus a hidden backup path inside `<vault>/.tolaria-rename-txn/`.
- Tolaria moves the old note into that hidden backup, persists the new file with a no-clobber destination write, and then deletes the backup/manifest only after the new note exists.
- If the process crashes before the new note is committed, the next `scan_vault` restores the hidden backup back to the original path before listing entries.
- Manual filename renames keep their explicit conflict semantics, but the final destination is now claimed with a no-clobber write instead of relying on an existence check.
- Backlink rewrites now return both the number of successful updates and the number of failed updates so the UI can warn about partial success instead of reporting a clean rename.
## Options considered
- **Hidden transaction directory + scan-time recovery** (chosen): keeps in-flight rename artifacts out of the visible vault model, gives Tolaria a deterministic recovery point after crashes, and lets the final destination use no-clobber persistence.
- **Rename in place without transaction metadata**: simpler, but it cannot recover a half-finished rename reliably after process death and still leaves either duplicate or missing-note windows.
- **Best-effort duplicate cleanup with no recovery path**: lowest implementation cost, but it leaves the user-visible vault state dependent on exact crash timing and does not meet the trustworthiness goal for rename operations.
## Consequences
- Every vault can now contain a hidden `.tolaria-rename-txn/` directory managed by Tolaria; scan and folder UI continue to ignore it because hidden directories are already excluded.
- Rename results are richer: the frontend must treat `failed_updates > 0` as a warning state even when the rename itself succeeded.
- Future changes to vault scanning or note rename behavior must preserve transaction recovery before entry listing, otherwise crash safety regresses.
- The rename path no longer silently overwrites a destination discovered via a stale existence check; title-driven renames retry with suffixed filenames, while explicit filename renames fail cleanly on collision.

View File

@@ -0,0 +1,34 @@
---
type: ADR
id: "0076"
title: "Note retargeting separates type changes from folder moves"
status: active
date: 2026-04-22
---
## Context
ADR-0025 made `type:` the canonical classification field, and ADR-0033 reopened subfolders as a valid way to organize files in the vault. Once Tolaria exposed both type sections and the folder tree in the sidebar, note reorganization had an unresolved ambiguity: does retargeting a note mean changing its semantic type, moving its file, or both?
Without an explicit model, drag-and-drop and command-palette flows would need to duplicate their own validation and persistence logic, and Tolaria could easily drift back toward the old type-folder coupling that ADR-0006 deliberately removed.
## Decision
**Tolaria treats note retargeting as one shared interaction model with two distinct mutation paths: types change metadata, folders change file paths.**
- Retargeting a note to a type updates only the note's `type:` frontmatter. The file stays where it is.
- Retargeting a note to a folder preserves the current filename and `type:` value, and moves the file through the same crash-safe rename transaction pipeline used for backend rename commands.
- Drag/drop targets and command-palette actions both route through the same frontend retargeting abstraction so validation, dialogs, collision handling, and success/error behavior stay consistent.
## Options considered
- **Shared retargeting model with separate type-vs-folder semantics** (chosen): preserves ADR-0025/ADR-0006's decoupling of type from path, lets folder moves reuse ADR-0075's crash-safe rename guarantees, and keeps multiple UI surfaces behaviorally aligned.
- **Treat folders as the source of truth for note type**: simpler mental model for some vaults, but it reintroduces path-based type inference and makes type changes depend on file moves again.
- **Implement drag/drop and command-palette retargeting as separate flows**: lower short-term coordination cost, but it duplicates mutation rules and makes consistency regressions likely.
## Consequences
- Type sections are semantic targets only; they must never imply a filesystem move.
- Folder targets are physical move operations; they must preserve filename/title behavior, reject collisions, and rewrite path-based wikilinks through the shared rename pipeline.
- Future note-retargeting surfaces should reuse the shared retargeting abstraction instead of introducing another mutation path.
- Re-evaluate this ADR if Tolaria later supports bulk retargeting, folder rules that intentionally infer type, or another organization primitive that needs different semantics.

View File

@@ -66,7 +66,7 @@ proposed → active → superseded
| [0008](0008-underscore-system-properties.md) | Underscore convention for system properties | active |
| [0009](0009-keyword-only-search.md) | Keyword-only search (remove semantic indexing) | active |
| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active |
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | active |
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) |
| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active |
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active |
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
@@ -125,3 +125,10 @@ proposed → active → superseded
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | active |
| [0072](0072-confirmed-vault-paths-gate-startup-state.md) | Confirmed vault paths gate startup state | active |
| [0073](0073-persistent-linkify-protocol-registry-across-editor-remounts.md) | Persistent linkify protocol registry across editor remounts | active |
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active |
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |

View File

@@ -6,8 +6,10 @@ import os from 'node:os'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
@@ -79,6 +81,17 @@ describe('getNote', () => {
{ code: 'ENOENT' }
)
})
it('should reject absolute paths outside the vault', async () => {
await assertRejectsOutsideVault('laputa-mcp-outside-', outsideNote => outsideNote)
})
it('should reject traversal paths outside the vault', async () => {
await assertRejectsOutsideVault(
'laputa-mcp-traversal-',
outsideNote => path.relative(tmpDir, outsideNote),
)
})
})
describe('searchNotes', () => {
@@ -142,3 +155,53 @@ describe('vaultContext', () => {
assert.equal(ctx.noteCount, 3)
})
})
describe('evaluateBridgeRequest', () => {
it('accepts loopback UI requests from trusted origins', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: 'http://localhost:5202',
remoteAddress: '127.0.0.1',
}),
{ ok: true, reason: null },
)
})
it('rejects browser origins on the tool bridge', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'tool',
origin: 'https://evil.example',
remoteAddress: '127.0.0.1',
}),
{ ok: false, reason: 'browser origins are not allowed on the tool bridge' },
)
})
it('rejects non-loopback clients even without an origin', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: undefined,
remoteAddress: '192.168.1.10',
}),
{ ok: false, reason: 'non-local client' },
)
})
})
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')
try {
await fs.writeFile(outsideNote, '# Outside\n')
await assert.rejects(
() => getNote(tmpDir, resolveNotePath(outsideNote)),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await fs.rm(outsideDir, { recursive: true, force: true })
}
}

View File

@@ -6,6 +6,8 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
/**
* Recursively find all .md files under a directory.
* @param {string} dir
@@ -15,17 +17,28 @@ export async function findMarkdownFiles(dir) {
const results = []
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
if (item.name.startsWith('.')) continue
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
} else if (item.name.endsWith('.md')) {
results.push(full)
}
await collectMarkdownFile(results, dir, item)
}
return results
}
async function resolveVaultNotePath(vaultPath, notePath) {
const vaultRoot = await fs.realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
const noteRealPath = await fs.realpath(requestedPath)
const relativePath = path.relative(vaultRoot, noteRealPath)
if (!isVaultRelativePath(relativePath)) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return {
vaultRoot,
noteRealPath,
relativePath,
}
}
/**
* Read a note with parsed frontmatter and content.
* @param {string} vaultPath
@@ -33,11 +46,14 @@ export async function findMarkdownFiles(dir) {
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
*/
export async function getNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const {
noteRealPath,
relativePath,
} = await resolveVaultNotePath(vaultPath, notePath)
const raw = await fs.readFile(noteRealPath, 'utf-8')
const parsed = matter(raw)
return {
path: path.relative(vaultPath, absPath),
path: relativePath,
frontmatter: parsed.data,
content: parsed.content.trim(),
}
@@ -59,18 +75,15 @@ export async function searchNotes(vaultPath, query, limit = 10) {
if (results.length >= limit) break
const content = await fs.readFile(filePath, 'utf-8')
const filename = path.basename(filePath, '.md')
const titleMatch = extractTitle(content, filename)
const matches = titleMatch.toLowerCase().includes(q) || content.toLowerCase().includes(q)
if (!matchesSearchQuery(titleMatch, content, q)) continue
if (matches) {
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
return results
@@ -88,47 +101,92 @@ export async function vaultContext(vaultPath) {
const notesWithMtime = []
for (const filePath of files) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
const { topFolder, note, type } = await readVaultContextNote(vaultPath, filePath)
if (type) typesSet.add(type)
const rel = path.relative(vaultPath, filePath)
const topFolder = rel.split(path.sep)[0]
if (topFolder !== rel) foldersSet.add(topFolder + '/')
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
})
if (topFolder) foldersSet.add(topFolder)
notesWithMtime.push(note)
}
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
// Read config files for AI agent context
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
configFiles.agents = agentsContent
} catch {
// config/agents.md may not exist yet
}
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
configFiles,
configFiles: await readConfigFiles(vaultPath),
vaultPath,
}
}
// --- Helpers ---
async function collectMarkdownFile(results, dir, item) {
if (item.name.startsWith('.')) return
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
return
}
if (item.name.endsWith('.md')) {
results.push(full)
}
}
function resolveRequestedNotePath(vaultRoot, notePath) {
if (path.isAbsolute(notePath)) return notePath
return path.resolve(vaultRoot, notePath)
}
function isVaultRelativePath(relativePath) {
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
function matchesSearchQuery(title, content, query) {
return title.toLowerCase().includes(query) || content.toLowerCase().includes(query)
}
async function readVaultContextNote(vaultPath, filePath) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const rel = path.relative(vaultPath, filePath)
const topFolder = extractTopFolder(rel)
const stat = await fs.stat(filePath)
const type = parsed.data.type || parsed.data.is_a || null
return {
topFolder,
type,
note: {
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
},
}
}
function extractTopFolder(relativePath) {
const topFolder = relativePath.split(path.sep)[0]
return topFolder === relativePath ? null : `${topFolder}/`
}
async function readConfigFiles(vaultPath) {
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
configFiles.agents = await fs.readFile(agentsPath, 'utf-8')
} catch {
// config/agents.md may not exist yet
}
return configFiles
}
/**
* Extract title from markdown content (first H1 or frontmatter title).
* @param {string} content

View File

@@ -28,6 +28,12 @@ import {
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const LOOPBACK_HOST = 'localhost'
const TRUSTED_UI_ORIGINS = new Set([
'tauri://localhost',
'http://tauri.localhost',
'https://tauri.localhost',
])
/** @type {WebSocketServer | null} */
let uiBridge = null
@@ -71,6 +77,52 @@ async function handleMessage(data) {
}
}
export function isLoopbackAddress(remoteAddress) {
return remoteAddress === '127.0.0.1'
|| remoteAddress === '::1'
|| remoteAddress === '::ffff:127.0.0.1'
}
export function isTrustedUiOrigin(origin) {
if (!origin) return true
if (TRUSTED_UI_ORIGINS.has(origin)) return true
return /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/u.test(origin)
}
export function evaluateBridgeRequest({ bridgeType, origin, remoteAddress }) {
if (!isLoopbackAddress(remoteAddress)) {
return { ok: false, reason: 'non-local client' }
}
if (bridgeType === 'tool' && origin) {
return { ok: false, reason: 'browser origins are not allowed on the tool bridge' }
}
if (bridgeType === 'ui' && !isTrustedUiOrigin(origin)) {
return { ok: false, reason: 'untrusted UI origin' }
}
return { ok: true, reason: null }
}
function verifyBridgeRequest(bridgeType) {
return (info, done) => {
const verdict = evaluateBridgeRequest({
bridgeType,
origin: info.origin,
remoteAddress: info.req.socket.remoteAddress,
})
if (!verdict.ok) {
console.error(`[ws-bridge] Rejected ${bridgeType} bridge client: ${verdict.reason}`)
done(false, 403, 'Forbidden')
return
}
done(true)
}
}
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
@@ -89,8 +141,11 @@ export function startUiBridge(port = WS_UI_PORT) {
resolve(null)
})
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
httpServer.listen(port, LOOPBACK_HOST, () => {
const wss = new WebSocketServer({
server: httpServer,
verifyClient: verifyBridgeRequest('ui'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
@@ -109,7 +164,11 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const wss = new WebSocketServer({ port })
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
verifyClient: verifyBridgeRequest('tool'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
@@ -126,7 +185,7 @@ export function startBridge(port = WS_PORT) {
ws.on('close', () => console.error('[ws-bridge] Client disconnected'))
})
console.error(`[ws-bridge] Listening on ws://localhost:${port}`)
console.error(`[ws-bridge] Listening on ws://${LOOPBACK_HOST}:${port}`)
return wss
}

View File

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

View File

@@ -11,3 +11,50 @@ index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceac
if (l === u)
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
r.state.tr.setMeta(B, {
diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts
--- a/src/editor/managers/ExtensionManager/extensions.ts
+++ b/src/editor/managers/ExtensionManager/extensions.ts
@@ -84,7 +84,8 @@ export function getDefaultTiptapExtensions(
}).configure({
defaultProtocol: DEFAULT_LINK_PROTOCOL,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS,
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: [],
}),
...(Object.values(editor.schema.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark.configure({
diff --git a/dist/blocknote.js b/dist/blocknote.js
--- a/dist/blocknote.js
+++ b/dist/blocknote.js
@@ -2037,7 +2037,9 @@ const de = () => {
]
})
);
-let fe = !1;
+const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered";
+const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis;
+let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]);
function mn(o, e) {
const t = [
I.ClipboardTextSerializer,
@@ -2063,8 +2065,9 @@ function mn(o, e) {
inclusive: !1
}).configure({
defaultProtocol: Ct,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: fe ? [] : Bt
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: []
}),
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
editor: o
@@ -2112,7 +2115,7 @@ function mn(o, e) {
),
Do(o)
];
- return fe = !0, t;
+ return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t;
}
function kn(o, e) {
const t = [

View File

@@ -0,0 +1,101 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index b7357fa..c2c59cb 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -36,6 +36,16 @@ var import_core = require("@tiptap/core");
var import_state = require("@tiptap/pm/state");
var import_linkifyjs = require("linkifyjs");
+var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"];
+var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered";
+var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis;
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ (0, import_linkifyjs.registerCustomProtocol)(protocol);
+ });
+ linkifyGlobalObject[linkifyProtocolFlag] = true;
+}
+
// src/helpers/whitespace.ts
var UNICODE_WHITESPACE_PATTERN = "[\0- \xA0\u1680\u180E\u2000-\u2029\u205F\u3000]";
var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
@@ -253,7 +263,8 @@ var Link = import_core3.Mark.create({
});
},
onDestroy() {
- (0, import_linkifyjs3.reset)();
+ // Tolaria keeps a single editor shell alive across note swaps, so linkify's
+ // protocol registry must survive editor teardown and remount cycles.
},
inclusive() {
return this.options.autolink;
@@ -472,4 +483,4 @@ var index_default = Link;
isAllowedUri,
pasteRegex
});
-//# sourceMappingURL=index.cjs.map
\ No newline at end of file
+//# sourceMappingURL=index.cjs.map
diff --git a/dist/index.js b/dist/index.js
index d486894..cb8e256 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -13,6 +13,16 @@ var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
var UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`);
var UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, "g");
+var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"];
+var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered";
+var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis;
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ registerCustomProtocol(protocol);
+ });
+ linkifyGlobalObject[linkifyProtocolFlag] = true;
+}
+
// src/helpers/autolink.ts
function isValidLinkStructure(tokens) {
if (tokens.length === 1) {
@@ -224,7 +234,8 @@ var Link = Mark.create({
});
},
onDestroy() {
- reset();
+ // Tolaria keeps a single editor shell alive across note swaps, so linkify's
+ // protocol registry must survive editor teardown and remount cycles.
},
inclusive() {
return this.options.autolink;
@@ -443,4 +454,4 @@ export {
isAllowedUri,
pasteRegex
};
-//# sourceMappingURL=index.js.map
\ No newline at end of file
+//# sourceMappingURL=index.js.map
diff --git a/src/link.ts b/src/link.ts
index 839a575..bbef783 100644
--- a/src/link.ts
+++ b/src/link.ts
@@ -8,6 +8,20 @@ import { clickHandler } from './helpers/clickHandler.js'
import { pasteHandler } from './helpers/pasteHandler.js'
import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js'
+const PRE_REGISTERED_PROTOCOLS = ['tel', 'callto', 'sms', 'cid', 'xmpp'] as const
+const linkifyProtocolFlag = '__tolariaTiptapLinkifyProtocolsRegistered'
+const linkifyGlobalObject = typeof globalThis === 'undefined'
+ ? undefined
+ : (globalThis as Record<string, boolean | undefined>)
+
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ registerCustomProtocol(protocol)
+ })
+
+ linkifyGlobalObject[linkifyProtocolFlag] = true
+}
+
export interface LinkProtocolOptions {
/**
* The protocol scheme to be registered.

23
pnpm-lock.yaml generated
View File

@@ -6,11 +6,14 @@ settings:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
hash: 04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97
path: patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0':
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
path: patches/@tiptap__extension-link@3.19.0.patch
prosemirror-tables@1.8.5:
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
path: patches/prosemirror-tables@1.8.5.patch
@@ -24,10 +27,10 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4511,9 +4514,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -4521,7 +4524,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -4532,7 +4535,7 @@ snapshots:
'@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-link': 3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
@@ -4573,7 +4576,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
@@ -4595,7 +4598,7 @@ snapshots:
'@blocknote/react@0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
@@ -6288,7 +6291,7 @@ snapshots:
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
'@tiptap/extension-link@3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0

View File

@@ -7,4 +7,5 @@ ignoredBuiltDependencies:
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0': patches/@tiptap__extension-link@3.19.0.patch
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch

View File

@@ -1,47 +1,121 @@
#!/usr/bin/env node
import { mkdir, rename, rm } from 'node:fs/promises'
import { cp, mkdir, rm } from 'node:fs/promises'
import os from 'node:os'
import { resolve } from 'node:path'
import { spawn } from 'node:child_process'
const rootDir = process.cwd()
const finalCoverageDir = resolve(rootDir, 'coverage')
const coverageRunRoot = resolve(rootDir, '.tmp', 'vitest-coverage-runs')
const runId = `${Date.now()}-${process.pid}`
const runCoverageDir = resolve(coverageRunRoot, runId)
const coverageRunRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-runs')
const forwardedArgs = process.argv.slice(2)
await mkdir(runCoverageDir, { recursive: true })
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
arg === '--fileParallelism' || arg === '--no-file-parallelism'
)
const maxAttempts = 2
const packageManagerExec = process.env.npm_execpath
const command = packageManagerExec ? process.execPath : 'pnpm'
const commandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage', `--coverage.reportsDirectory=${runCoverageDir}`, ...forwardedArgs]
: ['exec', 'vitest', 'run', '--coverage', `--coverage.reportsDirectory=${runCoverageDir}`, ...forwardedArgs]
const baseCommandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
: ['exec', 'vitest', 'run', '--coverage']
const exitCode = await new Promise((resolveExit, rejectExit) => {
const child = spawn(command, commandArgs, {
cwd: rootDir,
env: process.env,
stdio: 'inherit',
})
child.on('error', rejectExit)
child.on('exit', (code, signal) => {
if (signal) {
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
return
}
resolveExit(code ?? 1)
})
})
if (exitCode === 0) {
await rm(finalCoverageDir, { recursive: true, force: true })
await rename(runCoverageDir, finalCoverageDir)
process.exit(0)
function isKnownVitestInternalStateFlake(output) {
return output.includes('Vitest failed to access its internal state.')
&& /Test Files\s+\d+\s+passed\s+\(\d+\)/.test(output)
&& /Tests\s+\d+\s+passed\s+\(\d+\)/.test(output)
}
console.error(`Vitest coverage artifacts preserved at ${runCoverageDir}`)
process.exit(exitCode)
function appendCapturedOutput(output, chunk) {
const nextOutput = output + chunk
return nextOutput.length > 200_000 ? nextOutput.slice(-200_000) : nextOutput
}
async function runCoverageAttempt(attempt) {
const runId = `${Date.now()}-${process.pid}-${attempt}`
const runCoverageDir = resolve(coverageRunRoot, runId)
const runCoverageTempDir = resolve(runCoverageDir, '.tmp')
await mkdir(runCoverageDir, { recursive: true })
// Vitest writes per-worker coverage shards under reportsDirectory/.tmp.
await mkdir(runCoverageTempDir, { recursive: true })
const commandArgs = [
...baseCommandArgs,
// Vitest 4.0.18 occasionally crashes during coverage worker teardown
// after all files pass, so serialize file execution unless a caller
// explicitly opts into a different file-parallelism mode.
...(hasFileParallelismOverride ? [] : ['--no-file-parallelism']),
`--coverage.reportsDirectory=${runCoverageDir}`,
...forwardedArgs,
]
let output = ''
const exitCode = await new Promise((resolveExit, rejectExit) => {
const child = spawn(command, commandArgs, {
cwd: rootDir,
env: {
...process.env,
VITEST_COVERAGE_DIR: runCoverageDir,
},
stdio: ['inherit', 'pipe', 'pipe'],
})
const handleOutput = (stream, target) => {
if (!stream) return
stream.setEncoding('utf8')
stream.on('data', (chunk) => {
target.write(chunk)
output = appendCapturedOutput(output, chunk)
})
}
handleOutput(child.stdout, process.stdout)
handleOutput(child.stderr, process.stderr)
child.on('error', rejectExit)
child.on('exit', (code, signal) => {
if (signal) {
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
return
}
resolveExit(code ?? 1)
})
})
return {
exitCode,
output,
runCoverageDir,
}
}
let finalRun = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const run = await runCoverageAttempt(attempt)
finalRun = run
if (run.exitCode === 0) {
await rm(finalCoverageDir, { recursive: true, force: true })
await cp(run.runCoverageDir, finalCoverageDir, {
force: true,
recursive: true,
})
await rm(run.runCoverageDir, { recursive: true, force: true })
process.exit(0)
}
// Retry once when Vitest itself flakes after a fully passing suite.
if (attempt < maxAttempts && isKnownVitestInternalStateFlake(run.output)) {
console.error(`Vitest hit a known internal-state teardown flake on attempt ${attempt}; retrying once...`)
await rm(run.runCoverageDir, { recursive: true, force: true })
continue
}
break
}
console.error(`Vitest coverage artifacts preserved at ${finalRun.runCoverageDir}`)
process.exit(finalRun.exitCode)

View File

@@ -37,6 +37,6 @@ tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "4.0.4"
sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
[dev-dependencies]
tempfile = "3"

View File

@@ -246,7 +246,6 @@ fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, Strin
Ok(vec![
"exec".into(),
"--json".into(),
"--dangerously-bypass-approvals-and-sandbox".into(),
"-C".into(),
request.vault_path.clone(),
"-c".into(),
@@ -377,8 +376,8 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
crate::claude_cli::ClaudeStreamEvent::Error { message } => {
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done
| crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
@@ -406,6 +405,20 @@ mod tests {
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn build_codex_args_uses_safe_default_permissions() {
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();
@@ -460,4 +473,11 @@ mod tests {
AiAgentStreamEvent::TextDelta { text } if text == "All set"
));
}
#[test]
fn map_claude_done_event_preserves_completion_signal() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,14 +1,15 @@
use crate::vault;
use super::expand_tilde;
use super::vault::VaultBoundary;
#[tauri::command]
pub async fn batch_delete_notes_async(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths
.iter()
.map(|path| expand_tilde(path).into_owned())
.collect();
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&expanded))
pub async fn batch_delete_notes_async(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<Vec<String>, String> {
let boundary = VaultBoundary::from_request(vault_path.as_deref())?;
let validated_paths = boundary.validate_existing_paths(&paths)?;
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&validated_paths))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}

View File

@@ -0,0 +1,23 @@
use crate::vault::{self, FolderRenameResult};
use super::expand_tilde;
#[tauri::command]
pub fn rename_vault_folder(
vault_path: String,
folder_path: String,
new_name: String,
) -> Result<FolderRenameResult, String> {
let vault_path = expand_tilde(&vault_path);
vault::rename_folder(
std::path::Path::new(vault_path.as_ref()),
&folder_path,
&new_name,
)
}
#[tauri::command]
pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path)
}

View File

@@ -1,5 +1,6 @@
mod ai;
mod delete;
mod folders;
mod git;
mod git_connect;
mod system;
@@ -10,6 +11,7 @@ use std::borrow::Cow;
pub use ai::*;
pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use system::*;

View File

@@ -26,8 +26,17 @@ pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
pub async fn remove_mcp_tools() -> Result<String, String> {
tokio::task::spawn_blocking(crate::mcp::remove_mcp)
.await
.map_err(|e| format!("Removal task failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatus, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::check_mcp_status(&vault_path))
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
@@ -42,7 +51,13 @@ pub async fn register_mcp_tools(_vault_path: String) -> Result<String, String> {
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
pub async fn remove_mcp_tools() -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStatus, String> {
Ok(crate::mcp::McpStatus::NotInstalled)
}
@@ -56,6 +71,7 @@ pub struct MenuStateUpdate {
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
note_list_search_enabled: Option<bool>,
}
#[cfg(desktop)]
@@ -77,6 +93,9 @@ pub fn update_menu_state(
if let Some(v) = state.has_no_remote {
menu::set_git_no_remote_items_enabled(&app_handle, v);
}
if let Some(v) = state.note_list_search_enabled {
menu::set_note_list_search_items_enabled(&app_handle, v);
}
Ok(())
}

View File

@@ -1,321 +1,36 @@
use crate::frontmatter::FrontmatterValue;
use crate::search::SearchResponse;
use crate::vault::{
DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile,
};
use crate::{frontmatter, git, search, vault};
mod boundary;
mod file_cmds;
mod frontmatter_cmds;
mod lifecycle_cmds;
mod rename_cmds;
mod scan_cmds;
mod view_cmds;
use super::expand_tilde;
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn list_vault_folders(path: String) -> Result<Vec<FolderNode>, String> {
let path = expand_tilde(&path);
vault::scan_vault_folders(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::auto_rename_untitled(&vault_path, &note_path)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths.iter().map(|p| expand_tilde(p).into_owned()).collect();
vault::batch_delete_notes(&expanded)
}
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let folder_path =
build_vault_folder_path(std::path::Path::new(vault_path.as_ref()), &folder_name);
ensure_missing_folder(&folder_path, &folder_name)?;
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
}
fn build_vault_folder_path(vault_root: &std::path::Path, folder_name: &str) -> std::path::PathBuf {
vault_root.join(folder_name)
}
fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = std::path::Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
vault::create_getting_started_vault(&path)
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(std::path::Path::new(&path));
vault::scan_vault_cached(std::path::Path::new(&path))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
let path = expand_tilde(&path);
vault::reload_entry(std::path::Path::new(path.as_ref()))
}
/// 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]
pub fn sync_note_title(path: String) -> Result<bool, String> {
use vault::SyncAction;
let path = expand_tilde(&path);
let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?;
Ok(matches!(action, SyncAction::Updated { .. }))
}
#[tauri::command]
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
// ── View commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_views(vault_path: String) -> Vec<ViewFile> {
let path = expand_tilde(&vault_path);
vault::scan_views(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::delete_view(std::path::Path::new(path.as_ref()), &filename)
}
// ── Frontmatter commands ────────────────────────────────────────────────────
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
// ── Search commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
// ── Repair command ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}
pub(super) use boundary::VaultBoundary;
pub use file_cmds::*;
pub use frontmatter_cmds::*;
pub use lifecycle_cmds::*;
pub use rename_cmds::*;
pub use scan_cmds::*;
pub use view_cmds::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::ViewDefinition;
use std::path::Path;
const ACTIVE_VAULT_PATH_ERROR: &str = super::boundary::ACTIVE_VAULT_PATH_ERROR;
const INVALID_VIEW_FILENAME_ERROR: &str = super::boundary::INVALID_VIEW_FILENAME_ERROR;
fn vault_path_arg(vault_path: &Path) -> Option<std::path::PathBuf> {
Some(vault_path.to_path_buf())
}
fn vault_path_string_arg(vault_path: &Path) -> Option<String> {
Some(vault_path.to_string_lossy().to_string())
}
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
@@ -356,9 +71,13 @@ mod tests {
#[test]
fn test_batch_archive_notes() {
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
let (dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
assert_eq!(
batch_archive_notes(vec![note.to_str().unwrap().to_string()]).unwrap(),
batch_archive_notes(
vec![note.to_str().unwrap().to_string()],
vault_path_string_arg(dir.path()),
)
.unwrap(),
1
);
let content = std::fs::read_to_string(&note).unwrap();
@@ -372,22 +91,102 @@ mod tests {
let note = dir.path().join("test.md");
std::fs::write(&note, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap();
let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
let entry = reload_vault_entry(note.clone(), vault_path_arg(dir.path())).unwrap();
assert_eq!(entry.title, "Test");
assert_eq!(entry.status, Some("Active".to_string()));
// Modify file on disk
std::fs::write(&note, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap();
let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
let fresh = reload_vault_entry(note, vault_path_arg(dir.path())).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_vault_entry_nonexistent() {
let result = reload_vault_entry("/nonexistent/path.md".to_string());
let result = reload_vault_entry("/nonexistent/path.md".into(), None);
assert!(result.is_err());
}
#[test]
fn test_get_note_content_rejects_path_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let inside = vault_path.join("inside.md");
let outside_dir = tempfile::TempDir::new().unwrap();
let outside = outside_dir.path().join("outside.md");
std::fs::write(&inside, "# Inside\n").unwrap();
std::fs::write(&outside, "# Outside\n").unwrap();
let err = get_note_content(outside, vault_path_arg(vault_path))
.expect_err("expected out-of-vault read to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_save_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = save_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = create_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal create to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_vault_folder_rejects_escape_path() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(dir.path().into(), "../escape".into())
.expect_err("expected escaping folder path to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_save_view_cmd_rejects_nested_filename() {
let dir = tempfile::TempDir::new().unwrap();
let definition = ViewDefinition {
name: "Inbox".to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),
};
let err = save_view_cmd(
dir.path().to_string_lossy().to_string(),
"../escape.yml".to_string(),
definition,
)
.expect_err("expected nested filename to be rejected");
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
}
#[test]
fn test_reload_vault_invalidates_cache_and_rescans() {
let dir = tempfile::TempDir::new().unwrap();
@@ -430,7 +229,7 @@ mod tests {
.output()
.unwrap();
let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap();
let entries = list_vault(vault_path.into()).unwrap();
assert!(!entries[0].archived);
std::fs::write(

View File

@@ -0,0 +1,282 @@
use crate::commands::expand_tilde;
use crate::vault_list;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
pub(crate) const ACTIVE_VAULT_PATH_ERROR: &str = "Path must stay inside the active vault";
const ACTIVE_VAULT_MISMATCH_ERROR: &str = "Vault path must match the active vault";
const ACTIVE_VAULT_UNAVAILABLE_ERROR: &str = "Active vault is not available";
const NO_ACTIVE_VAULT_ERROR: &str = "No active vault selected";
pub(crate) const INVALID_VIEW_FILENAME_ERROR: &str = "Invalid view filename";
#[derive(Clone, Debug)]
struct VaultRootPaths {
requested: PathBuf,
canonical: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct VaultBoundary {
requested_root: PathBuf,
canonical_root: PathBuf,
}
impl VaultBoundary {
pub(crate) fn from_request(requested_vault_path: Option<&str>) -> Result<Self, String> {
let configured_root = if cfg!(test) {
None
} else {
load_configured_active_vault_root()?
};
let requested_root = requested_vault_path
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()?;
let root = match (configured_root, requested_root) {
(Some(configured), Some(requested)) => {
if configured.canonical != requested.canonical {
return Err(ACTIVE_VAULT_MISMATCH_ERROR.to_string());
}
requested
}
(Some(configured), None) => configured,
(None, Some(requested)) => requested,
(None, None) => return Err(NO_ACTIVE_VAULT_ERROR.to_string()),
};
Ok(Self {
requested_root: root.requested,
canonical_root: root.canonical,
})
}
pub(crate) fn requested_root(&self) -> &Path {
&self.requested_root
}
fn requested_root_str(&self) -> String {
path_to_string(&self.requested_root)
}
fn validate_existing_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, false)
}
pub(crate) fn validate_existing_paths(
&self,
raw_paths: &[String],
) -> Result<Vec<String>, String> {
raw_paths
.iter()
.map(|path| self.validate_existing_path(path))
.collect()
}
fn validate_writable_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, true)
}
pub(crate) fn child_path(&self, relative_path: &str) -> Result<PathBuf, String> {
validate_relative_child_path(relative_path)?;
let requested = self.requested_root.join(relative_path);
let canonical = canonicalize_candidate_for_write(&requested)?;
self.ensure_within_root(&canonical)?;
Ok(requested)
}
fn validate_path(&self, raw_path: &str, allow_missing_leaf: bool) -> Result<String, String> {
let requested = self.requested_path(raw_path);
let canonical = if allow_missing_leaf {
canonicalize_candidate_for_write(&requested)?
} else {
requested
.canonicalize()
.map_err(|_| "File does not exist".to_string())?
};
self.ensure_within_root(&canonical)?;
Ok(path_to_string(&requested))
}
fn requested_path(&self, raw_path: &str) -> PathBuf {
let expanded = PathBuf::from(expand_tilde(raw_path).into_owned());
if expanded.is_absolute() {
expanded
} else {
self.requested_root.join(expanded)
}
}
fn ensure_within_root(&self, candidate: &Path) -> Result<(), String> {
candidate
.strip_prefix(&self.canonical_root)
.map(|_| ())
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())
}
}
fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
list.active_vault
.as_deref()
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()
}
fn build_vault_root_paths(raw_vault_path: &str) -> Result<VaultRootPaths, String> {
let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned());
let canonical = requested
.canonicalize()
.map_err(|_| ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string())?;
if !canonical.is_dir() {
return Err(ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string());
}
Ok(VaultRootPaths {
requested,
canonical,
})
}
fn canonicalize_candidate_for_write(path: &Path) -> Result<PathBuf, String> {
let (ancestor, tail) = find_existing_ancestor(path)?;
Ok(tail
.into_iter()
.fold(ancestor, |current, segment| current.join(segment)))
}
fn find_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec<OsString>), String> {
let mut current = path;
let mut tail = Vec::new();
loop {
if current.exists() {
let canonical = current
.canonicalize()
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.reverse();
return Ok((canonical, tail));
}
let file_name = current
.file_name()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.push(file_name.to_os_string());
current = current
.parent()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
}
}
fn validate_relative_child_path(relative_path: &str) -> Result<(), String> {
if relative_path.trim().is_empty() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
let path = Path::new(relative_path);
if path.is_absolute() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
if path.components().any(|component| {
matches!(
component,
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
Ok(())
}
pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
if !filename.ends_with(".yml") {
return Err("Filename must end with .yml".to_string());
}
let path = Path::new(filename);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
}
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub(crate) fn with_boundary<T>(
requested_vault_path: Option<&str>,
action: impl FnOnce(&VaultBoundary) -> Result<T, String>,
) -> Result<T, String> {
let boundary = VaultBoundary::from_request(requested_vault_path)?;
action(&boundary)
}
pub(crate) enum ValidatedPathMode {
Existing,
Writable,
}
pub(crate) fn with_validated_path<T>(
path: &str,
vault_path: Option<&str>,
mode: ValidatedPathMode,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_path = match mode {
ValidatedPathMode::Existing => boundary.validate_existing_path(path)?,
ValidatedPathMode::Writable => boundary.validate_writable_path(path)?,
};
action(&validated_path)
})
}
pub(crate) fn with_existing_paths<T>(
paths: &[String],
vault_path: Option<&str>,
action: impl FnOnce(Vec<String>) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_paths = boundary.validate_existing_paths(paths)?;
action(validated_paths)
})
}
pub(crate) fn with_requested_root<T>(
vault_path: &str,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
action(&requested_root)
})
}
pub(crate) fn with_existing_path_in_requested_vault<T>(
vault_path: &str,
path: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
let validated_path = boundary.validate_existing_path(path)?;
action(&requested_root, &validated_path)
})
}
pub(crate) fn with_view_file<T>(
vault_path: &str,
filename: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
validate_view_filename(filename)?;
let requested_root = boundary.requested_root_str();
action(&requested_root, filename)
})
}

View File

@@ -0,0 +1,170 @@
use crate::commands::expand_tilde;
use crate::vault::{self, FolderNode, VaultEntry};
use std::path::{Path, PathBuf};
use super::boundary::{
with_boundary, with_existing_paths, with_requested_root, with_validated_path, ValidatedPathMode,
};
fn with_note_path<T>(
path: &Path,
vault_path: Option<&Path>,
mode: ValidatedPathMode,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.map(|value| value.to_string_lossy());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
mode,
|validated_path| action(Path::new(validated_path)),
)
}
fn with_expanded_vault_root<T>(
path: &Path,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let expanded = expand_tilde(raw_path.as_ref()).into_owned();
action(Path::new(&expanded))
}
fn with_requested_root_path<T>(
vault_path: &Path,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_requested_root(raw_vault_path.as_ref(), action)
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
vault_path
.as_ref()
.map(|value| value.to_string_lossy())
.as_deref(),
ValidatedPathMode::Writable,
action,
)
}
#[tauri::command]
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
vault::get_note_content,
)
}
#[tauri::command]
pub fn save_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::save_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn create_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::create_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn delete_note(path: PathBuf) -> Result<String, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
None,
ValidatedPathMode::Existing,
vault::delete_note,
)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<PathBuf>) -> Result<Vec<String>, String> {
let raw_paths = paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
with_existing_paths(&raw_paths, None, |validated_paths| {
vault::batch_delete_notes(&validated_paths)
})
}
#[tauri::command]
pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<String, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
let folder_name = folder_name.to_string_lossy();
let folder_path = boundary.child_path(folder_name.as_ref())?;
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
std::fs::create_dir_all(&folder_path)
.map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name.into_owned())
})
}
fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
/// 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]
pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<bool, String> {
use vault::SyncAction;
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| {
let action = vault::sync_title_on_open(validated_path)?;
Ok(matches!(action, SyncAction::Updated { .. }))
},
)
}
#[tauri::command]
pub fn save_image(vault_path: PathBuf, filename: String, data: String) -> Result<String, String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
vault::save_image(requested_root, &filename, &data)
})
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result<String, String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
})
}
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
}

View File

@@ -0,0 +1,48 @@
use crate::frontmatter;
use crate::frontmatter::FrontmatterValue;
use super::boundary::{with_existing_paths, with_validated_path, ValidatedPathMode};
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::update_frontmatter(validated_path, &key, value),
)
}
#[tauri::command]
pub fn delete_frontmatter_property(
path: String,
key: String,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::delete_frontmatter_property(validated_path, &key),
)
}
#[tauri::command]
pub fn batch_archive_notes(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<usize, String> {
with_existing_paths(&paths, vault_path.as_deref(), |validated_paths| {
let mut count = 0;
for path in &validated_paths {
frontmatter::update_frontmatter(path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
})
}

View File

@@ -0,0 +1,90 @@
use crate::commands::expand_tilde;
use crate::{git, vault};
use std::path::Path;
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
fn initialize_empty_vault(vault_dir: &Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
vault::create_getting_started_vault(&path)
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}

View File

@@ -0,0 +1,108 @@
use crate::commands::expand_tilde;
use crate::vault::{self, DetectedRename, RenameResult};
use std::path::Path;
use super::boundary::{
with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
};
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note(
requested_root,
validated_path,
&new_title,
old_title.as_deref(),
)
},
)
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note_filename(requested_root, validated_path, &new_filename_stem)
},
)
}
#[tauri::command]
pub fn move_note_to_folder(
vault_path: String,
old_path: String,
folder_path: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
let trimmed_folder_path = folder_path.trim();
if trimmed_folder_path.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let folder_absolute_path = Path::new(requested_root).join(trimmed_folder_path);
with_validated_path(
folder_absolute_path.to_string_lossy().as_ref(),
Some(&vault_path),
ValidatedPathMode::Existing,
|validated_folder_path| {
let validated_folder = Path::new(validated_folder_path);
if !validated_folder.is_dir() {
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
}
vault::move_note_to_folder(
requested_root,
validated_path,
validated_folder_path,
)
},
)
},
)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
with_existing_path_in_requested_vault(
&vault_path,
&note_path,
|requested_root, validated_path| {
vault::auto_rename_untitled(requested_root, validated_path)
},
)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
}

View File

@@ -0,0 +1,51 @@
use crate::commands::expand_tilde;
use crate::search::SearchResponse;
use crate::vault::VaultEntry;
use crate::{search, vault};
use std::path::{Path, PathBuf};
use super::boundary::{with_validated_path, ValidatedPathMode};
#[tauri::command]
pub fn reload_vault_entry(
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<VaultEntry, String> {
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.as_ref().map(|value| value.to_string_lossy());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::reload_entry(Path::new(validated_path)),
)
}
#[tauri::command]
pub async fn reload_vault(
app_handle: tauri::AppHandle,
path: String,
) -> Result<Vec<crate::vault::VaultEntry>, String> {
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))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}

View File

@@ -0,0 +1,37 @@
use crate::vault::{self, ViewDefinition, ViewFile};
use std::path::Path;
use super::boundary::{with_boundary, with_view_file};
#[tauri::command]
pub fn list_views(vault_path: String) -> Result<Vec<ViewFile>, String> {
with_boundary(Some(vault_path.as_str()), |boundary| {
Ok(vault::scan_views(boundary.requested_root()))
})
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::save_view(Path::new(requested_root), validated_filename, &definition)
},
)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::delete_view(Path::new(requested_root), validated_filename)
},
)
}

View File

@@ -13,6 +13,8 @@ pub mod telemetry;
pub mod vault;
pub mod vault_list;
#[cfg(desktop)]
use std::path::Path;
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
@@ -21,6 +23,9 @@ use std::sync::Mutex;
#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct ActiveAssetScopeRoot(Mutex<Option<std::path::PathBuf>>);
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
@@ -48,12 +53,6 @@ fn run_startup_tasks() {
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and starter type definitions at vault root if missing
vault::seed_config_files(vp_str);
// Register Tolaria MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
Err(e) => log::warn!("MCP registration failed: {e}"),
}
}
#[cfg(desktop)]
@@ -148,17 +147,62 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
) -> Result<(), String> {
use tauri::Manager;
let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"Failed to resolve asset scope for {}: {e}",
vault_path.display()
)
})?;
let scope = app_handle.asset_protocol_scope();
let state: tauri::State<'_, ActiveAssetScopeRoot> = app_handle.state();
let mut active_root = state
.0
.lock()
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
if active_root.as_ref() == Some(&canonical_vault_path) {
return Ok(());
}
scope
.allow_directory(&canonical_vault_path, true)
.map_err(|e| {
format!(
"Failed to allow asset access for {}: {e}",
canonical_vault_path.display()
)
})?;
if let Some(previous_root) = active_root.as_ref() {
if previous_root != &canonical_vault_path {
let _ = scope.forbid_directory(previous_root, true);
}
}
*active_root = Some(canonical_vault_path);
Ok(())
}
macro_rules! app_invoke_handler {
() => {
tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::move_note_to_folder,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
@@ -198,6 +242,8 @@ macro_rules! app_invoke_handler {
commands::batch_delete_notes_async,
commands::migrate_is_a_to_type,
commands::create_vault_folder,
commands::rename_vault_folder,
commands::delete_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::check_for_app_update,
@@ -215,6 +261,7 @@ macro_rules! app_invoke_handler {
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::repair_vault,
commands::reinit_telemetry,
@@ -248,7 +295,9 @@ pub fn run() {
let builder = tauri::Builder::default();
#[cfg(desktop)]
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoot(Mutex::new(None)));
with_invoke_handler(builder)
.setup(setup_app)

View File

@@ -11,10 +11,8 @@ const LEGACY_MCP_SERVER_NAME: &str = "laputa";
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing but can be installed.
/// MCP server files or config are missing for the active vault.
NotInstalled,
/// Claude CLI is not installed — must install that first.
NoClaudeCli,
}
/// Find the `node` binary path at runtime.
@@ -116,8 +114,14 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
Ok(child)
}
fn claude_mcp_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json"))
fn mcp_config_paths() -> Vec<PathBuf> {
[
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json")),
dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect()
}
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
@@ -142,6 +146,21 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
};
let Ok(expected) = std::fs::canonicalize(vault_path) else {
return false;
};
let Ok(actual) = std::fs::canonicalize(entry_vault_path) else {
return false;
};
actual == expected
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
@@ -172,15 +191,7 @@ pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let entry = build_mcp_entry(&index_js, vault_path);
let configs: Vec<PathBuf> = [
dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")),
dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect();
Ok(register_mcp_to_configs(&entry, &configs))
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
/// Insert or update the Tolaria entry in an MCP config file.
@@ -222,29 +233,79 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
Ok(was_update)
}
fn remove_mcp_from_configs(config_paths: &[PathBuf]) -> String {
let mut removed_any = false;
for config_path in config_paths {
match remove_mcp_from_config(config_path) {
Ok(true) => removed_any = true,
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
if removed_any {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
if !config_path.exists() {
return Ok(false);
}
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
let mut config: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?;
let Some(config_object) = config.as_object_mut() else {
return Err("Config is not a JSON object".into());
};
let Some(servers_value) = config_object.get_mut("mcpServers") else {
return Ok(false);
};
let Some(servers) = servers_value.as_object_mut() else {
return Err("mcpServers is not a JSON object".into());
};
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
if !removed_primary && !removed_legacy {
return Ok(false);
}
if servers.is_empty() {
config_object.remove("mcpServers");
}
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(true)
}
pub fn remove_mcp() -> String {
remove_mcp_from_configs(&mcp_config_paths())
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the Tolaria entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
// Check Claude CLI first — no point installing MCP if Claude isn't available
if crate::claude_cli::find_claude_binary().is_err() {
return McpStatus::NoClaudeCli;
}
let Some(config_path) = claude_mcp_config_path() else {
return McpStatus::NotInstalled;
};
if !config_path.exists() {
return McpStatus::NotInstalled;
}
let Some(entry) = read_registered_mcp_entry(&config_path) else {
return McpStatus::NotInstalled;
};
if entry_index_js_exists(&entry) {
/// Returns `Installed` when the Tolaria entry exists for the active vault in
/// Claude Code or Cursor config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_index_js_exists(&entry) && entry_targets_vault(&entry, active_vault_path)
})
}) {
McpStatus::Installed
} else {
McpStatus::NotInstalled
@@ -260,6 +321,12 @@ mod tests {
serde_json::from_str(&raw).unwrap()
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
index_js
}
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
@@ -566,18 +633,79 @@ mod tests {
}
#[test]
fn check_mcp_status_returns_valid_variant() {
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
// On CI without Claude it might be NoClaudeCli. Either way it must not panic.
let status = check_mcp_status();
assert!(
matches!(
status,
McpStatus::Installed | McpStatus::NotInstalled | McpStatus::NoClaudeCli
),
"unexpected status: {:?}",
status
);
fn remove_mcp_from_config_removes_primary_and_legacy_entries() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": { "command": "node", "args": ["/index.js"] },
"laputa": { "command": "node", "args": ["/legacy.js"] },
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(removed);
let updated = read_config(&config_path);
assert!(updated["mcpServers"][MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"]["other-server"].is_object());
}
#[test]
fn remove_mcp_from_config_returns_false_when_entry_missing() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(!removed);
}
#[test]
fn check_mcp_status_returns_installed_for_matching_vault() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("vault");
std::fs::create_dir_all(&vault_path).unwrap();
let index_js = write_index_js(tmp.path());
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path.to_string_lossy() }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert!(entry_targets_vault(&entry, &vault_path));
assert!(entry_index_js_exists(&entry));
}
#[test]
fn entry_targets_vault_requires_matching_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
let first_vault = tmp.path().join("vault-a");
let second_vault = tmp.path().join("vault-b");
std::fs::create_dir_all(&first_vault).unwrap();
std::fs::create_dir_all(&second_vault).unwrap();
let entry = serde_json::json!({
"env": { "VAULT_PATH": first_vault.to_string_lossy() }
});
assert!(entry_targets_vault(&entry, &first_vault));
assert!(!entry_targets_vault(&entry, &second_vault));
}
#[test]
@@ -586,7 +714,5 @@ mod tests {
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
let json = serde_json::to_string(&McpStatus::NoClaudeCli).unwrap();
assert_eq!(json, r#""no_claude_cli""#);
}
}

View File

@@ -14,6 +14,7 @@ const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
const FILE_SAVE: &str = "file-save";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
@@ -62,6 +63,7 @@ const CUSTOM_IDS: &[&str] = &[
FILE_QUICK_OPEN_ALIAS,
FILE_SAVE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
@@ -110,6 +112,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
NOTE_OPEN_IN_NEW_WINDOW,
];
/// IDs of menu items that depend on the note list being the active surface.
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
/// IDs of menu items that depend on a deleted-note preview being active.
const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED];
@@ -185,6 +190,11 @@ fn build_edit_menu(app: &App) -> MenuResult {
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
.build(app)?;
let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search")
.id(EDIT_TOGGLE_NOTE_LIST_SEARCH)
.accelerator("CmdOrCtrl+F")
.enabled(false)
.build(app)?;
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
.id(EDIT_TOGGLE_DIFF)
.build(app)?;
@@ -200,6 +210,7 @@ fn build_edit_menu(app: &App) -> MenuResult {
.select_all()
.separator()
.item(&find_in_vault)
.item(&toggle_note_list_search)
.item(&toggle_diff)
.build()?)
}
@@ -356,7 +367,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
let install_mcp = MenuItemBuilder::new("Set Up External AI Tools…")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")
@@ -452,6 +463,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on the note list being the active surface.
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
@@ -486,6 +502,7 @@ mod tests {
FILE_QUICK_OPEN,
FILE_SAVE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
@@ -532,6 +549,16 @@ mod tests {
}
}
#[test]
fn note_list_search_dependent_ids_are_subset_of_custom_ids() {
for id in NOTE_LIST_SEARCH_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"note-list-search-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn git_dependent_ids_are_subset_of_custom_ids() {
for id in GIT_COMMIT_DEPENDENT_IDS {

View File

@@ -11,6 +11,39 @@ fn scrub_paths(input: &str) -> String {
re.replace_all(input, "<redacted-path>").to_string()
}
fn normalize_embedded_env(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
if value.is_empty() {
return None;
}
let unwrapped = match (value.chars().next(), value.chars().last()) {
(Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => {
value[1..value.len() - 1].trim()
}
_ => value,
};
if unwrapped.is_empty() {
None
} else {
Some(unwrapped.to_string())
}
}
fn normalize_http_like_value(value: &str) -> String {
if value.contains("://") {
value.to_string()
} else {
format!("https://{value}")
}
}
fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
let normalized = normalize_embedded_env(raw)?;
normalize_http_like_value(&normalized).parse().ok()
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -23,14 +56,13 @@ pub fn init_sentry_from_settings() -> bool {
return false;
}
let dsn = option_env!("SENTRY_DSN").unwrap_or_default();
if dsn.is_empty() {
let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else {
return false;
}
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let guard = sentry::init(sentry::ClientOptions {
dsn: dsn.parse().ok(),
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
@@ -86,6 +118,43 @@ mod tests {
assert_eq!(scrub_paths("Normal error message"), "Normal error message");
}
#[test]
fn test_normalize_embedded_env_trims_wrapping_quotes() {
assert_eq!(
normalize_embedded_env(Some(" \"value\" ")).as_deref(),
Some("value")
);
assert_eq!(
normalize_embedded_env(Some(" 'value' ")).as_deref(),
Some("value")
);
}
#[test]
fn test_normalize_embedded_env_drops_blank_values() {
assert_eq!(normalize_embedded_env(Some(" ")), None);
assert_eq!(normalize_embedded_env(None), None);
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() {
let parsed =
parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" "));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() {
let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1"));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_rejects_invalid_value() {
let parsed = parse_embedded_sentry_dsn(Some("not a dsn"));
assert!(parsed.is_none());
}
#[test]
fn test_init_sentry_returns_false_without_dsn() {
// Without SENTRY_DSN env var set at compile time, init should return false

View File

@@ -1,4 +1,5 @@
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -63,3 +64,25 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
}
/// Create a new note file without overwriting any existing file.
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
validate_save_path(file_path, path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => format!("Failed to create {}: {}", path, e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to save {}: {}", path, e))
}

View File

@@ -0,0 +1,183 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FolderRenameResult {
pub old_path: String,
pub new_path: String,
}
fn normalize_folder_name(next_name: &str) -> Result<String, String> {
let trimmed = next_name.trim();
if trimmed.is_empty() {
return Err("Folder name cannot be empty".to_string());
}
if trimmed == "." || trimmed == ".." || trimmed.contains('/') || trimmed.contains('\\') {
return Err("Invalid folder name".to_string());
}
Ok(trimmed.to_string())
}
fn ensure_relative_folder_path(folder_path: &str) -> Result<PathBuf, String> {
let trimmed = folder_path.trim();
if trimmed.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let relative = Path::new(trimmed);
if relative.is_absolute() {
return Err("Folder path must be relative to the vault root".to_string());
}
if relative
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err("Folder path cannot escape the vault root".to_string());
}
Ok(relative.to_path_buf())
}
fn display_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub fn rename_folder(
vault_path: &Path,
folder_path: &str,
next_name: &str,
) -> Result<FolderRenameResult, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let normalized_name = normalize_folder_name(next_name)?;
let source_path = vault_path.join(&relative_path);
if !source_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !source_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
let current_name = source_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.ok_or_else(|| "Folder path cannot target the vault root".to_string())?;
if current_name == normalized_name {
return Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&relative_path),
});
}
let parent_relative = relative_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let destination_relative = parent_relative.join(&normalized_name);
let destination_path = vault_path.join(&destination_relative);
if destination_path.exists() {
return Err(format!(
"Folder '{}' already exists",
display_relative_path(&destination_relative)
));
}
fs::rename(&source_path, &destination_path)
.map_err(|error| format!("Failed to rename folder: {}", error))?;
Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&destination_relative),
})
}
pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result<String, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let target_path = vault_path.join(&relative_path);
if !target_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !target_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
fs::remove_dir_all(&target_path)
.map_err(|error| format!("Failed to delete folder: {}", error))?;
Ok(display_relative_path(&relative_path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_folder(dir: &TempDir, relative: &str) -> PathBuf {
let path = dir.path().join(relative);
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn rename_folder_updates_relative_destination() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects/laputa");
let result = rename_folder(dir.path(), "projects", "work").unwrap();
assert_eq!(
result,
FolderRenameResult {
old_path: "projects".to_string(),
new_path: "work".to_string(),
}
);
assert!(dir.path().join("work/laputa").is_dir());
assert!(!dir.path().join("projects").exists());
}
#[test]
fn rename_folder_rejects_duplicate_sibling() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
make_folder(&dir, "areas");
let error = rename_folder(dir.path(), "projects", "areas").unwrap_err();
assert_eq!(error, "Folder 'areas' already exists");
}
#[test]
fn rename_folder_rejects_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn delete_folder_removes_nested_contents() {
let dir = TempDir::new().unwrap();
let nested = make_folder(&dir, "projects/laputa");
fs::write(nested.join("note.md"), "# Note\n").unwrap();
let deleted_path = delete_folder(dir.path(), "projects").unwrap();
assert_eq!(deleted_path, "projects");
assert!(!dir.path().join("projects").exists());
}
#[test]
fn delete_folder_rejects_missing_folder() {
let dir = TempDir::new().unwrap();
let error = delete_folder(dir.path(), "projects").unwrap_err();
assert_eq!(error, "Folder does not exist: projects");
}
}

View File

@@ -13,9 +13,45 @@ pub fn default_vault_path() -> Result<PathBuf, String> {
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
const GETTING_STARTED_REQUIRED_CONFIG_FILES: [&str; 2] = ["type.md", "note.md"];
const GETTING_STARTED_TEMPLATE_MARKERS: [&str; 2] = ["welcome.md", "views/active-projects.yml"];
/// Check whether a vault path exists on disk.
pub fn vault_exists(path: &str) -> bool {
Path::new(path).is_dir()
let default_path = default_vault_path().ok();
vault_exists_with_default_path(Path::new(path), default_path.as_deref())
}
fn vault_exists_with_default_path(path: &Path, default_path: Option<&Path>) -> bool {
if !path.is_dir() {
return false;
}
if !is_canonical_getting_started_path(path, default_path) {
return true;
}
canonical_getting_started_vault_exists(path)
}
fn is_canonical_getting_started_path(path: &Path, default_path: Option<&Path>) -> bool {
default_path.is_some_and(|candidate| candidate == path)
}
fn canonical_getting_started_vault_exists(path: &Path) -> bool {
has_getting_started_config_files(path) && has_getting_started_template_marker(path)
}
fn has_getting_started_config_files(path: &Path) -> bool {
GETTING_STARTED_REQUIRED_CONFIG_FILES
.iter()
.all(|file| path.join(file).is_file())
}
fn has_getting_started_template_marker(path: &Path) -> bool {
GETTING_STARTED_TEMPLATE_MARKERS
.iter()
.any(|file| path.join(file).is_file())
}
/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults
@@ -640,6 +676,13 @@ mod tests {
.unwrap();
}
fn write_tolaria_config_files(path: &Path) {
fs::create_dir_all(path).unwrap();
fs::write(path.join("AGENTS.md"), AGENTS_MD).unwrap();
fs::write(path.join("type.md"), "# Type\n").unwrap();
fs::write(path.join("note.md"), "# Note\n").unwrap();
}
fn assert_getting_started_vault_replaces_template(agents_content: &str) {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
@@ -669,6 +712,33 @@ mod tests {
);
}
#[test]
fn test_canonical_getting_started_path_rejects_plain_tolaria_folder() {
let dir = tempfile::TempDir::new().unwrap();
let default_path = dir.path().join("Getting Started");
write_tolaria_config_files(&default_path);
assert!(!vault_exists_with_default_path(
default_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_non_canonical_vault_path_stays_permissive() {
let dir = tempfile::TempDir::new().unwrap();
let default_path = dir.path().join("Getting Started");
let other_vault_path = dir.path().join("Existing Vault");
fs::create_dir_all(&other_vault_path).unwrap();
assert!(vault_exists_with_default_path(
other_vault_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_create_getting_started_vault_clones_repo() {
let dir = tempfile::TempDir::new().unwrap();
@@ -692,6 +762,22 @@ mod tests {
assert!(dest.join("note.md").exists());
}
#[test]
fn test_canonical_getting_started_path_accepts_cloned_starter_vault() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let default_path = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(default_path.as_path(), source.to_str().unwrap())
.unwrap();
assert!(vault_exists_with_default_path(
default_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_create_getting_started_vault_rejects_nonempty_destination() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -2,12 +2,14 @@ mod cache;
mod config_seed;
mod entry;
mod file;
mod folders;
mod frontmatter;
mod getting_started;
mod image;
mod migration;
mod parsing;
mod rename;
mod rename_transaction;
mod title_sync;
mod trash;
mod views;
@@ -18,12 +20,13 @@ pub use config_seed::{
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
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 image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note, rename_note_filename,
update_wikilinks_for_renames, DetectedRename, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
@@ -413,6 +416,14 @@ pub fn scan_vault(
));
}
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
log::warn!(
"Failed to recover pending rename transactions in {}: {}",
vault_path.display(),
err
);
}
let mut entries = Vec::new();
scan_all_files(vault_path, git_dates, &mut entries);

View File

@@ -28,6 +28,8 @@ mod complex_frontmatter;
mod display_metadata;
#[path = "mod_tests/folder_and_file_kind.rs"]
mod folder_and_file_kind;
#[path = "mod_tests/journal_type_visibility.rs"]
mod journal_type_visibility;
#[path = "mod_tests/real_vault_consistency.rs"]
mod real_vault_consistency;
#[path = "mod_tests/relationships.rs"]

View File

@@ -0,0 +1,38 @@
use super::*;
use std::collections::HashMap;
#[test]
fn test_scan_vault_preserves_explicit_journal_type_definition() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"journal.md",
"---\ntype: Type\nvisible: true\n---\n# Journal\n",
);
create_test_file(
dir.path(),
"2026-03-11.md",
"---\ntitle: March 11\ntype: Journal\n---\n# March 11\n",
);
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
assert_eq!(entries.len(), 2);
let journal_type = entries
.iter()
.find(|entry| entry.filename == "journal.md")
.expect("expected the explicit Journal type file to be scanned");
assert_eq!(journal_type.title, "Journal");
assert_eq!(journal_type.is_a.as_deref(), Some("Type"));
assert_eq!(journal_type.visible, Some(true));
let journal_note = entries
.iter()
.find(|entry| entry.filename == "2026-03-11.md")
.expect("expected the Journal note to be scanned");
assert_eq!(journal_note.is_a.as_deref(), Some("Journal"));
assert_eq!(
journal_note.relationships.get("Type"),
Some(&vec!["[[journal]]".to_string()]),
);
}

View File

@@ -165,3 +165,29 @@ fn test_save_note_content_deeply_nested_new_directory() {
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("new-type/briefing.md");
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
assert!(!path.parent().unwrap().exists());
create_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_rejects_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("briefing.md");
fs::write(&path, "# Existing\n").unwrap();
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
.expect_err("expected create-only write to reject collisions");
assert!(err.contains("already exists"));
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
}

View File

@@ -3,8 +3,10 @@ use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use tempfile::NamedTempFile;
use walkdir::WalkDir;
use super::rename_transaction::RenameWorkspace;
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
/// Result of a rename operation
@@ -14,6 +16,14 @@ pub struct RenameResult {
pub new_path: String,
/// Number of other files updated (wiki link replacements)
pub updated_files: usize,
/// Number of linked-note rewrites that failed and need manual attention
pub failed_updates: usize,
}
#[derive(Debug, Default)]
struct WikilinkUpdateSummary {
updated_files: usize,
failed_updates: usize,
}
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
@@ -95,10 +105,10 @@ fn update_wikilinks_in_vault(
old_targets: &[&str],
new_target: &str,
exclude_path: &Path,
) -> usize {
) -> WikilinkUpdateSummary {
let re = match build_wikilink_pattern(old_targets) {
Some(r) => r,
None => return 0,
None => return WikilinkUpdateSummary::default(),
};
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
}
@@ -107,23 +117,29 @@ fn replace_wikilinks_in_files(
files: Vec<std::path::PathBuf>,
re: &Regex,
replacement: &str,
) -> usize {
files
.iter()
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
.count()
) -> WikilinkUpdateSummary {
let mut summary = WikilinkUpdateSummary::default();
for path in files.iter() {
match rewrite_wikilinks_in_file(path, re, replacement) {
Ok(true) => summary.updated_files += 1,
Ok(false) => {}
Err(_) => summary.failed_updates += 1,
}
}
summary
}
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> Result<bool, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
return false;
return Ok(false);
};
fs::write(path, &new_content).is_ok()
fs::write(path, &new_content)
.map(|_| true)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))
}
/// Extract the value of the `title:` frontmatter field from raw content.
@@ -168,28 +184,26 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
.unwrap_or(abs_path)
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
/// `exclude` is the source file being renamed — it should not be treated as a collision.
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
if !dest.exists() || dest == exclude {
return dest;
}
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let mut counter = 2;
loop {
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() || candidate == exclude {
return candidate;
}
counter += 1;
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
super::rename_transaction::recover_pending_rename_transactions(vault)
}
fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(), String> {
staged
.persist(target_path)
.map(|_| ())
.map_err(|e| format!("Failed to replace {}: {}", target_path.display(), e.error))
}
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
let new_path = new_file.to_string_lossy().to_string();
let vault_prefix = format!("{}/", vault.to_string_lossy());
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
RenameResult {
new_path,
updated_files: summary.updated_files,
failed_updates: summary.failed_updates,
}
}
@@ -209,6 +223,66 @@ fn is_invalid_filename_stem(stem: &str) -> bool {
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
}
struct LoadedNote {
content: String,
filename: String,
title: String,
}
fn unchanged_result(path: &str) -> RenameResult {
RenameResult {
new_path: path.to_string(),
updated_files: 0,
failed_updates: 0,
}
}
fn validate_new_title(new_title: &str) -> Result<&str, String> {
let trimmed = new_title.trim();
if trimmed.is_empty() {
return Err("New title cannot be empty".to_string());
}
Ok(trimmed)
}
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
if old_file.exists() {
return Ok(());
}
Err(format!("File does not exist: {}", old_path))
}
fn load_note_for_title_rename(
old_file: &Path,
old_path: &str,
old_title_hint: Option<&str>,
) -> Result<LoadedNote, String> {
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let fm_title = extract_fm_title_value(&content);
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &filename);
Ok(LoadedNote {
content,
filename,
title: old_title_hint.unwrap_or(&extracted_title).to_string(),
})
}
fn persist_title_only_update(
workspace: &RenameWorkspace,
old_file: &Path,
updated_content: &str,
old_path: &str,
) -> Result<RenameResult, String> {
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
Ok(unchanged_result(old_path))
}
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
///
/// When `old_title_hint` is provided it is used instead of extracting the title from
@@ -223,69 +297,47 @@ pub fn rename_note(
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
}
let new_title = new_title.trim();
if new_title.is_empty() {
return Err("New title cannot be empty".to_string());
}
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let fm_title = extract_fm_title_value(&content);
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let old_title = old_title_hint.unwrap_or(&extracted_title);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
let new_title = validate_new_title(new_title)?;
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
// Check both title and filename: even if the title in content matches,
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
let expected_filename = format!("{}.md", title_to_slug(new_title));
let title_unchanged = old_title == new_title;
let filename_matches = old_filename == expected_filename;
let title_unchanged = loaded.title == new_title;
let filename_matches = loaded.filename == expected_filename;
if title_unchanged && filename_matches {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
return Ok(unchanged_result(old_path));
}
// Update content only if the title actually changed
let updated_content = if title_unchanged {
content.clone()
loaded.content.clone()
} else {
update_note_title_in_content(&content, new_title)
update_note_title_in_content(&loaded.content, new_title)
};
let workspace = RenameWorkspace::new(vault)?;
if filename_matches {
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
}
// Compute new path, handling collisions with numeric suffix
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
if old_file != new_file {
fs::remove_file(old_file)
.map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?;
}
// Update wikilinks across the vault
let committed = workspace
.operation(old_path, old_file)
.rename_with_candidates(
workspace.stage_note_content(&updated_content)?,
&expected_filename,
parent_dir,
)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(RenameResult {
new_path: new_path_str,
updated_files,
})
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Rename only the file path stem while preserving title/frontmatter content.
@@ -297,6 +349,8 @@ pub fn rename_note_filename(
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
recover_pending_rename_transactions(vault)?;
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
}
@@ -313,40 +367,73 @@ pub fn rename_note_filename(
let new_filename = format!("{}.md", normalized_stem);
if old_filename == new_filename {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
return Ok(unchanged_result(old_path));
}
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = parent_dir.join(&new_filename);
if new_file.exists() && new_file != old_file {
return Err("A note with that name already exists".to_string());
}
fs::rename(old_file, &new_file).map_err(|e| {
format!(
"Failed to rename {} to {}: {}",
old_path,
new_file.to_string_lossy(),
e
)
})?;
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let new_path = new_file.to_string_lossy().to_string();
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
Ok(RenameResult {
new_path,
updated_files,
})
/// Move a note into a different folder while preserving its filename and content.
pub fn move_note_to_folder(
vault_path: &str,
old_path: &str,
destination_folder_path: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
let destination_dir = Path::new(destination_folder_path);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
if !destination_dir.exists() {
return Err(format!(
"Folder does not exist: {}",
destination_folder_path
));
}
if !destination_dir.is_dir() {
return Err(format!(
"Folder is not a directory: {}",
destination_folder_path
));
}
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_file = destination_dir.join(&old_filename);
if new_file == old_file {
return Ok(unchanged_result(old_path));
}
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
@@ -455,8 +542,8 @@ pub fn update_wikilinks_for_renames(
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
let new_file = vault.join(&rename.new_path);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
total_updated += updated;
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
total_updated += summary.updated_files;
}
Ok(total_updated)
@@ -477,6 +564,29 @@ mod tests {
file.write_all(content.as_bytes()).unwrap();
}
struct RenameTestRequest<'a> {
path: &'a str,
content: &'a str,
new_title: &'a str,
old_title_hint: Option<&'a str>,
}
fn rename_test_note_file(
vault: &Path,
request: RenameTestRequest<'_>,
) -> (std::path::PathBuf, RenameResult) {
create_test_file(vault, request.path, request.content);
let old_path = vault.join(request.path);
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
request.new_title,
request.old_title_hint,
)
.unwrap();
(old_path, result)
}
#[test]
fn test_title_to_slug() {
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
@@ -552,25 +662,6 @@ mod tests {
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
fn test_rename_note_same_title_noop() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
let old_path = vault.join("note/my-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
None,
)
.unwrap();
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_rename_note_empty_title_error() {
let dir = TempDir::new().unwrap();
@@ -588,54 +679,81 @@ mod tests {
}
#[test]
fn test_rename_note_preserves_pipe_alias_in_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
);
fn test_rename_note_noop_variants() {
for old_title_hint in [None, Some("My Note")] {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let (old_path, result) = rename_test_note_file(
vault,
RenameTestRequest {
path: "note/my-note.md",
content: "# My Note\n\nContent.\n",
new_title: "My Note",
old_title_hint,
},
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
assert_eq!(result.failed_updates, 0);
}
}
#[test]
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[weekly-review]] for info.\n",
);
fn test_rename_note_updates_legacy_wikilink_targets() {
struct WikilinkRewriteCase<'a> {
ref_content: &'a str,
note_content: &'a str,
new_title: &'a str,
expected_link: &'a str,
removed_link: Option<&'a str>,
}
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
let cases = [
WikilinkRewriteCase {
ref_content: "# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
note_content: "# Weekly Review\n",
new_title: "Sprint Retro",
expected_link: "[[note/sprint-retro|my review]]",
removed_link: None,
},
WikilinkRewriteCase {
ref_content: "# Ref\n\nSee [[weekly-review]] for info.\n",
note_content: "# Weekly Review\n",
new_title: "Sprint Retro",
expected_link: "[[note/sprint-retro]]",
removed_link: Some("[[weekly-review]]"),
},
WikilinkRewriteCase {
ref_content: "See [[Weekly Review]] for details.\n",
note_content: "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
new_title: "Sprint Retrospective",
expected_link: "[[note/sprint-retrospective]]",
removed_link: Some("[[Weekly Review]]"),
},
];
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/sprint-retro]]"));
assert!(!ref_content.contains("[[weekly-review]]"));
for case in cases {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/ref.md", case.ref_content);
let (_old_path, result) = rename_test_note_file(
vault,
RenameTestRequest {
path: "note/weekly-review.md",
content: case.note_content,
new_title: case.new_title,
old_title_hint: None,
},
);
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains(case.expected_link));
if let Some(removed_link) = case.removed_link {
assert!(!ref_content.contains(removed_link));
}
}
}
#[test]
@@ -864,6 +982,72 @@ mod tests {
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_move_note_to_folder_preserves_filename_and_updates_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"projects/weekly-review.md",
"---\ntitle: Weekly Review\n---\n# Weekly Review\nBody\n",
);
create_test_file(
vault,
"areas/linked.md",
"Reference [[projects/weekly-review]]\n",
);
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
)
.expect("move should succeed");
assert!(result.new_path.ends_with("areas/weekly-review.md"));
assert!(!vault.join("projects/weekly-review.md").exists());
assert!(vault.join("areas/weekly-review.md").exists());
assert_eq!(
fs::read_to_string(vault.join("areas/linked.md")).unwrap(),
"Reference [[areas/weekly-review]]\n"
);
}
#[test]
fn test_move_note_to_folder_noop_when_destination_matches_current_parent() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
let source = vault.join("projects/weekly-review.md");
let result = move_note_to_folder(
vault.to_str().unwrap(),
source.to_str().unwrap(),
vault.join("projects").to_str().unwrap(),
)
.expect("move should noop");
assert_eq!(result.new_path, source.to_string_lossy());
assert!(source.exists());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_move_note_to_folder_rejects_existing_destination() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
);
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
@@ -909,36 +1093,6 @@ mod tests {
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
fn test_rename_note_without_hint_backward_compatible() {
// Existing behavior: no hint, extracts title from H1
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"note/other.md",
"See [[Weekly Review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
fn test_rename_note_does_not_modify_h1() {
// H1 is body content — rename should only update frontmatter title, not H1
@@ -975,22 +1129,53 @@ mod tests {
}
#[test]
fn test_rename_note_hint_same_as_new_title_noop() {
// If old_title_hint == new_title, should be a noop
fn test_replace_wikilinks_in_files_reports_failed_updates() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
create_test_file(vault, "note/ref.md", "See [[Old Note]] for details.\n");
let old_path = vault.join("note/my-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
Some("My Note"),
let pattern = build_wikilink_pattern(&["Old Note"]).unwrap();
let summary = replace_wikilinks_in_files(
vec![vault.join("note/ref.md"), vault.join("note/missing.md")],
&pattern,
"note/new-note",
);
assert_eq!(summary.updated_files, 1);
assert_eq!(summary.failed_updates, 1);
}
#[test]
fn test_recover_pending_rename_transactions_restores_backup_when_new_file_is_missing() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let old_path = vault.join("note/original.md");
let new_path = vault.join("note/renamed.md");
create_test_file(vault, "note/original.md", "# Original\n");
let txn_dir = vault.join(".tolaria-rename-txn");
fs::create_dir_all(&txn_dir).unwrap();
let backup_path = txn_dir.join("rename-backup.bak");
let manifest_path = txn_dir.join("rename-transaction.json");
fs::rename(&old_path, &backup_path).unwrap();
fs::write(
&manifest_path,
serde_json::json!({
"old_path": old_path.to_string_lossy().to_string(),
"new_path": new_path.to_string_lossy().to_string(),
"backup_path": backup_path.to_string_lossy().to_string(),
})
.to_string(),
)
.unwrap();
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
recover_pending_rename_transactions(vault).unwrap();
assert!(old_path.exists());
assert!(!new_path.exists());
assert!(!backup_path.exists());
assert!(!manifest_path.exists());
}
}

View File

@@ -0,0 +1,287 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
struct RenameTransaction {
old_path: String,
new_path: String,
backup_path: String,
}
pub(super) struct RenameWorkspace {
dir: PathBuf,
}
impl RenameWorkspace {
pub(super) fn new(vault: &Path) -> Result<Self, String> {
let dir = vault.join(".tolaria-rename-txn");
fs::create_dir_all(&dir).map_err(|e| {
format!(
"Failed to create rename transaction dir {}: {}",
dir.display(),
e
)
})?;
Ok(Self { dir })
}
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
let mut staged = NamedTempFile::new_in(&self.dir)
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
staged
.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
staged
.as_file_mut()
.sync_all()
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
Ok(staged)
}
pub(super) fn operation<'a>(
&self,
old_path: &'a str,
old_file: &'a Path,
) -> RenameOperation<'a> {
RenameOperation {
old_path,
old_file,
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
}
}
}
pub(super) struct CommittedRename {
new_file: PathBuf,
manifest_path: PathBuf,
backup_path: PathBuf,
}
impl CommittedRename {
pub(super) fn new_file(&self) -> &Path {
&self.new_file
}
}
impl Drop for CommittedRename {
fn drop(&mut self) {
let _ = fs::remove_file(&self.backup_path);
let _ = fs::remove_file(&self.manifest_path);
}
}
pub(super) struct RenameOperation<'a> {
old_path: &'a str,
old_file: &'a Path,
backup_path: PathBuf,
manifest_path: PathBuf,
}
impl<'a> RenameOperation<'a> {
pub(super) fn rename_with_candidates(
&self,
staged: NamedTempFile,
desired_filename: &str,
parent_dir: &Path,
) -> Result<CommittedRename, String> {
let mut staged = staged;
for attempt in 0.. {
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
self.prepare(&candidate)?;
match staged.persist_noclobber(&candidate) {
Ok(_) => return Ok(self.committed(candidate)),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
staged = err.file;
self.rollback()?;
}
Err(err) => {
self.rollback()?;
return Err(format!(
"Failed to create {}: {}",
candidate.display(),
err.error
));
}
}
}
unreachable!()
}
pub(super) fn rename_exact(
&self,
staged: NamedTempFile,
new_file: &Path,
) -> Result<CommittedRename, String> {
self.prepare(new_file)?;
match staged.persist_noclobber(new_file) {
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
self.rollback()?;
Err("A note with that name already exists".to_string())
}
Err(err) => {
self.rollback()?;
Err(format!(
"Failed to rename {} to {}: {}",
self.old_path,
new_file.to_string_lossy(),
err.error
))
}
}
}
fn prepare(&self, new_file: &Path) -> Result<(), String> {
self.write_manifest(new_file)?;
self.move_into_backup()
}
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
let transaction = RenameTransaction {
old_path: self.old_path.to_string(),
new_path: new_file.to_string_lossy().to_string(),
backup_path: self.backup_path.to_string_lossy().to_string(),
};
let data = serde_json::to_string(&transaction)
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
fs::write(&self.manifest_path, data).map_err(|e| {
format!(
"Failed to write rename transaction {}: {}",
self.manifest_path.display(),
e
)
})
}
fn move_into_backup(&self) -> Result<(), String> {
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
format!(
"Failed to move {} into rename backup {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})
}
fn rollback(&self) -> Result<(), String> {
if self.backup_path.exists() {
if let Some(parent) = self.old_file.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
format!(
"Failed to restore {} from {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})?;
}
let _ = fs::remove_file(&self.manifest_path);
Ok(())
}
fn committed(&self, new_file: PathBuf) -> CommittedRename {
CommittedRename {
new_file,
manifest_path: self.manifest_path.clone(),
backup_path: self.backup_path.clone(),
}
}
}
fn candidate_filename(filename: &str, attempt: usize) -> String {
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
if attempt == 0 {
return filename.to_string();
}
format!("{}-{}{}", stem, attempt + 1, ext)
}
fn transaction_dir(vault: &Path) -> PathBuf {
vault.join(".tolaria-rename-txn")
}
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
let txn_dir = transaction_dir(vault);
if !txn_dir.exists() {
return Ok(());
}
let entries = fs::read_dir(&txn_dir).map_err(|e| {
format!(
"Failed to read rename transaction dir {}: {}",
txn_dir.display(),
e
)
})?;
for entry in entries {
let path = entry
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(data) = fs::read_to_string(&path) else {
let _ = fs::remove_file(&path);
continue;
};
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
let _ = fs::remove_file(&path);
continue;
};
recover_rename_transaction(&path, transaction)?;
}
Ok(())
}
fn recover_rename_transaction(
manifest_path: &Path,
transaction: RenameTransaction,
) -> Result<(), String> {
let old_path = Path::new(&transaction.old_path);
let new_path = Path::new(&transaction.new_path);
let backup_path = Path::new(&transaction.backup_path);
if !backup_path.exists() {
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if new_path.exists() || old_path.exists() {
let _ = fs::remove_file(backup_path);
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if let Some(parent) = old_path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(backup_path, old_path).map_err(|e| {
format!(
"Failed to recover {} from {}: {}",
old_path.display(),
backup_path.display(),
e
)
})?;
let _ = fs::remove_file(manifest_path);
Ok(())
}

View File

@@ -27,12 +27,19 @@
}
],
"security": {
"csp": null,
"csp": {
"default-src": "'self' ipc: http://ipc.localhost",
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"object-src": "'none'"
},
"assetProtocol": {
"enable": true,
"scope": [
"**"
]
"scope": []
}
}
},

View File

@@ -328,6 +328,7 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
import App from './App'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('App', () => {
@@ -379,6 +380,36 @@ describe('App', () => {
})
})
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
}
mockCommandResults.check_mcp_status = 'installed'
render(<App />)
await waitFor(() => {
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
})
await waitFor(() => {
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
})
act(() => {
window.__laputaTest?.dispatchBrowserMenuCommand?.('vault-install-mcp')
})
await waitFor(() => {
expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument()
})
expect(screen.getByTestId('mcp-setup-dialog')).toBeInTheDocument()
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
@@ -495,6 +526,24 @@ describe('App', () => {
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder')
})
it('shows welcome instead of vault-missing when the missing path was not a persisted active vault', async () => {
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.load_vault_list = {
vaults: [],
active_vault: null,
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
render(<App />)
await waitFor(() => {
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
})
expect(screen.queryByText('Vault not found')).not.toBeInTheDocument()
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('renders sidebar with correct default selection (All Notes)', async () => {
render(<App />)
await waitFor(() => {
@@ -570,9 +619,10 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
const workVaultPath = '/Users/mock/Documents/Work'
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',
vaults: [{ label: 'Work Vault', path: workVaultPath }],
active_vault: workVaultPath,
hidden_defaults: [],
}
const disabledWorkflowConfig = JSON.stringify({
@@ -584,14 +634,7 @@ describe('App', () => {
property_display_modes: null,
inbox: { noteListProperties: null, explicitOrganization: false },
})
const vaultPaths = [
'/Users/mock/Documents/Getting Started',
'/Users/mock/demo-vault-v2',
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
]
for (const path of vaultPaths) {
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
}
localStorage.setItem(`laputa:vault-config:${workVaultPath}`, disabledWorkflowConfig)
render(<App />)

View File

@@ -19,6 +19,9 @@ import { WelcomeScreen } from './components/WelcomeScreen'
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { McpSetupDialog } from './components/McpSetupDialog'
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
@@ -29,7 +32,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { slugify } from './hooks/useNoteCreation'
import { planNewTypeCreation } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
@@ -58,9 +61,11 @@ import {
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
@@ -74,6 +79,7 @@ import type { NoteListItem } from './utils/ai-context'
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
@@ -220,6 +226,8 @@ function App() {
const dialogs = useDialogs()
const { showAIChat, toggleAIChat } = dialogs
const [showFeedback, setShowFeedback] = useState(false)
const [showMcpSetupDialog, setShowMcpSetupDialog] = useState(false)
const [mcpDialogAction, setMcpDialogAction] = useState<'connect' | 'disconnect' | null>(null)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
const networkStatus = useNetworkStatus()
@@ -343,44 +351,6 @@ function App() {
return true
}, [handleSetSelection])
const shouldBlockNeighborhoodEscape = (
dialogs.showCreateTypeDialog
|| dialogs.showQuickOpen
|| dialogs.showCommandPalette
|| dialogs.showAIChat
|| dialogs.showSettings
|| dialogs.showCloneVault
|| dialogs.showSearch
|| dialogs.showConflictResolver
|| dialogs.showCreateViewDialog
|| showFeedback
)
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
const activeElement = document.activeElement
if (isEditorEscapeTarget(activeElement)) {
event.preventDefault()
activeElement.blur()
requestAnimationFrame(() => {
focusNoteListContainer(document)
})
return
}
if (isEditableElement(activeElement)) return
if (handleNeighborhoodHistoryBack()) {
event.preventDefault()
}
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
updateConfig('inbox', {
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
@@ -403,20 +373,38 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const openMcpSetupDialog = useCallback(() => {
setShowMcpSetupDialog(true)
}, [])
const closeMcpSetupDialog = useCallback(() => {
if (mcpDialogAction !== null) return
setShowMcpSetupDialog(false)
}, [mcpDialogAction])
const handleConnectMcp = useCallback(async () => {
setMcpDialogAction('connect')
try {
const didConnect = await connectMcp()
if (didConnect) setShowMcpSetupDialog(false)
} finally {
setMcpDialogAction(null)
}
}, [connectMcp])
const handleDisconnectMcp = useCallback(async () => {
setMcpDialogAction('disconnect')
try {
const didDisconnect = await disconnectMcp()
if (didDisconnect) setShowMcpSetupDialog(false)
} finally {
setMcpDialogAction(null)
}
}, [disconnectMcp])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -456,19 +444,18 @@ function App() {
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const flushEditorStateBeforeAction = async (path: string) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
}
const notes = useNoteActions({
addEntry: vault.addEntry,
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforeNoteSwitch: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforePathRename: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforeNoteSwitch: flushEditorStateBeforeAction,
flushBeforeFrontmatterChange: flushEditorStateBeforeAction,
flushBeforePathRename: flushEditorStateBeforeAction,
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -487,8 +474,41 @@ function App() {
const {
handleSelectNote,
handleReplaceActiveTab,
closeAllTabs,
openTabWithContent,
} = notes
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
closeAllTabs,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
reloadFolders: vault.reloadFolders,
reloadVault: vault.reloadVault,
reloadViews: vault.reloadViews,
replaceActiveTab: handleReplaceActiveTab,
updatedFiles,
vaultPath: resolvedPath,
})
}, [
closeAllTabs,
handleReplaceActiveTab,
notes.activeTabPath,
resolvedPath,
vault.reloadFolders,
vault.reloadVault,
vault.reloadViews,
vault.unsavedPaths,
])
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
const pulseCommitDiffRequestIdRef = useRef(0)
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
@@ -592,8 +612,14 @@ function App() {
}, [handleEnterNeighborhood, handleReplaceActiveTab])
const vaultBridge = useVaultBridge({
entriesByPath, resolvedPath,
entriesByPath,
resolvedPath,
reloadVault: vault.reloadVault,
reloadFolders: vault.reloadFolders,
reloadViews: vault.reloadViews,
closeAllTabs,
replaceActiveTab: handleReplaceActiveTab,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
})
@@ -613,7 +639,7 @@ function App() {
const appSave = useAppSave({
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: async () => { await vault.reloadViews() },
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
@@ -686,11 +712,26 @@ function App() {
}
await vault.reloadFolders()
setToastMessage(`Created folder "${name}"`)
return true
} catch (e) {
setToastMessage(`Failed to create folder: ${e}`)
return false
}
}, [resolvedPath, vault, setToastMessage])
const folderActions = useFolderActions({
vaultPath: resolvedPath,
selection: effectiveSelection,
setSelection: handleSetSelection,
setTabs: notes.setTabs,
activeTabPathRef: notes.activeTabPathRef,
handleSwitchTab: notes.handleSwitchTab,
closeAllTabs: notes.closeAllTabs,
reloadVault: vault.reloadVault,
reloadFolders: vault.reloadFolders,
setToastMessage,
})
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
}, [notes.activeTabPath, handleRemoveNoteIcon])
@@ -858,39 +899,43 @@ function App() {
setToastMessage,
})
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)
setToastMessage(`Type "${name}" created`)
const handleCreateType = useCallback(async (name: string) => {
const created = await notes.handleCreateType(name)
if (created) setToastMessage(`Type "${name}" created`)
return created
}, [notes, setToastMessage])
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
const trimmed = nextTypeName.trim()
if (!trimmed) return
if (!trimmed) return false
const targetFilename = `${slugify(trimmed)}.md`
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
if (filenameCollision && filenameCollision.isA !== 'Type') {
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
throw new Error(`Type filename collision for ${targetFilename}`)
const plan = planNewTypeCreation({ entries: vault.entries, typeName: trimmed, vaultPath: resolvedPath })
if (plan.status === 'blocked') {
setToastMessage(plan.message)
return false
}
if (!exactType && !slugMatch) {
await notes.createTypeEntrySilent(trimmed)
let resolvedTypeName = plan.status === 'existing' ? plan.entry.title : trimmed
if (plan.status === 'create') {
try {
resolvedTypeName = (await notes.createTypeEntrySilent(trimmed)).title
} catch {
return false
}
}
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
setToastMessage(
resolvedTypeName === missingType
plan.status === 'create' && resolvedTypeName === missingType
? `Type "${resolvedTypeName}" created`
: `Type set to "${resolvedTypeName}"`,
)
}, [notes, setToastMessage, vault.entries])
return true
}, [notes, resolvedPath, setToastMessage, vault.entries])
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
@@ -1063,10 +1108,60 @@ function App() {
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
?? null
}, [notes.activeTabPath, notes.tabs, vault.entries])
const noteRetargetingUi = useNoteRetargetingUi({
activeEntry: activeCommandEntry,
activeNoteBlocked: !!activeDeletedFile,
entries: vault.entries,
folders: vault.folders,
selection: effectiveSelection,
setSelection: handleSetSelection,
setToastMessage,
vaultPath: resolvedPath,
updateFrontmatter: notes.handleUpdateFrontmatter,
moveNoteToFolder: notes.handleMoveNoteToFolder,
})
const canToggleRichEditor = !!activeCommandEntry
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const shouldBlockNeighborhoodEscape = (
dialogs.showCreateTypeDialog
|| dialogs.showQuickOpen
|| dialogs.showCommandPalette
|| dialogs.showAIChat
|| dialogs.showSettings
|| dialogs.showCloneVault
|| dialogs.showSearch
|| dialogs.showConflictResolver
|| dialogs.showCreateViewDialog
|| noteRetargetingUi.isDialogOpen
|| showFeedback
)
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
const activeElement = document.activeElement
if (isEditorEscapeTarget(activeElement)) {
event.preventDefault()
activeElement.blur()
requestAnimationFrame(() => {
focusNoteListContainer(document)
})
return
}
if (isEditableElement(activeElement)) return
if (handleNeighborhoodHistoryBack()) {
event.preventDefault()
}
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
const noteListColumnsLabel = useMemo(() => {
if (effectiveSelection.kind === 'view') {
@@ -1078,6 +1173,45 @@ function App() {
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
)
const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [])
const toggleRawEditorCommand = useMemo(
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
)
const removeActiveVaultCommand = useCallback(() => {
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
}, [vaultSwitcher])
const restoreVaultAiGuidanceCommand = useCallback(() => {
void restoreVaultAiGuidance()
}, [restoreVaultAiGuidance])
const changeNoteTypeCommand = useMemo(
() => noteRetargetingUi.canChangeActiveNoteType ? noteRetargetingUi.openChangeNoteTypeDialog : undefined,
[noteRetargetingUi.canChangeActiveNoteType, noteRetargetingUi.openChangeNoteTypeDialog],
)
const moveNoteToFolderCommand = useMemo(
() => noteRetargetingUi.canMoveActiveNoteToFolder ? noteRetargetingUi.openMoveNoteToFolderDialog : undefined,
[noteRetargetingUi.canMoveActiveNoteToFolder, noteRetargetingUi.openMoveNoteToFolderDialog],
)
const activeNoteHasIcon = useMemo(() => {
const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath)
return hasNoteIconValue(entry?.icon)
}, [notes.activeTabPath, vault.entries])
const toggleOrganizedCommand = explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined
const canCustomizeNoteListColumns = useMemo(() => (
effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
)
), [effectiveSelection, explicitOrganizationEnabled])
const restoreDeletedNoteCommand = useMemo(
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
[activeDeletedFile, handleDiscardFile],
)
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -1085,7 +1219,7 @@ function App() {
visibleNotesRef,
multiSelectionCommandRef,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
activeNoteModified,
selection: effectiveSelection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
@@ -1101,11 +1235,13 @@ function App() {
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
onRenameFolder: folderActions.renameSelectedFolder,
onDeleteFolder: folderActions.deleteSelectedFolder,
showInbox: explicitOrganizationEnabled,
onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
@@ -1116,16 +1252,16 @@ function App() {
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRemoveActiveVault: removeActiveVaultCommand,
onRestoreGettingStarted: cloneGettingStartedVault,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onInstallMcp: openMcpSetupDialog,
onOpenAiAgents: dialogs.openSettings,
aiAgentsStatus,
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
onRestoreVaultAiGuidance: restoreVaultAiGuidanceCommand,
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
@@ -1134,23 +1270,19 @@ function App() {
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
onRemoveNoteIcon: handleRemoveNoteIconCommand,
activeNoteHasIcon: (() => {
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
return hasNoteIconValue(ae?.icon)
})(),
onChangeNoteType: changeNoteTypeCommand,
onMoveNoteToFolder: moveNoteToFolderCommand,
canMoveNoteToFolder: noteRetargetingUi.canMoveActiveNoteToFolder,
activeNoteHasIcon,
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
onToggleOrganized: toggleOrganizedCommand,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
),
canCustomizeNoteListColumns,
noteListColumnsLabel,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
onRestoreDeletedNote: restoreDeletedNoteCommand,
canRestoreDeletedNote: !!activeDeletedFile,
})
@@ -1211,7 +1343,12 @@ function App() {
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
if (
!noteWindowParams
&& onboarding.state.status === 'ready'
&& aiAgentsOnboarding.showPrompt
&& !showMcpSetupDialog
) {
return (
<>
<AiAgentsOnboardingView
@@ -1224,7 +1361,7 @@ function App() {
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
if (!noteWindowParams && gitRepoState === 'required' && !showMcpSetupDialog) {
return (
<div className="app-shell">
<GitRequiredModal
@@ -1241,135 +1378,157 @@ function App() {
}
return (
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
</div>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
)}
</div>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<NoteRetargetingDialogs
dialogState={noteRetargetingUi.dialogState}
dialogEntry={noteRetargetingUi.dialogEntry}
typeOptions={noteRetargetingUi.typeOptions}
folderOptions={noteRetargetingUi.folderOptions}
onClose={noteRetargetingUi.closeDialog}
onSelectType={noteRetargetingUi.selectType}
onSelectFolder={noteRetargetingUi.selectFolder}
/>
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
{folderActions.confirmDeleteFolder && (
<ConfirmDeleteDialog
open={true}
title={folderActions.confirmDeleteFolder.title}
message={folderActions.confirmDeleteFolder.message}
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
onConfirm={folderActions.confirmDeleteSelectedFolder}
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
</NoteRetargetingProvider>
)
}

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

@@ -77,6 +77,12 @@ describe('AiPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('starts a new AI chat when the header action is clicked', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByTitle('New AI chat'))
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
@@ -153,6 +159,20 @@ describe('AiPanel', () => {
vi.useRealTimers()
})
it('focuses the panel shell when reopening with existing messages', async () => {
vi.useFakeTimers()
mockMessages = [{
userMessage: 'Remember this',
actions: [],
response: 'Still here.',
id: 'msg-3',
}]
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
expect(document.activeElement).toBe(screen.getByTestId('ai-panel'))
vi.useRealTimers()
})
it('calls onClose when Escape is pressed while panel has focus', async () => {
vi.useFakeTimers()
const onClose = vi.fn()

View File

@@ -8,7 +8,7 @@ import {
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import { type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { useAiPanelController } from './useAiPanelController'
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
import { useAiPanelFocus } from './useAiPanelFocus'
@@ -32,29 +32,31 @@ interface AiPanelProps {
noteListFilter?: { type: string | null; query: string }
}
export function AiPanel({
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onOpenNote?: (path: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReady?: boolean
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
}
export function AiPanelView({
controller,
onClose,
onOpenNote,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
onVaultChanged,
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
}: AiPanelProps) {
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
const {
agent,
input,
@@ -64,24 +66,17 @@ export function AiPanel({
isActive,
handleSend,
handleNavigateWikilink,
} = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
handleNewChat,
} = controller
useAiPanelPromptQueue({ agent, input, isActive, setInput })
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
useAiPanelFocus({
inputRef,
panelRef,
hasMessages: agent.messages.length > 0,
isActive,
onClose,
})
return (
<aside
@@ -99,7 +94,13 @@ export function AiPanel({
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
onClose={onClose}
onNewChat={handleNewChat}
/>
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
@@ -128,3 +129,48 @@ export function AiPanel({
</aside>
)
}
export function AiPanel({
onClose,
onOpenNote,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
onVaultChanged,
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
}: AiPanelProps) {
const controller = useAiPanelController({
vaultPath,
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
return (
<AiPanelView
controller={controller}
onClose={onClose}
onOpenNote={onOpenNote}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}
entries={entries}
/>
)
}

View File

@@ -12,7 +12,7 @@ interface AiPanelHeaderProps {
agentReady: boolean
legacyCopy: boolean
onClose: () => void
onClear: () => void
onNewChat: () => void
}
interface AiPanelContextBarProps {
@@ -111,7 +111,7 @@ export function AiPanelHeader({
agentReady,
legacyCopy,
onClose,
onClear,
onNewChat,
}: AiPanelHeaderProps) {
return (
<div
@@ -132,8 +132,9 @@ export function AiPanelHeader({
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear}
title="New conversation"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>

View File

@@ -245,6 +245,12 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
expect(actions).toBeInTheDocument()
expect(actions).toHaveClass('ml-auto')
})
it('does not render the unused backlinks or more-actions placeholders', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.queryByRole('button', { name: 'Backlinks are coming soon' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'More note actions are coming soon' })).not.toBeInTheDocument()
})
})
describe('BreadcrumbBar — raw editor toggle', () => {

View File

@@ -8,10 +8,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import {
GitBranch,
Code,
CursorText,
Sparkle,
SlidersHorizontal,
DotsThree,
Trash,
Archive,
ArrowUUpLeft,
@@ -214,14 +212,6 @@ function DiffAction({
)
}
function PlaceholderAction({ copy, children }: { copy: ActionTooltipCopy; children: ReactNode }) {
return (
<IconActionButton copy={copy} style={DISABLED_ICON_STYLE}>
{children}
</IconActionButton>
)
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
const copy: ActionTooltipCopy = {
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
@@ -481,16 +471,10 @@ function BreadcrumbActions({
onToggleDiff={onToggleDiff}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<PlaceholderAction copy={{ label: 'Backlinks are coming soon' }}>
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
</PlaceholderAction>
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
<PlaceholderAction copy={{ label: 'More note actions are coming soon' }}>
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
</PlaceholderAction>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ConflictResolverModal } from './ConflictResolverModal'
import type { ConflictFileState } from '../hooks/useConflictResolver'
function renderModal(fileStates: ConflictFileState[], overrides: Record<string, unknown> = {}) {
const props = {
open: true,
fileStates,
allResolved: true,
committing: false,
error: null,
onResolveFile: vi.fn(),
onOpenInEditor: vi.fn(),
onCommit: vi.fn(),
onClose: vi.fn(),
...overrides,
}
render(<ConflictResolverModal {...props} />)
return props
}
describe('ConflictResolverModal extra coverage', () => {
it('supports keyboard navigation and actions across the focused file', () => {
const props = renderModal([
{ file: 'notes/project.md', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: false },
])
const list = screen.getByTestId('conflict-file-list')
fireEvent.keyDown(list, { key: 'ArrowDown' })
fireEvent.keyDown(list, { key: 'T' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'theirs')
fireEvent.keyDown(list, { key: 'ArrowUp' })
fireEvent.keyDown(list, { key: 'k' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'ours')
fireEvent.keyDown(list, { key: 'o' })
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
fireEvent.keyDown(list, { key: 'Enter' })
expect(props.onCommit).toHaveBeenCalledTimes(1)
fireEvent.keyDown(list, { key: 'Escape' })
expect(props.onClose).toHaveBeenCalled()
})
it('ignores binary open shortcuts, resolving rows, and modifier-assisted actions', () => {
const props = renderModal([
{ file: 'images/photo.png', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: true },
], { allResolved: false })
const list = screen.getByTestId('conflict-file-list')
fireEvent.keyDown(list, { key: 'o' })
expect(props.onOpenInEditor).not.toHaveBeenCalled()
fireEvent.focus(screen.getByTestId('conflict-file-notes/plan.md'))
fireEvent.keyDown(list, { key: 'K' })
fireEvent.keyDown(list, { key: 'T', ctrlKey: true })
expect(props.onResolveFile).not.toHaveBeenCalled()
expect(props.onCommit).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { ConflictFileState } from '../hooks/useConflictResolver'
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}) => (
open ? (
<div data-testid="dialog-root">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>close</button>
</div>
) : null
),
DialogContent: ({
children,
onKeyDown,
}: {
children: React.ReactNode
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void
}) => (
<div role="dialog" tabIndex={0} onKeyDown={onKeyDown}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
}))
import { ConflictResolverModal } from './ConflictResolverModal'
function makeFileStates(overrides?: Partial<ConflictFileState>[]): ConflictFileState[] {
const defaults: ConflictFileState[] = [
{ file: 'notes/project.md', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: false },
]
if (!overrides) return defaults
return defaults.map((state, index) => ({ ...state, ...(overrides[index] ?? {}) }))
}
function renderModal(overrides: Partial<React.ComponentProps<typeof ConflictResolverModal>> = {}) {
const props: React.ComponentProps<typeof ConflictResolverModal> = {
open: true,
fileStates: makeFileStates(),
allResolved: false,
committing: false,
error: null,
onResolveFile: vi.fn(),
onOpenInEditor: vi.fn(),
onCommit: vi.fn(),
onClose: vi.fn(),
...overrides,
}
render(<ConflictResolverModal {...props} />)
return props
}
describe('ConflictResolverModal keyboard behavior', () => {
it('navigates rows with the keyboard and routes shortcuts to the focused file', () => {
const props = renderModal()
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'ArrowDown' })
fireEvent.keyDown(dialog, { key: 'k' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'ours')
fireEvent.keyDown(dialog, { key: 'ArrowUp' })
fireEvent.keyDown(dialog, { key: 'T' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'theirs')
fireEvent.keyDown(dialog, { key: 'o' })
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
})
it('commits on Enter when all files are resolved and closes through Escape and open-change callbacks', () => {
const props = renderModal({ allResolved: true })
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'Enter' })
expect(props.onCommit).toHaveBeenCalledTimes(1)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(props.onClose).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByTestId('dialog-close'))
expect(props.onClose).toHaveBeenCalledTimes(2)
})
it('ignores shortcut variants that should be blocked', () => {
const props = renderModal({
fileStates: [{ file: 'images/photo.png', resolution: null, resolving: true }],
})
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'k' })
fireEvent.keyDown(dialog, { key: 'o' })
fireEvent.keyDown(dialog, { key: 't', metaKey: true })
expect(props.onResolveFile).not.toHaveBeenCalled()
expect(props.onOpenInEditor).not.toHaveBeenCalled()
})
})

View File

@@ -46,6 +46,19 @@ describe('CreateTypeDialog', () => {
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('stays open when create reports a handled collision', async () => {
const onClose = vi.fn()
const onCreate = vi.fn().mockResolvedValue(false)
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={onCreate} />)
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: 'Recipe' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Recipe'))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByText('Create New Type')).toBeInTheDocument()
})
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={() => {}} />)

View File

@@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input'
interface CreateTypeDialogProps {
open: boolean
onClose: () => void
onCreate: (name: string) => void | Promise<void>
onCreate: (name: string) => boolean | void | Promise<boolean | void>
initialName?: string
}
interface CreateTypeDialogFormProps {
initialName: string
onClose: () => void
onCreate: (name: string) => void | Promise<void>
onCreate: (name: string) => boolean | void | Promise<boolean | void>
}
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
@@ -23,8 +23,8 @@ function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDial
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
await onCreate(trimmed)
onClose()
const created = await onCreate(trimmed)
if (created !== false) onClose()
}
return (

View File

@@ -239,7 +239,7 @@ export function DynamicPropertiesPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,

View File

@@ -398,6 +398,66 @@ describe('Editor', () => {
resetVaultConfigStore()
})
it('updates the open raw editor when tab content changes externally', async () => {
resetVaultConfigStore()
bindVaultConfigStore(
{
zoom: null,
view_mode: null,
editor_mode: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
},
vi.fn(),
)
const rawToggleRef = { current: (() => {}) as () => void }
const initialContent = '---\nowner: [[Alice]]\nstatus: Active\n---\n\n# Test Project\n\nBody.\n'
const updatedContent = '---\nowner: [[Bob]]\nstatus: Active\n---\n\n# Test Project\n\nBody.\n'
const initialTab = { entry: mockEntry, content: initialContent }
const updatedTab = { entry: mockEntry, content: updatedContent }
const { rerender } = render(
<Editor
{...defaultProps}
tabs={[initialTab]}
activeTabPath={mockEntry.path}
entries={[mockEntry]}
rawToggleRef={rawToggleRef}
/>,
)
await vi.waitFor(() => {
expect(typeof rawToggleRef.current).toBe('function')
})
await act(async () => {
await rawToggleRef.current()
})
await vi.waitFor(() => {
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('owner: [[Alice]]')
})
rerender(
<Editor
{...defaultProps}
tabs={[updatedTab]}
activeTabPath={mockEntry.path}
entries={[mockEntry]}
rawToggleRef={rawToggleRef}
/>,
)
await vi.waitFor(() => {
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('owner: [[Bob]]')
})
resetVaultConfigStore()
})
})
describe('applyPendingRawExitContent', () => {
@@ -528,6 +588,37 @@ describe('click empty editor space', () => {
container.removeChild(editableDiv)
})
it('restores the cursor to the H1 when clicking the title block', async () => {
mockEditor.focus.mockClear()
mockEditor.setTextCursorPosition.mockClear()
mockEditor.document = [
{ id: 'title', type: 'heading', content: [{ type: 'text', text: 'Alpha Project', styles: {} }], props: { level: 1 }, children: [] },
{ id: 'body', type: 'paragraph', content: [], props: {}, children: [] },
]
render(
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
)
const container = document.querySelector('.editor__blocknote-container')!
const editableDiv = document.createElement('div')
editableDiv.setAttribute('contenteditable', 'true')
const heading = document.createElement('h1')
heading.textContent = 'Alpha Project'
heading.setAttribute('data-content-type', 'heading')
heading.setAttribute('data-level', '1')
editableDiv.appendChild(heading)
container.appendChild(editableDiv)
fireEvent.click(heading)
await act(() => Promise.resolve())
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('title', 'end')
expect(mockEditor.focus).toHaveBeenCalled()
container.removeChild(editableDiv)
})
})
describe('archived note behavior', () => {
@@ -598,6 +689,12 @@ describe('wikilink autocomplete', () => {
expect(mockFilterSuggestionItems).toHaveBeenCalled()
})
it('normalizes BlockNote trigger-prefixed wikilink queries before filtering', async () => {
renderWithEntries()
const items = await capturedGetItems!('[[Al')
expect(items.length).toBeGreaterThan(0)
})
it('limits results to MAX_RESULTS (20)', async () => {
// Create many entries that will all match
const manyEntries = Array.from({ length: 50 }, (_, i) => ({
@@ -633,7 +730,7 @@ describe('wikilink autocomplete', () => {
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/project/test' } },
' ',
])
], { updateSelection: true })
})
it('deduplicates entries with the same path', async () => {
@@ -790,7 +887,7 @@ describe('person @mention autocomplete', () => {
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
' ',
])
], { updateSelection: true })
})
it('shows Person type badge on results', async () => {

View File

@@ -52,7 +52,7 @@ interface EditorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
@@ -360,7 +360,7 @@ function EditorLayout({
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onFileCreated?: (relativePath: string) => void

View File

@@ -1,8 +1,11 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanel } from './AiPanel'
import { AiPanelView } from './AiPanel'
import { useAiPanelController } from './useAiPanelController'
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
interface EditorRightPanelProps {
showAIChat?: boolean
@@ -24,7 +27,7 @@ interface EditorRightPanelProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
@@ -43,26 +46,45 @@ export function EditorRightPanel({
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
const aiPanelController = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
activeEntry: inspectorEntry,
activeNoteContent: inspectorContent,
entries,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
const { handleNewChat } = aiPanelController
useEffect(() => {
const handleRequestedNewChat = () => {
handleNewChat()
}
window.addEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
}, [handleNewChat])
if (showAIChat) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanel
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
noteList={noteList}
noteListFilter={noteListFilter}
/>
</div>
)

View File

@@ -1,6 +1,8 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { useState } from 'react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FolderTree } from './FolderTree'
import { FOLDER_ROW_SINGLE_CLICK_DELAY_MS } from './folder-tree/useFolderRowInteractions'
import type { FolderNode, SidebarSelection } from '../types'
const mockFolders: FolderNode[] = [
@@ -34,37 +36,191 @@ describe('FolderTree', () => {
expect(screen.getByText('journal')).toBeInTheDocument()
})
it('does not show children initially', () => {
it('expands children when clicking the folder chevron', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
})
it('calls onSelect with folder kind when clicking a folder', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByText('projects'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
})
it('expands children when clicking a folder with children', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByText('projects'))
fireEvent.click(screen.getByLabelText('Expand projects'))
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
})
it('collapses section when clicking FOLDERS header', () => {
it('calls onSelect with folder kind when clicking a folder row', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByTestId('folder-row:projects'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
})
it('expands children when single-clicking a folder row with children', () => {
vi.useFakeTimers()
function FolderTreeHarness() {
const [selection, setSelection] = useState<SidebarSelection>(defaultSelection)
return <FolderTree folders={mockFolders} selection={selection} onSelect={setSelection} />
}
render(<FolderTreeHarness />)
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('collapses section when clicking the FOLDERS header', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.getByText('projects')).toBeInTheDocument()
fireEvent.click(screen.getByText('FOLDERS'))
expect(screen.queryByText('projects')).not.toBeInTheDocument()
})
it('highlights selected folder', () => {
const sel: SidebarSelection = { kind: 'folder', path: 'areas' }
render(<FolderTree folders={mockFolders} selection={sel} onSelect={vi.fn()} />)
const btn = screen.getByText('areas').closest('button')!
expect(btn.className).toContain('text-primary')
it('highlights the selected folder row', () => {
const selection: SidebarSelection = { kind: 'folder', path: 'areas' }
render(<FolderTree folders={mockFolders} selection={selection} onSelect={vi.fn()} />)
expect(screen.getByTestId('folder-row:areas').className).toContain('text-primary')
})
it('opens the create-folder input from the header action', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onCreateFolder={vi.fn().mockResolvedValue(true)}
/>,
)
fireEvent.click(screen.getByTestId('create-folder-btn'))
expect(screen.getByTestId('new-folder-input')).toBeInTheDocument()
})
it('starts rename on folder double-click', () => {
const onStartRenameFolder = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={vi.fn()}
/>,
)
fireEvent.doubleClick(screen.getByTestId('folder-row:projects'))
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
})
it('shows inline rename and delete actions for folders', () => {
const onDeleteFolder = vi.fn()
const onStartRenameFolder = vi.fn()
const onSelect = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={onSelect}
onDeleteFolder={onDeleteFolder}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('rename-folder-btn:projects'))
fireEvent.click(screen.getByTestId('delete-folder-btn:projects'))
expect(onSelect).toHaveBeenNthCalledWith(1, { kind: 'folder', path: 'projects' })
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
expect(onSelect).toHaveBeenNthCalledWith(2, { kind: 'folder', path: 'projects' })
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
it('does not reserve a disclosure slot for leaf folders', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement
const parentRowContainer = screen.getByTestId('folder-row:projects').parentElement
expect(leafRowContainer).not.toBeNull()
expect(parentRowContainer).not.toBeNull()
expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(2)
})
it('shows the rename input when a folder is being renamed', () => {
render(
<FolderTree
folders={mockFolders}
selection={{ kind: 'folder', path: 'areas' }}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
renamingFolderPath="areas"
onCancelRenameFolder={vi.fn()}
/>,
)
expect(screen.getByTestId('rename-folder-input')).toBeInTheDocument()
})
it('keeps folder toggling healthy after cancelling rename', () => {
vi.useFakeTimers()
const onCancelRenameFolder = vi.fn()
const { rerender } = render(
<FolderTree
folders={mockFolders}
selection={{ kind: 'folder', path: 'projects' }}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
renamingFolderPath="projects"
onCancelRenameFolder={onCancelRenameFolder}
/>,
)
fireEvent.keyDown(screen.getByTestId('rename-folder-input'), { key: 'Escape' })
expect(onCancelRenameFolder).toHaveBeenCalledTimes(1)
rerender(
<FolderTree
folders={mockFolders}
selection={{ kind: 'folder', path: 'projects' }}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onCancelRenameFolder={onCancelRenameFolder}
/>,
)
const wasExpanded = screen.queryByText('laputa') !== null
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.queryByText('laputa') !== null).toBe(!wasExpanded)
vi.useRealTimers()
})
it('opens a context menu with a delete action on right-click', () => {
const onDeleteFolder = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onDeleteFolder={onDeleteFolder}
onStartRenameFolder={vi.fn()}
/>,
)
fireEvent.contextMenu(screen.getByText('projects'))
expect(screen.getByTestId('folder-context-menu')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('delete-folder-menu-item'))
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
})

View File

@@ -1,162 +1,148 @@
import { useState, useCallback, useRef, useEffect, memo } from 'react'
import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react'
import {
memo,
useCallback,
} from 'react'
import {
Plus,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import type { FolderNode, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { FolderContextMenu } from './folder-tree/FolderContextMenu'
import { FolderNameInput } from './folder-tree/FolderNameInput'
import { FolderTreeRow } from './folder-tree/FolderTreeRow'
import { useFolderContextMenu } from './folder-tree/useFolderContextMenu'
import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure'
import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader'
interface FolderTreeProps {
folders: FolderNode[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onCreateFolder?: (name: string) => void
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
collapsed?: boolean
onToggle?: () => void
}
function FolderItem({
node, depth, selection, expanded, onToggle, onSelect,
}: {
node: FolderNode
depth: number
selection: SidebarSelection
expanded: Record<string, boolean>
onToggle: (path: string) => void
onSelect: (selection: SidebarSelection) => void
}) {
const isSelected = selection.kind === 'folder' && selection.path === node.path
const isExpanded = expanded[node.path] ?? false
const hasChildren = node.children.length > 0
export const FolderTree = memo(function FolderTree({
folders,
selection,
onSelect,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
collapsed: externalCollapsed,
onToggle,
}: FolderTreeProps) {
const {
closeCreateForm,
expanded,
handleToggleSection,
isCreating,
openCreateForm,
sectionCollapsed,
toggleFolder,
} = useFolderTreeDisclosure({
collapsed: externalCollapsed,
onToggle,
renamingFolderPath,
selection,
})
const {
closeContextMenu,
contextMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRenameFromMenu,
menuRef,
} = useFolderContextMenu({
onDeleteFolder,
onStartRenameFolder,
})
const handleClick = () => {
onSelect({ kind: 'folder', path: node.path })
if (hasChildren) onToggle(node.path)
}
return (
<>
<button
className={cn(
'flex w-full items-center gap-2 rounded-[5px] border-none bg-transparent cursor-pointer text-left transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ padding: '5px 8px', paddingLeft: 8 + depth * 16, fontSize: 13 }}
onClick={handleClick}
title={node.path}
>
{isSelected || isExpanded ? (
<FolderOpen size={18} weight="fill" className="shrink-0" />
) : (
<Folder size={18} className="shrink-0" />
)}
<span className={cn('truncate', isSelected && 'font-medium')}>{node.name}</span>
</button>
{isExpanded && hasChildren && (
<div className="relative" style={{ paddingLeft: 15 }}>
<div
className="absolute top-0 bottom-0 bg-border"
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
/>
{node.children.map((child) => (
<FolderItem
key={child.path}
node={child}
depth={depth + 1}
selection={selection}
expanded={expanded}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
)}
</>
)
}
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder, collapsed: externalCollapsed, onToggle }: FolderTreeProps) {
const [internalCollapsed, setInternalCollapsed] = useState(false)
const sectionCollapsed = externalCollapsed ?? internalCollapsed
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [isCreating, setIsCreating] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const toggleFolder = useCallback((path: string) => {
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
}, [])
useEffect(() => {
if (isCreating) inputRef.current?.focus()
}, [isCreating])
const handleCreateFolder = () => {
const name = newFolderName.trim()
if (name && onCreateFolder) {
onCreateFolder(name)
const handleCreateFolderSubmit = useCallback(async (value: string) => {
const nextName = value.trim()
if (!nextName || !onCreateFolder) {
closeCreateForm()
return true
}
setIsCreating(false)
setNewFolderName('')
}
const created = await onCreateFolder(nextName)
if (created) closeCreateForm()
return created
}, [closeCreateForm, onCreateFolder])
if (folders.length === 0 && !isCreating) return null
return (
<div style={{ padding: '0 6px' }}>
{/* Header */}
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={() => onToggle ? onToggle() : setInternalCollapsed((v) => !v)}
>
<div className="flex items-center gap-1">
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FOLDERS</span>
</div>
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="FOLDERS" collapsed={sectionCollapsed} onToggle={handleToggleSection}>
{onCreateFolder && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); setIsCreating(true); if (sectionCollapsed && onToggle) onToggle(); else if (sectionCollapsed) setInternalCollapsed(false) }}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
/>
title="Create folder"
aria-label="Create folder"
onClick={(event) => {
event.stopPropagation()
closeContextMenu()
openCreateForm()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)}
</button>
{/* Tree */}
</SidebarGroupHeader>
{!sectionCollapsed && (
<div className="flex flex-col gap-0.5" style={{ padding: '2px 6px 8px 14px' }}>
<div className="flex flex-col gap-0.5 pb-2">
{folders.map((node) => (
<FolderItem
<FolderTreeRow
key={node.path}
node={node}
depth={0}
selection={selection}
expanded={expanded}
onToggle={toggleFolder}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={handleOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
onToggle={toggleFolder}
onCancelRenameFolder={onCancelRenameFolder}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
))}
{isCreating && (
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
<Folder size={18} className="shrink-0 text-muted-foreground" />
<input
ref={inputRef}
className="flex-1 border border-border rounded bg-background px-1.5 py-0.5 text-[13px] text-foreground outline-none focus:border-primary"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') }
}}
onBlur={handleCreateFolder}
<div style={{ paddingLeft: 8 }}>
<FolderNameInput
ariaLabel="New folder name"
initialValue=""
placeholder="Folder name"
data-testid="new-folder-input"
submitOnBlur={true}
testId="new-folder-input"
onCancel={closeCreateForm}
onSubmit={handleCreateFolderSubmit}
/>
</div>
)}
</div>
)}
<FolderContextMenu
menu={contextMenu}
menuRef={menuRef}
onDelete={handleDeleteFromMenu}
onRename={handleRenameFromMenu}
/>
</div>
)
})

View File

@@ -451,8 +451,8 @@ Status: Active
/>
)
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
})
it('hides referenced-by section when no entries reference the current note', () => {
@@ -559,7 +559,7 @@ Status: Active
/>
)
// noteA shows in Referenced By (via Belongs to)
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()

View File

@@ -32,7 +32,7 @@ interface InspectorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
@@ -71,7 +71,7 @@ function ValidFrontmatterPanels({
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<void>
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
}) {
return (
<>
@@ -134,7 +134,7 @@ function PrimaryInspectorPanel({
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<void>
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
}) {
if (frontmatterState === 'valid') {
return (
@@ -162,12 +162,10 @@ function PrimaryInspectorPanel({
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
}
export function Inspector({
collapsed,
onToggle,
function InspectorBody({
entry,
content,
entries,
content,
gitHistory,
vaultPath,
onNavigate,
@@ -179,7 +177,7 @@ export function Inspector({
onCreateAndOpenNote,
onInitializeProperties,
onToggleRawEditor,
}: InspectorProps) {
}: Omit<InspectorProps, 'collapsed' | 'onToggle'>) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
@@ -198,40 +196,46 @@ export function Inspector({
onCreateMissingType,
})
if (!entry) {
return <EmptyInspector />
}
return (
<>
<PrimaryInspectorPanel
entry={entry}
frontmatterState={frontmatterState}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
referencedBy={referencedBy}
onNavigate={onNavigate}
onToggleRawEditor={onToggleRawEditor}
onInitializeProperties={onInitializeProperties}
onCreateAndOpenNote={onCreateAndOpenNote}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
)
}
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
{!collapsed && (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
{entry ? (
<>
<PrimaryInspectorPanel
entry={entry}
frontmatterState={frontmatterState}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
referencedBy={referencedBy}
onNavigate={onNavigate}
onToggleRawEditor={onToggleRawEditor}
onInitializeProperties={onInitializeProperties}
onCreateAndOpenNote={onCreateAndOpenNote}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
) : (
<EmptyInspector />
)}
<InspectorBody {...bodyProps} />
</div>
)}
</aside>

View File

@@ -1,13 +1,16 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -265,17 +268,29 @@ describe('DynamicRelationshipsPanel', () => {
describe('relation editing', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const renderEditableRelationships = (
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
) => renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
...overrides,
})
const openInlineAdd = (value?: string) => {
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
if (value !== undefined) {
fireEvent.change(input, { target: { value } })
}
return input
}
beforeEach(() => {
vi.clearAllMocks()
})
it('shows remove buttons on relation refs when editing is enabled', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
renderEditableRelationships()
expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument()
})
@@ -285,91 +300,77 @@ describe('DynamicRelationshipsPanel', () => {
})
it('calls onDeleteProperty when removing the last ref in a group', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
renderEditableRelationships()
fireEvent.click(screen.getByTestId('remove-relation-ref'))
expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to')
})
it('calls onUpdateProperty with remaining refs when removing one of many', () => {
renderRelationshipsPanel({
it.each([
{
name: 'calls onUpdateProperty with remaining refs when removing one of many',
frontmatter: { 'Related to': ['[[project/my-project]]', '[[topic/ai]]'] },
onUpdateProperty,
onDeleteProperty,
})
const removeButtons = screen.getAllByTestId('remove-relation-ref')
fireEvent.click(removeButtons[0]) // Remove first ref
expect(onUpdateProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
})
it('calls onUpdateProperty with string when two refs become one', () => {
renderRelationshipsPanel({
expectedKey: 'Related to',
expectedValue: '[[topic/ai]]',
},
{
name: 'calls onUpdateProperty with string when two refs become one',
frontmatter: { Has: ['[[project/my-project]]', '[[topic/ai]]'] },
onUpdateProperty,
onDeleteProperty,
})
expectedKey: 'Has',
expectedValue: '[[topic/ai]]',
},
])('$name', ({ frontmatter, expectedKey, expectedValue }) => {
renderEditableRelationships({ frontmatter })
const removeButtons = screen.getAllByTestId('remove-relation-ref')
fireEvent.click(removeButtons[0])
// Should pass a single string, not an array of one
expect(onUpdateProperty).toHaveBeenCalledWith('Has', '[[topic/ai]]')
expect(onUpdateProperty).toHaveBeenCalledWith(expectedKey, expectedValue)
})
it('shows inline add button for each relationship group when editing is enabled', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
renderEditableRelationships()
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
})
it('opens inline add input when add button clicked', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
renderEditableRelationships()
openInlineAdd()
expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument()
})
it('adds a note to an existing relationship via inline add', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
renderEditableRelationships()
const input = openInlineAdd('AI')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
})
it('clicks a search result to add the relationship and close the inline editor', () => {
renderEditableRelationships()
openInlineAdd('AI')
fireEvent.click(screen.getByText('AI'))
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
})
it('does not add duplicate refs', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[topic/ai]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
const input = openInlineAdd('AI')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('ignores empty inline-add Enter presses', () => {
renderEditableRelationships()
const input = openInlineAdd()
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).not.toHaveBeenCalled()
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
})
it('closes inline add on Escape', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
renderEditableRelationships()
const input = openInlineAdd()
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
@@ -380,6 +381,21 @@ describe('DynamicRelationshipsPanel', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
const renderInlineCreatePanel = (
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
) => renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
...overrides,
})
const openInlineCreate = (value: string) => {
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value } })
return input
}
beforeEach(() => {
vi.clearAllMocks()
@@ -387,43 +403,22 @@ describe('DynamicRelationshipsPanel', () => {
})
it('shows "Create & open" option when typed title does not match any note', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
renderInlineCreatePanel()
openInlineCreate('Brand New Note')
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
})
it('does not show "Create & open" when typed title matches an existing note', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
renderInlineCreatePanel()
openInlineCreate('AI')
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
})
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
renderInlineCreatePanel()
openInlineCreate('Brand New Note')
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
await vi.waitFor(() => {
@@ -433,15 +428,8 @@ describe('DynamicRelationshipsPanel', () => {
it('does not add wikilink when note creation fails', async () => {
onCreateAndOpenNote.mockResolvedValue(false)
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Failing Note' } })
renderInlineCreatePanel()
openInlineCreate('Failing Note')
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
// Give async handler time to resolve
@@ -453,29 +441,16 @@ describe('DynamicRelationshipsPanel', () => {
})
it('shows both existing matches and "Create & open" for partial matches', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
onCreateAndOpenNote,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
// "My" partially matches "My Project" but is not an exact match
fireEvent.change(input, { target: { value: 'My' } })
renderInlineCreatePanel()
// "My" partially matches "My Project" but is not an exact match.
openInlineCreate('My')
// Should show search results AND create option
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
})
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
renderRelationshipsPanel({
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
onUpdateProperty,
onDeleteProperty,
})
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
renderInlineCreatePanel({ onCreateAndOpenNote: undefined })
openInlineCreate('Brand New Note')
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
})
})
@@ -498,6 +473,40 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
})
it('submits from the relationship-name input on Enter and cancels the form from the note input on Escape', () => {
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.keyDown(screen.getByPlaceholderText('Relationship name'), { key: 'Enter' })
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[topic/ai]]')
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.keyDown(screen.getByPlaceholderText('Note title'), { key: 'Escape' })
expect(screen.queryByPlaceholderText('Relationship name')).not.toBeInTheDocument()
})
it('hides the target dropdown after blur once the grace timeout expires', () => {
vi.useFakeTimers()
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
const noteInput = screen.getByPlaceholderText('Note title')
fireEvent.focus(noteInput)
fireEvent.change(noteInput, { target: { value: 'New Person' } })
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
act(() => {
fireEvent.blur(noteInput)
vi.advanceTimersByTime(150)
})
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('creates note and adds relationship via form', async () => {
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
fireEvent.click(screen.getByText('+ Add relationship'))
@@ -594,11 +603,15 @@ describe('ReferencedByPanel', () => {
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByTestId('derived-relationships-separator')).toBeInTheDocument()
expect(screen.getByText('Write Essays')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
expect(screen.queryByText('Derived relationships')).not.toBeInTheDocument()
expect(screen.queryByText('Read-only groups sourced from other notes.')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
@@ -618,6 +631,17 @@ describe('ReferencedByPanel', () => {
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('adds an inverse-relationship tooltip to each derived relationship label', async () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'Child Note' }), viaKey: 'Belongs to' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
fireEvent.focus(screen.getByText('Children'))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
})
})
describe('GitHistoryPanel', () => {

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
/>,
)
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
})
it('renders reconnect and disconnect actions for an already connected vault', () => {
render(
<McpSetupDialog
open={true}
status="installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
/>,
)
expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Reconnect External AI Tools')
expect(screen.getByTestId('mcp-setup-disconnect')).toHaveTextContent('Disconnect')
})
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onDisconnect = vi.fn()
render(
<McpSetupDialog
open={true}
status="installed"
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,109 @@
import { ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { McpStatus } from '../hooks/useMcpStatus'
interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
}
function isConnected(status: McpStatus): boolean {
return status === 'installed'
}
function actionCopy(status: McpStatus) {
if (isConnected(status)) {
return {
description: 'Tolaria is already connected to external AI tools for this vault. Reconnect to refresh the configuration, or disconnect to remove Tolaria from those third-party config files.',
primaryLabel: 'Reconnect External AI Tools',
secondaryLabel: 'Disconnect',
title: 'Manage External AI Tools',
}
}
return {
description: 'Tolaria can add its MCP server to external AI tools for this vault, but it will not touch third-party config files until you confirm here.',
primaryLabel: 'Connect External AI Tools',
secondaryLabel: null,
title: 'Set Up External AI Tools',
}
}
export function McpSetupDialog({
open,
status,
busyAction,
onClose,
onConnect,
onDisconnect,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck size={18} />
{copy.title}
</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
Confirming this action will write or update Tolaria&apos;s single <code className="rounded bg-muted px-1 py-0.5 text-xs">tolaria</code> MCP entry in:
</p>
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
<div>~/.claude/mcp.json</div>
<div>~/.cursor/mcp.json</div>
</div>
<p>
The entry points those tools at the current vault. Cancel leaves both files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
@@ -56,20 +56,33 @@ describe('NoteList virtualized datasets', () => {
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', () => {
const entries = [
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
makeIndexedEntry(999, { title: 'Beta Strategy' }),
]
it('filters large datasets by search query', async () => {
vi.useFakeTimers()
try {
const entries = [
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
...Array.from({ length: 298 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
makeIndexedEntry(299, { title: 'Beta Strategy' }),
]
renderNoteList({ entries })
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
renderNoteList({ entries })
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(200)
})
vi.useRealTimers()
await waitFor(() => {
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
})
} finally {
vi.useRealTimers()
}
})
it('sorts large datasets correctly', () => {

View File

@@ -84,10 +84,16 @@ function renderManagedViewNoteList({
}
}
function searchNoteList(query: string) {
async function searchNoteList(query: string) {
const searchInput = screen.queryByPlaceholderText('Search notes...')
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
await waitFor(() => {
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
})
}
function renderBookNoteList({
@@ -117,11 +123,11 @@ function renderBookNoteList({
})
}
function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
searchNoteList(matchingQuery)
async function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
await searchNoteList(matchingQuery)
expect(screen.getByText(title)).toBeInTheDocument()
searchNoteList(hiddenQuery)
await searchNoteList(hiddenQuery)
expect(screen.queryByText(title)).not.toBeInTheDocument()
expect(screen.getByText('No matching notes')).toBeInTheDocument()
}
@@ -181,7 +187,7 @@ describe('NoteList rendering', () => {
it('shows referenced-by groups for topic entities', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } })
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
})
it('toggles the search input from the header action', () => {
@@ -191,14 +197,14 @@ describe('NoteList rendering', () => {
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
})
it('filters by a case-insensitive search query', () => {
it('filters by a case-insensitive search query', async () => {
renderNoteList()
searchNoteList('facebook')
await searchNoteList('facebook')
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by snippet text when the title does not match', () => {
it('filters by snippet text when the title does not match', async () => {
renderNoteList({
entries: [
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
@@ -206,13 +212,13 @@ describe('NoteList rendering', () => {
],
})
searchNoteList('nebula-only')
await searchNoteList('nebula-only')
expect(screen.getByText('Beta Note')).toBeInTheDocument()
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
})
it('filters by visible property values and ignores hidden properties', () => {
it('filters by visible property values and ignores hidden properties', async () => {
renderBookNoteList({
entryOverrides: {
title: 'Property Search Note',
@@ -221,10 +227,10 @@ describe('NoteList rendering', () => {
allNotesNoteListProperties: null,
})
expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
await expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
})
it('uses the active all-notes columns when filtering by visible property values', () => {
it('uses the active all-notes columns when filtering by visible property values', async () => {
renderBookNoteList({
entryOverrides: {
title: 'Override Search Note',
@@ -233,7 +239,7 @@ describe('NoteList rendering', () => {
allNotesNoteListProperties: ['Owner'],
})
expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
await expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
})
it('sorts entries by last modified descending by default', () => {
@@ -289,6 +295,35 @@ describe('NoteList rendering', () => {
}
})
it('keeps the note-list search input full width and shows only an inline spinner while loading', () => {
vi.useFakeTimers()
try {
renderNoteList({
entries: [
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Strategy' }),
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note' }),
],
})
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'strategy' } })
const searchInput = screen.getByPlaceholderText('Search notes...')
expect(searchInput).toHaveClass('pr-8')
expect(searchInput.parentElement).toHaveClass('relative', 'flex-1')
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
expect(screen.queryByText('Searching...')).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(180)
})
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('shows backlinks from outgoing links in entity view', () => {
const entriesWithBacklink = mockEntries.map((entry) =>
entry.path === mockEntries[2].path ? { ...entry, outgoingLinks: ['Build Laputa App'] } : entry,
@@ -318,11 +353,11 @@ describe('NoteList rendering', () => {
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
})
it('keeps existing neighborhood groups visible at zero after search filters them out', () => {
it('keeps existing neighborhood groups visible at zero after search filters them out', async () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
@@ -344,11 +379,11 @@ describe('NoteList rendering', () => {
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
searchNoteList('missing-neighborhood-match')
await searchNoteList('missing-neighborhood-match')
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
})
@@ -375,7 +410,7 @@ describe('NoteList rendering', () => {
})
expect(screen.getByText('Related to')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
})

View File

@@ -0,0 +1,325 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
const { createValueButtonMock } = vi.hoisted(() => ({
createValueButtonMock:
(testId: string, nextValue: (value: string) => string) =>
({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid={testId} onClick={() => onSave(nextValue(value))}>
{value}
</button>
),
}))
vi.mock('./EditableValue', () => ({
EditableValue: createValueButtonMock('editable-value', (value) => `${value}-saved`),
TagPillList: ({
items,
label,
onSave,
}: {
items: string[]
label: string
onSave: (items: string[]) => void
}) => (
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'omega'])}>
{label}:{items.join(',')}
</button>
),
UrlValue: createValueButtonMock('url-value', (value) => `${value}/updated`),
}))
vi.mock('./StatusDropdown', () => ({
StatusDropdown: ({
onSave,
onCancel,
}: {
onSave: (value: string) => void
onCancel: () => void
}) => (
<div>
<button data-testid="status-save" onClick={() => onSave('Done')}>save</button>
<button data-testid="status-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('./TagsDropdown', () => ({
TagsDropdown: ({
onToggle,
onClose,
}: {
onToggle: (tag: string) => void
onClose: () => void
}) => (
<div data-testid="tags-dropdown">
<button data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
<button data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button>
<button data-testid="tags-close" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('./ColorInput', () => ({
ColorEditableValue: createValueButtonMock('color-value', (value) => value.toUpperCase()),
}))
vi.mock('./IconEditableValue', () => ({
IconEditableValue: createValueButtonMock('icon-value', (value) => `${value}-icon`),
}))
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value?: Date) => void }) => (
<div>
<button data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
pick
</button>
<button data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
empty
</button>
</div>
),
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({
children,
onOpenChange,
}: {
children: ReactNode
onOpenChange?: (open: boolean) => void
}) => (
<div>
{children}
<button data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
</div>
),
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
}))
function makeRect(right: number, bottom: number): DOMRect {
return {
x: 0,
y: 0,
width: 24,
height: 24,
top: 0,
left: 0,
right,
bottom,
toJSON: () => ({}),
} as DOMRect
}
describe('PropertyValueCells extra', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('normalizes relationship property keys and positions the display-mode menu within the viewport', () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue(makeRect(100, 20))
const onSelect = vi.fn()
render(
<DisplayModeSelector
propKey=" Belongs-To "
currentMode="text"
autoMode="text"
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-menu').style.left).toBe('8px')
expect(screen.getByTestId('display-mode-menu').style.top).toBe('24px')
const backdrop = Array.from(document.body.querySelectorAll('div')).find(
(node) => node.className === 'fixed inset-0 z-[12000]',
)
fireEvent.click(backdrop as HTMLDivElement)
expect(screen.queryByTestId('display-mode-menu')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-date'))
expect(onSelect).toHaveBeenCalledWith(' Belongs-To ', 'date')
rectSpy.mockRestore()
})
it('closes empty date pickers without saving and ignores undefined selections', () => {
const onSave = vi.fn()
const onStartEdit = vi.fn()
render(
<SmartPropertyValueCell
propKey="Due"
value=""
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('date-picker-empty'))
fireEvent.click(screen.getByTestId('popover-close'))
expect(onSave).not.toHaveBeenCalled()
expect(onStartEdit).toHaveBeenCalledWith(null)
})
it('renders number displays, restores invalid input on escape, and falls back to editable text', () => {
const onSave = vi.fn()
const onStartEdit = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('number-display'))
expect(onStartEdit).toHaveBeenCalledWith('Count')
rerender(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'oops' } })
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Escape' })
expect(onStartEdit).toHaveBeenCalledWith(null)
rerender(
<SmartPropertyValueCell
propKey="Title"
value="Plain text"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('editable-value'))
expect(onSave).toHaveBeenCalledWith('Title', 'Plain text-saved')
})
it('handles string booleans, tag toggles, and tag removals', () => {
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const onUpdate = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Published"
value="false"
displayMode="boolean"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
onUpdate={onUpdate}
/>,
)
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
expect(checkbox.checked).toBe(false)
fireEvent.click(checkbox)
expect(onUpdate).toHaveBeenCalledWith('Published', true)
rerender(
<SmartPropertyValueCell
propKey="Archived"
value={0}
displayMode="boolean"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
onUpdate={onUpdate}
/>,
)
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
rerender(
<SmartPropertyValueCell
propKey="Tags"
value={['alpha']}
displayMode="tags"
isEditing={true}
vaultStatuses={[]}
vaultTags={['alpha', 'beta']}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('tags-toggle-alpha'))
fireEvent.click(screen.getByTitle('Remove alpha'))
fireEvent.click(screen.getByTestId('tags-add-button'))
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
expect(onStartEdit).toHaveBeenCalledWith('Tags')
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
rerender(
<SmartPropertyValueCell
propKey="Category"
value="solo"
displayMode="tags"
isEditing={true}
vaultStatuses={[]}
vaultTags={['solo', 'beta']}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
expect(onSaveList).toHaveBeenCalledWith('Category', ['solo', 'beta'])
})
})

View File

@@ -0,0 +1,390 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() => {
const renderMockActionMenu = (
containerTestId: string,
actions: Array<{ testId: string; label: string; onClick: () => void }>,
) => (
<div data-testid={containerTestId}>
{actions.map(({ testId, label, onClick }) => (
<button key={testId} data-testid={testId} onClick={onClick}>
{label}
</button>
))}
</div>
)
const createDropdownModule = (
exportName: string,
containerTestId: string,
createActions: (callbacks: Record<string, (...args: never[]) => void>) => Array<{
testId: string
label: string
onClick: () => void
}>,
) => ({
[exportName]: (callbacks: Record<string, (...args: never[]) => void>) =>
renderMockActionMenu(containerTestId, createActions(callbacks)),
})
const createPrimarySecondaryActions = (options: {
primary: { testId: string; label: string; onClick: () => void }
secondary: { testId: string; label: string; onClick: () => void }
}) => [options.primary, options.secondary]
return { createDropdownModule, createPrimarySecondaryActions }
})
vi.mock('./EditableValue', () => ({
EditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="editable-value" onClick={() => onSave(`${value}-saved`)}>
{value}
</button>
),
TagPillList: ({
items,
label,
onSave,
}: {
items: string[]
label: string
onSave: (items: string[]) => void
}) => (
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'gamma'])}>
{label}:{items.join(',')}
</button>
),
UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="url-value" onClick={() => onSave('https://saved.example')}>
{value}
</button>
),
}))
vi.mock('./StatusDropdown', () =>
createDropdownModule('StatusDropdown', 'status-dropdown', ({ onSave, onCancel }) =>
createPrimarySecondaryActions({
primary: { testId: 'status-save', label: 'save', onClick: () => onSave('Done') },
secondary: { testId: 'status-cancel', label: 'cancel', onClick: onCancel },
})),
)
vi.mock('./TagsDropdown', () =>
createDropdownModule('TagsDropdown', 'tags-dropdown', ({ onToggle, onClose }) =>
createPrimarySecondaryActions({
primary: { testId: 'tags-toggle', label: 'toggle', onClick: () => onToggle('beta') },
secondary: { testId: 'tags-close', label: 'close', onClick: onClose },
})),
)
vi.mock('./ColorInput', () => ({
ColorEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="color-value" onClick={() => onSave('#00ff00')}>
{value}
</button>
),
}))
vi.mock('./IconEditableValue', () => ({
IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="icon-value" onClick={() => onSave('sparkles')}>
{value}
</button>
),
}))
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => (
<button
data-testid="date-picker-calendar"
onClick={() => onSelect(new Date(2026, 3, 22))}
>
pick
</button>
),
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
}))
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
describe('PropertyValueCells', () => {
it('shows the relationship icon and resets to auto mode when the auto option is selected', () => {
const onSelect = vi.fn()
render(
<DisplayModeSelector
propKey="Related to"
currentMode="text"
autoMode="number"
onSelect={onSelect}
/>,
)
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-number'))
expect(onSelect).toHaveBeenCalledWith('Related to', null)
})
it('selects explicit display modes from the menu', () => {
const onSelect = vi.fn()
render(
<DisplayModeSelector
propKey="Status"
currentMode="status"
autoMode="text"
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-date'))
expect(onSelect).toHaveBeenCalledWith('Status', 'date')
})
it('handles status and tags editing interactions', () => {
const onSave = vi.fn()
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Status"
value="Doing"
displayMode="status"
isEditing={true}
vaultStatuses={['Doing', 'Done']}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('status-save'))
fireEvent.click(screen.getByTestId('status-cancel'))
expect(onSave).toHaveBeenCalledWith('Status', 'Done')
expect(onStartEdit).toHaveBeenCalledWith(null)
rerender(
<SmartPropertyValueCell
propKey="Tags"
value={['alpha']}
displayMode="tags"
isEditing={true}
vaultStatuses={[]}
vaultTags={['alpha', 'beta']}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTitle('Remove alpha'))
fireEvent.click(screen.getByTestId('tags-add-button'))
fireEvent.click(screen.getByTestId('tags-toggle'))
fireEvent.click(screen.getByTestId('tags-close'))
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
expect(onStartEdit).toHaveBeenCalledWith('Tags')
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
expect(onStartEdit).toHaveBeenCalledWith(null)
})
it('covers number and date editing branches', () => {
const onSave = vi.fn()
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '' } })
fireEvent.blur(screen.getByTestId('number-input'))
expect(onSave).toHaveBeenCalledWith('Count', '')
rerender(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '19' } })
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('Count', '19')
rerender(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'nope' } })
fireEvent.blur(screen.getByTestId('number-input'))
expect(onStartEdit).toHaveBeenCalledWith(null)
rerender(
<SmartPropertyValueCell
propKey="Due"
value="2026-04-20"
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('date-picker-calendar'))
expect(onSave).toHaveBeenCalledWith('Due', '2026-04-22')
rerender(
<SmartPropertyValueCell
propKey="Due"
value="2026-04-20"
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('date-picker-clear'))
expect(onSave).toHaveBeenCalledWith('Due', '')
})
it('auto-detects scalar display modes and delegates array values correctly', () => {
const onSave = vi.fn()
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const onUpdate = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Flag"
value={true}
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
/>,
)
fireEvent.click(screen.getByRole('checkbox'))
expect(onUpdate).toHaveBeenCalledWith('Flag', false)
rerender(
<SmartPropertyValueCell
propKey="Website"
value="https://example.com"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('url-value'))
expect(onSave).toHaveBeenCalledWith('Website', 'https://saved.example')
rerender(
<SmartPropertyValueCell
propKey="Accent"
value="#ff0000"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('color-value'))
expect(onSave).toHaveBeenCalledWith('Accent', '#00ff00')
rerender(
<SmartPropertyValueCell
propKey="_icon"
value="spark"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('icon-value'))
expect(onSave).toHaveBeenCalledWith('_icon', 'sparkles')
rerender(
<SmartPropertyValueCell
propKey="Labels"
value={['alpha', 'beta']}
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('tag-pill-list'))
expect(onSaveList).toHaveBeenCalledWith('Labels', ['alpha', 'beta', 'gamma'])
})
})

View File

@@ -0,0 +1,315 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MutableRefObject } from 'react'
const {
buildRawEditorAutocompleteStateMock,
buildRawEditorBaseItemsMock,
buildTypeEntryMapMock,
detectYamlErrorMock,
extractWikilinkQueryMock,
getRawEditorDropdownPositionMock,
noteSearchListState,
replaceActiveWikilinkQueryMock,
trackEventMock,
useCodeMirrorMock,
viewRefState,
} = vi.hoisted(() => ({
buildRawEditorAutocompleteStateMock: vi.fn(),
buildRawEditorBaseItemsMock: vi.fn(),
buildTypeEntryMapMock: vi.fn(),
detectYamlErrorMock: vi.fn(),
extractWikilinkQueryMock: vi.fn(),
getRawEditorDropdownPositionMock: vi.fn(),
noteSearchListState: { lastProps: null as null | Record<string, unknown> },
replaceActiveWikilinkQueryMock: vi.fn(),
trackEventMock: vi.fn(),
useCodeMirrorMock: vi.fn(),
viewRefState: { current: null as null | Record<string, unknown> },
}))
vi.mock('../hooks/useCodeMirror', () => ({
useCodeMirror: useCodeMirrorMock,
}))
vi.mock('../utils/rawEditorUtils', () => ({
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
detectYamlError: detectYamlErrorMock,
extractWikilinkQuery: extractWikilinkQueryMock,
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: buildTypeEntryMapMock,
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
vi.mock('./NoteSearchList', () => ({
NoteSearchList: (props: {
items: Array<{ title: string; onItemClick: () => void }>
onItemClick: (item: { title: string; onItemClick: () => void }) => void
onItemHover: (index: number) => void
selectedIndex: number
}) => {
noteSearchListState.lastProps = props
return (
<div data-testid="note-search-list">
{props.items.map((item, index) => (
<button
key={item.title}
data-testid={`note-search-item-${index}`}
onMouseEnter={() => props.onItemHover(index)}
onClick={() => props.onItemClick(item)}
>
{item.title}
</button>
))}
<div data-testid="note-search-selected-index">{props.selectedIndex}</div>
</div>
)
},
}))
import { RawEditorView } from './RawEditorView'
function entry(title: string, path = `/vault/note/${title}.md`) {
return {
path,
filename: `${title}.md`,
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
}
}
function createMockView(docText = '[[Target') {
return {
state: {
doc: { toString: () => docText },
selection: { main: { head: docText.length } },
},
dispatch: vi.fn(),
focus: vi.fn(),
}
}
describe('RawEditorView behavior coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
noteSearchListState.lastProps = null
viewRefState.current = createMockView()
useCodeMirrorMock.mockImplementation((_containerRef: unknown, _content: string, callbacks: unknown) => {
useCodeMirrorMock.mock.calls[useCodeMirrorMock.mock.calls.length - 1]![2] = callbacks
return viewRefState
})
buildRawEditorBaseItemsMock.mockReturnValue([{ title: 'Base item' }])
buildTypeEntryMapMock.mockReturnValue({ Note: { title: 'Note' } })
detectYamlErrorMock.mockImplementation((doc: string) => (
doc.includes('broken') ? 'Broken YAML' : null
))
extractWikilinkQueryMock.mockReturnValue(null)
getRawEditorDropdownPositionMock.mockReturnValue({ top: 12, left: 34 })
replaceActiveWikilinkQueryMock.mockReturnValue({
text: '[[Inserted]]',
cursor: 12,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('debounces content changes, exposes the latest content ref, flushes saves, and cleans up on unmount', () => {
const onContentChange = vi.fn()
const onSave = vi.fn()
const latestContentRef = { current: null } as MutableRefObject<string | null>
const { rerender, unmount } = render(
<RawEditorView
content="---\ntitle: Start\n---"
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={onContentChange}
onSave={onSave}
latestContentRef={latestContentRef}
/>,
)
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
onDocChange: (doc: string) => void
onSave: () => void
}
act(() => {
callbacks.onDocChange('broken content')
})
expect(latestContentRef.current).toBe('broken content')
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Broken YAML')
act(() => {
vi.advanceTimersByTime(500)
})
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', 'broken content')
rerender(
<RawEditorView
content="fixed"
path="/vault/b.md"
entries={[entry('Alpha')]}
onContentChange={onContentChange}
onSave={onSave}
latestContentRef={latestContentRef}
/>,
)
act(() => {
callbacks.onDocChange('pending change')
callbacks.onSave()
})
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'pending change')
expect(onSave).toHaveBeenCalledTimes(1)
act(() => {
callbacks.onDocChange('flush on unmount')
})
unmount()
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'flush on unmount')
})
it('opens the autocomplete dropdown, updates selection, inserts wikilinks, and tracks the insert event', () => {
extractWikilinkQueryMock.mockReturnValue('alp')
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
items: [
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
],
selectedIndex: 0,
}))
const onContentChange = vi.fn()
const mockView = createMockView('[[alp')
viewRefState.current = mockView
render(
<RawEditorView
content="[[alp"
path="/vault/a.md"
entries={[entry('Alpha'), entry('Beta')]}
onContentChange={onContentChange}
onSave={vi.fn()}
vaultPath="/vault"
/>,
)
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
onCursorActivity: (view: unknown) => void
onEscape: () => boolean
}
act(() => {
callbacks.onCursorActivity(mockView)
})
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalledWith(expect.objectContaining({
query: 'alp',
vaultPath: '/vault',
}))
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowDown' })
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowUp' })
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
fireEvent.mouseEnter(screen.getByTestId('note-search-item-1'))
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'Enter' })
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('[[alp', '[[alp'.length, 'Beta')
expect(mockView.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 5, insert: '[[Inserted]]' },
selection: { anchor: 12 },
})
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', '[[Inserted]]')
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
expect(mockView.focus).toHaveBeenCalledTimes(1)
expect(callbacks.onEscape()).toBe(false)
})
it('clears autocomplete when the query is too short and reports escape handling while open', () => {
extractWikilinkQueryMock
.mockReturnValueOnce('a')
.mockReturnValueOnce('alpha')
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
items: [
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
],
selectedIndex: 0,
}))
const mockView = createMockView('[[alpha')
viewRefState.current = mockView
render(
<RawEditorView
content="[[alpha"
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={vi.fn()}
onSave={vi.fn()}
/>,
)
let callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
onCursorActivity: (view: unknown) => void
onEscape: () => boolean
}
act(() => {
callbacks.onCursorActivity(mockView)
})
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
act(() => {
callbacks.onCursorActivity(mockView)
})
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
expect(callbacks.onEscape()).toBe(true)
})
})

View File

@@ -0,0 +1,276 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
trackEventMock,
buildTypeEntryMapMock,
buildRawEditorBaseItemsMock,
detectYamlErrorMock,
extractWikilinkQueryMock,
buildRawEditorAutocompleteStateMock,
getRawEditorDropdownPositionMock,
replaceActiveWikilinkQueryMock,
useCodeMirrorMock,
} = vi.hoisted(() => ({
trackEventMock: vi.fn(),
buildTypeEntryMapMock: vi.fn(() => new Map()),
buildRawEditorBaseItemsMock: vi.fn(() => []),
detectYamlErrorMock: vi.fn(() => null),
extractWikilinkQueryMock: vi.fn(() => null),
buildRawEditorAutocompleteStateMock: vi.fn(),
getRawEditorDropdownPositionMock: vi.fn(() => ({ top: 48, left: 96 })),
replaceActiveWikilinkQueryMock: vi.fn(),
useCodeMirrorMock: vi.fn(),
}))
type CodeMirrorCallbacks = {
onCursorActivity: (view: unknown) => void
onDocChange: (doc: string) => void
onEscape: () => boolean
onSave: () => void
}
let latestCallbacks: CodeMirrorCallbacks | null = null
let latestViewRef: MutableRefObject<{
dispatch: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
state: {
doc: { toString: () => string }
selection: { main: { head: number } }
}
} | null>
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: buildTypeEntryMapMock,
}))
vi.mock('../utils/rawEditorUtils', () => ({
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
detectYamlError: detectYamlErrorMock,
extractWikilinkQuery: extractWikilinkQueryMock,
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
}))
vi.mock('../hooks/useCodeMirror', () => ({
useCodeMirror: useCodeMirrorMock,
}))
import { RawEditorView } from './RawEditorView'
vi.mock('./NoteSearchList', () => ({
NoteSearchList: ({
items,
selectedIndex,
onItemClick,
onItemHover,
}: {
items: Array<{ title: string }>
selectedIndex: number
onItemClick: (item: { title: string }) => void
onItemHover: (index: number) => void
}) => (
<div data-testid="raw-editor-note-search-list">
{items.map((item, index) => (
<button
key={item.title}
data-testid={`autocomplete-item-${index}`}
data-selected={index === selectedIndex}
onMouseEnter={() => onItemHover(index)}
onClick={() => onItemClick(item)}
>
{item.title}
</button>
))}
</div>
),
}))
function createEntry(title: string) {
return {
path: `/vault/${title.toLowerCase().replace(/\s+/g, '-')}.md`,
filename: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
}
}
const defaultProps = {
content: '# Raw note',
path: '/vault/raw-note.md',
entries: [createEntry('Alpha'), createEntry('Beta')],
onContentChange: vi.fn(),
onSave: vi.fn(),
vaultPath: '/vault',
}
function renderView(overrides: Partial<typeof defaultProps> = {}) {
const props = { ...defaultProps, ...overrides }
render(<RawEditorView {...props} />)
return props
}
describe('RawEditorView additional coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
latestViewRef = {
current: {
dispatch: vi.fn(),
focus: vi.fn(),
state: {
doc: { toString: () => 'Before [[Al' },
selection: { main: { head: 11 } },
},
},
}
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }) => ({
selectedIndex: 0,
items: [
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
],
}))
replaceActiveWikilinkQueryMock.mockReturnValue({
text: 'Before [[Alpha]]',
cursor: 15,
})
useCodeMirrorMock.mockImplementation((_container, _content, callbacks: CodeMirrorCallbacks) => {
latestCallbacks = callbacks
return latestViewRef
})
})
it('debounces content updates, exposes latest content, flushes on save, and flushes pending edits on unmount', async () => {
vi.useFakeTimers()
const latestContentRef = { current: null as string | null }
const onContentChange = vi.fn()
const onSave = vi.fn()
const { unmount } = render(
<RawEditorView
{...defaultProps}
latestContentRef={latestContentRef}
onContentChange={onContentChange}
onSave={onSave}
/>,
)
expect(latestContentRef.current).toBe('# Raw note')
act(() => {
latestCallbacks?.onDocChange('draft 1')
latestCallbacks?.onDocChange('draft 2')
})
expect(onContentChange).not.toHaveBeenCalled()
expect(latestContentRef.current).toBe('draft 2')
await act(async () => {
vi.advanceTimersByTimeAsync(500)
})
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 2')
act(() => {
latestCallbacks?.onDocChange('draft 3')
latestCallbacks?.onSave()
})
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 3')
expect(onSave).toHaveBeenCalledTimes(1)
act(() => {
latestCallbacks?.onDocChange('draft 4')
})
unmount()
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 4')
})
it('renders YAML errors from the parser result', () => {
detectYamlErrorMock.mockReturnValue('Missing closing delimiter')
renderView()
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Missing closing delimiter')
})
it('opens autocomplete from cursor activity, navigates items, inserts a wikilink, and closes on escape', async () => {
extractWikilinkQueryMock.mockReturnValue('Al')
const onContentChange = vi.fn()
renderView({ onContentChange })
act(() => {
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
})
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalled()
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
const presentation = screen.getByRole('presentation')
fireEvent.keyDown(presentation, { key: 'ArrowDown' })
fireEvent.keyDown(presentation, { key: 'ArrowUp' })
fireEvent.keyDown(presentation, { key: 'Enter' })
await waitFor(() => {
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('Before [[Al', 11, 'Alpha')
})
expect(latestViewRef.current?.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 'Before [[Al'.length, insert: 'Before [[Alpha]]' },
selection: { anchor: 15 },
})
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'Before [[Alpha]]')
expect(latestViewRef.current?.focus).toHaveBeenCalledTimes(1)
extractWikilinkQueryMock.mockReturnValue(null)
act(() => {
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
})
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
extractWikilinkQueryMock.mockReturnValue('Al')
act(() => {
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
})
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
let escaped = false
act(() => {
escaped = latestCallbacks?.onEscape() ?? false
})
expect(escaped).toBe(true)
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
})
})

View File

@@ -113,6 +113,16 @@ describe('buildDynamicSections', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'Daily Log', isA: 'Journal' },
]
const sections = buildDynamicSections(entries, {})
expect(sections.map((section) => section.type)).not.toContain('Journal')
})
it('includes Journal when a real Journal type definition exists', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'Daily Log', isA: 'journal' },
]
const typeEntryMap: Record<string, VaultEntry> = {
Journal: { ...baseEntry, title: 'Journal', isA: 'Type' },
journal: { ...baseEntry, title: 'Journal', isA: 'Type' },
@@ -120,7 +130,7 @@ describe('buildDynamicSections', () => {
const sections = buildDynamicSections(entries, typeEntryMap)
expect(sections.map((section) => section.type)).not.toContain('Journal')
expect(sections.filter((section) => section.type === 'Journal')).toHaveLength(1)
})
})

View File

@@ -517,11 +517,17 @@ describe('Sidebar', () => {
})
describe('type visibility via visible property', () => {
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
path: `/vault/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
const makeSidebarEntry = (overrides: {
path: string
filename: string
title: string
isA: string
visible?: boolean | null
}): VaultEntry => ({
path: overrides.path,
filename: overrides.filename,
title: overrides.title,
isA: overrides.isA,
aliases: [],
belongsTo: [],
relatedTo: [],
@@ -542,11 +548,19 @@ describe('Sidebar', () => {
template: null,
sort: null,
view: null,
visible,
visible: overrides.visible ?? null,
outgoingLinks: [],
properties: {},
})
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => makeSidebarEntry({
path: `/vault/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
visible,
})
it('hides a section when its Type entry has visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
@@ -620,6 +634,34 @@ describe('Sidebar', () => {
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
})
it('updates the sidebar type picker when Journal becomes a real type while the app stays open', () => {
const journalEntries: VaultEntry[] = [
...mockEntries,
makeSidebarEntry({
path: '/vault/april-21.md',
filename: 'april-21.md',
title: 'April 21',
isA: 'journal',
}),
]
const { rerender } = render(<Sidebar entries={journalEntries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
expect(screen.queryByLabelText('Toggle Journals')).not.toBeInTheDocument()
rerender(
<Sidebar
entries={[...journalEntries, makeTypeEntry('Journal', null)]}
selection={defaultSelection}
onSelect={() => {}}
/>,
)
expect(screen.getByLabelText('Toggle Journals')).toBeInTheDocument()
rerender(<Sidebar entries={journalEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByLabelText('Toggle Journals')).not.toBeInTheDocument()
})
it('preserves custom section colors in the customize popover', () => {
const entries: VaultEntry[] = [
...mockEntries,

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, memo } from 'react'
import { useCallback, memo } from 'react'
import type { VaultEntry, FolderNode, SidebarSelection, ViewFile } from '../types'
import {
KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent,
@@ -6,10 +6,8 @@ import {
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { FolderTree } from './FolderTree'
import {
applyCustomization,
computeReorder,
useEntryCounts,
useOutsideClick,
useSidebarCollapsed,
useSidebarSections,
} from './sidebar/sidebarHooks'
@@ -23,6 +21,7 @@ import {
TypesSection,
ViewsSection,
} from './sidebar/SidebarSections'
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
interface SidebarProps {
entries: VaultEntry[]
@@ -43,7 +42,12 @@ interface SidebarProps {
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => void
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
showInbox?: boolean
inboxCount?: number
onCollapse?: () => void
@@ -66,40 +70,30 @@ export const Sidebar = memo(function Sidebar({
onDeleteView,
folders = [],
onCreateFolder,
onRenameFolder,
onDeleteFolder,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
onCollapse,
onCreateNewType,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [renamingType, setRenamingType] = useState<string | null>(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [showCustomize, setShowCustomize] = useState(false)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const { activeCount, archivedCount } = useEntryCounts(entries)
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
const typeInteractions = useSidebarTypeInteractions({
allSectionGroups,
typeEntryMap,
onCustomizeType,
onUpdateTypeTemplate,
onRenameSection,
})
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const closeContextMenu = useCallback(() => {
setContextMenuPos(null)
setContextMenuType(null)
}, [])
const closeCustomize = useCallback(() => setShowCustomize(false), [])
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
useOutsideClick(customizeRef, showCustomize, closeCustomize)
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -112,44 +106,15 @@ export const Sidebar = memo(function Sidebar({
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => {
event.preventDefault()
event.stopPropagation()
setContextMenuPos({ x: event.clientX, y: event.clientY })
setContextMenuType(type)
}, [])
const cancelRename = useCallback(() => setRenamingType(null), [])
const handleStartRename = useCallback((type: string) => {
closeContextMenu()
const group = allSectionGroups.find((sectionGroup) => sectionGroup.type === type)
setRenameInitialValue(group?.label ?? type)
setRenamingType(type)
}, [allSectionGroups, closeContextMenu])
const handleRenameSubmit = useCallback((value: string) => {
if (renamingType) onRenameSection?.(renamingType, value)
setRenamingType(null)
}, [renamingType, onRenameSection])
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
}, [customizeTarget, typeEntryMap, onCustomizeType])
const handleChangeTemplate = useCallback((template: string) => {
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
}, [customizeTarget, onUpdateTypeTemplate])
const sectionProps: SidebarSectionProps = {
entries,
selection,
onSelect,
onContextMenu: handleContextMenu,
renamingType,
renameInitialValue,
onRenameSubmit: handleRenameSubmit,
onRenameCancel: cancelRename,
onContextMenu: typeInteractions.handleContextMenu,
renamingType: typeInteractions.renamingType,
renameInitialValue: typeInteractions.renameInitialValue,
onRenameSubmit: typeInteractions.handleRenameSubmit,
onRenameCancel: typeInteractions.cancelRename,
}
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
@@ -202,39 +167,41 @@ export const Sidebar = memo(function Sidebar({
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={showCustomize}
setShowCustomize={setShowCustomize}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={customizeRef}
customizeRef={typeInteractions.customizeRef}
/>
<FolderTree
folders={folders}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
onToggle={() => toggleGroup('folders')}
/>
</nav>
<ContextMenuOverlay
pos={contextMenuPos}
type={contextMenuType}
innerRef={contextMenuRef}
onOpenCustomize={(type) => {
closeContextMenu()
setCustomizeTarget(type)
}}
onStartRename={handleStartRename}
pos={typeInteractions.contextMenuPos}
type={typeInteractions.contextMenuType}
innerRef={typeInteractions.contextMenuRef}
onOpenCustomize={typeInteractions.openCustomizeTarget}
onStartRename={typeInteractions.handleStartRename}
/>
<CustomizeOverlay
target={customizeTarget}
target={typeInteractions.customizeTarget}
typeEntryMap={typeEntryMap}
innerRef={popoverRef}
onCustomize={handleCustomize}
onChangeTemplate={handleChangeTemplate}
onClose={closeCustomizeTarget}
innerRef={typeInteractions.popoverRef}
onCustomize={typeInteractions.handleCustomize}
onChangeTemplate={typeInteractions.handleChangeTemplate}
onClose={typeInteractions.closeCustomizeTarget}
/>
</aside>
)

View File

@@ -0,0 +1,304 @@
import { act, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
const state = vi.hoisted(() => ({
capturedToolbarProps: null as null | Record<string, unknown>,
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
wikilinkEntriesRef: { current: [] as VaultEntry[] },
}))
vi.mock('@blocknote/react', () => ({
ComponentsContext: {
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
},
BlockNoteViewRaw: (props: {
children?: ReactNode
editable?: boolean
className?: string
formattingToolbar?: boolean
slashMenu?: boolean
sideMenu?: boolean
}) => {
const {
children,
editable,
className,
formattingToolbar,
slashMenu,
sideMenu,
...restProps
} = props
void formattingToolbar
void slashMenu
void sideMenu
return (
<div
data-testid="blocknote-view"
data-editable={editable !== false ? 'true' : 'false'}
className={className}
{...restProps}
>
{children}
</div>
)
},
SideMenuController: () => <div data-testid="side-menu-controller" />,
SuggestionMenuController: (props: Record<string, unknown>) => {
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
return <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
},
useCreateBlockNote: vi.fn(),
}))
vi.mock('@blocknote/mantine', () => ({
components: {},
}))
vi.mock('@mantine/core', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
MantineContext: React.createContext(null),
MantineProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}
})
vi.mock('../hooks/useTheme', () => ({
useEditorTheme: () => ({ cssVars: { '--editor-accent': '#abc' } }),
}))
vi.mock('../hooks/useImageDrop', () => ({
useImageDrop: (args: Record<string, unknown>) => {
state.capturedImageDropArgs = args
return state.imageDropState
},
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: () => ({}),
}))
vi.mock('../utils/wikilinkSuggestions', () => ({
MIN_QUERY_LENGTH: 2,
deduplicateByPath: <T,>(items: T[]) => items,
preFilterWikilinks: () => [],
}))
vi.mock('../utils/personMentionSuggestions', () => ({
PERSON_MENTION_MIN_QUERY: 1,
filterPersonMentions: () => [],
}))
vi.mock('../utils/suggestionEnrichment', () => ({
attachClickHandlers: <T,>(items: T[]) => items,
enrichSuggestionItems: <T,>(items: T[]) => items,
}))
vi.mock('./WikilinkSuggestionMenu', () => ({
WikilinkSuggestionMenu: () => <div data-testid="wikilink-suggestion-menu" />,
}))
vi.mock('./editorSchema', () => ({
_wikilinkEntriesRef: state.wikilinkEntriesRef,
}))
vi.mock('./blockNoteSideMenuHoverGuard', () => ({
useBlockNoteSideMenuHoverGuard: (containerRef: unknown) => state.hoverGuardMock(containerRef),
}))
vi.mock('./tolariaEditorFormattingConfig', () => ({
getTolariaSlashMenuItems: vi.fn(async () => []),
}))
vi.mock('./tolariaEditorFormatting', () => ({
TolariaFormattingToolbar: () => <div data-testid="tolaria-formatting-toolbar" />,
TolariaFormattingToolbarController: (props: Record<string, unknown>) => {
state.capturedToolbarProps = props
return <div data-testid="tolaria-formatting-toolbar-controller" />
},
}))
vi.mock('./tolariaBlockNoteSideMenu', () => ({
TolariaSideMenu: () => <div data-testid="tolaria-side-menu" />,
}))
vi.mock('./useEditorLinkActivation', () => ({
useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown) => (
state.linkActivationMock(containerRef, onNavigateWikilink)
),
}))
import { SingleEditorView } from './SingleEditorView'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/project/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1,
createdAt: 1,
fileSize: 10,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
function createEditor() {
const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] }
return {
document: [
{ id: 'heading-block', type: 'heading', content: [], children: [] },
cursorBlock,
],
tryParseMarkdownToBlocks: vi.fn(async () => [
{ type: 'table', content: { type: 'tableContent' } },
]),
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
_tiptapEditor: { commands: { setContent: vi.fn() } },
focus: vi.fn(),
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
insertBlocks: vi.fn(),
insertInlineContent: vi.fn(),
setTextCursorPosition: vi.fn(),
}
}
describe('SingleEditorView', () => {
beforeEach(() => {
vi.clearAllMocks()
state.capturedToolbarProps = null
state.capturedSuggestionProps = {}
state.capturedImageDropArgs = null
state.imageDropState.isDragOver = false
state.wikilinkEntriesRef.current = []
delete window.__laputaTest
})
it('registers the seeded BlockNote test bridge, applies column widths, and cleans it up on unmount', async () => {
const editor = createEditor()
const entries = [makeEntry()]
const { unmount } = render(
<SingleEditorView
editor={editor as never}
entries={entries}
onNavigateWikilink={vi.fn()}
/>,
)
expect(state.wikilinkEntriesRef.current).toEqual(entries)
expect(typeof window.__laputaTest?.seedBlockNoteTable).toBe('function')
await act(async () => {
await window.__laputaTest?.seedBlockNoteTable?.([120, null, 80])
})
expect(editor.blocksToHTMLLossy).toHaveBeenCalledWith([
expect.objectContaining({
type: 'table',
content: expect.objectContaining({
type: 'tableContent',
columnWidths: [120, null, 80],
}),
}),
expect.objectContaining({ type: 'paragraph' }),
])
expect(editor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<table>seeded</table>')
expect(editor.focus).toHaveBeenCalled()
unmount()
expect(window.__laputaTest?.seedBlockNoteTable).toBeUndefined()
})
it('shows the drag overlay and inserts dropped images after the active cursor block', () => {
state.imageDropState.isDragOver = true
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
expect(screen.getByText('Drop image here')).toBeInTheDocument()
act(() => {
(state.capturedImageDropArgs?.onImageUrl as (url: string) => void)('https://example.com/image.png')
})
expect(editor.insertBlocks).toHaveBeenCalledWith(
[{ type: 'image', props: { url: 'https://example.com/image.png' } }],
expect.objectContaining({ id: 'cursor-block' }),
'after',
)
})
it('wires the toolbar mouse guard and suggestion item click handlers', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
expect(state.hoverGuardMock).toHaveBeenCalledOnce()
expect(state.linkActivationMock).toHaveBeenCalledOnce()
const onMouseDownCapture = (
(state.capturedToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } })
).elementProps.onMouseDownCapture
const menuTrigger = document.createElement('button')
menuTrigger.setAttribute('aria-haspopup', 'menu')
const menuPreventDefault = vi.fn()
onMouseDownCapture({ target: menuTrigger, preventDefault: menuPreventDefault })
expect(menuPreventDefault).not.toHaveBeenCalled()
const normalTarget = document.createElement('div')
const normalPreventDefault = vi.fn()
onMouseDownCapture({ target: normalTarget, preventDefault: normalPreventDefault })
expect(normalPreventDefault).toHaveBeenCalledOnce()
const onWikiItemClick = vi.fn()
const onMentionItemClick = vi.fn()
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onWikiItemClick })
;(state.capturedSuggestionProps['@'].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onMentionItemClick })
expect(onWikiItemClick).toHaveBeenCalledOnce()
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
})

View File

@@ -128,6 +128,128 @@ function shouldIgnoreContainerClick(target: HTMLElement) {
)
}
function normalizeSuggestionQuery(query: string, triggerCharacter: string): string {
return query.startsWith(triggerCharacter)
? query.slice(triggerCharacter.length)
: query
}
function isSelectionInsideElement(element: HTMLElement): boolean {
const selection = window.getSelection()
const anchorNode = selection?.anchorNode ?? null
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
return Boolean(anchorElement && element.contains(anchorElement))
}
function queueTitleHeadingCursorRepair(
target: HTMLElement,
editor: ReturnType<typeof useCreateBlockNote>,
): boolean {
const titleHeading = target.closest<HTMLElement>(
'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
)
if (!titleHeading) return false
queueMicrotask(() => {
if (isSelectionInsideElement(titleHeading)) return
const firstBlock = editor.document[0]
if (firstBlock?.type !== 'heading') return
editor.setTextCursorPosition(firstBlock.id, 'end')
editor.focus()
})
return true
}
function useEditorContainerClickHandler(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
}) {
const { editable, editor } = options
return useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
const target = e.target as HTMLElement
if (queueTitleHeadingCursorRepair(target, editor)) return
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
}
editor.focus()
}, [editor, editable])
}
function buildBaseSuggestionItems(entries: VaultEntry[]) {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
})))
}
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
return useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
}, [editor])
}
function useSuggestionMenuItems(options: {
baseItems: ReturnType<typeof buildBaseSuggestionItems>
editor: ReturnType<typeof useCreateBlockNote>
insertWikilink: (target: string) => void
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
}) {
const {
baseItems,
editor,
insertWikilink,
typeEntryMap,
vaultPath,
} = options
const buildItems = useCallback((query: string, triggerCharacter: '[[' | '@') => {
const normalizedQuery = normalizeSuggestionQuery(query, triggerCharacter)
const minLength = triggerCharacter === '[[' ? MIN_QUERY_LENGTH : PERSON_MENTION_MIN_QUERY
if (normalizedQuery.length < minLength) return null
const candidates = triggerCharacter === '[['
? preFilterWikilinks(baseItems, normalizedQuery)
: filterPersonMentions(baseItems, normalizedQuery)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '[[') ?? []
), [buildItems])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '@') ?? []
), [buildItems])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
return {
getWikilinkItems,
getPersonMentionItems,
getSlashMenuItems,
}
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -150,22 +272,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
}) {
const { cssVars } = useEditorTheme()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
useBlockNoteSideMenuHoverGuard(containerRef)
useEditorLinkActivation(containerRef, onNavigateWikilink)
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
const target = e.target as HTMLElement
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
}
editor.focus()
}, [editor, editable])
useEffect(() => {
_wikilinkEntriesRef.current = entries
}, [entries])
@@ -173,44 +285,19 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
useSeedBlockNoteTableBridge(editor)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
}))),
[entries]
)
const insertWikilink = useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
])
trackEvent('wikilink_inserted')
}, [editor])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < MIN_QUERY_LENGTH) return []
const candidates = preFilterWikilinks(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const candidates = filterPersonMentions(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
const {
getWikilinkItems,
getPersonMentionItems,
getSlashMenuItems,
} = useSuggestionMenuItems({
baseItems,
editor,
insertWikilink,
typeEntryMap,
vaultPath,
})
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>

View File

@@ -224,7 +224,7 @@ describe('StatusBar', () => {
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
})
it('exposes a hover-revealed remove action for non-active vaults', () => {
it('exposes an in-row, hover-revealed remove action for non-active vaults', () => {
render(
<StatusBar
noteCount={100}
@@ -237,8 +237,16 @@ describe('StatusBar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByTestId('vault-menu-item-Work Vault').className).toContain('hover:bg-[var(--hover)]')
expect(screen.getByTestId('vault-menu-remove-Work Vault').className).toContain('group-hover:opacity-100')
const item = screen.getByTestId('vault-menu-item-Work Vault')
const removeAction = screen.getByTestId('vault-menu-remove-Work Vault')
expect(item.className).toContain('hover:bg-[var(--hover)]')
expect(item.className).toContain('pr-7')
expect(removeAction.className).toContain('absolute')
expect(removeAction.className).toContain('right-1')
expect(removeAction.className).toContain('group-hover:opacity-100')
expect(removeAction.className).toContain('group-focus-within:opacity-100')
expect(removeAction.className).toContain('pointer-events-none')
expect(screen.getByRole('button', { name: 'Remove Work Vault from list' })).toBeInTheDocument()
})
@@ -308,15 +316,7 @@ describe('StatusBar', () => {
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'MCP server not installed — click to install' }), 'MCP server not installed — click to install')
})
it('shows MCP warning badge when status is no_claude_cli', async () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'Claude CLI not found — install it first' }), 'Claude CLI not found — install it first')
await expectTooltip(screen.getByRole('button', { name: 'External AI tools not connected — click to set up' }), 'External AI tools not connected — click to set up')
})
it('hides MCP badge when status is installed', () => {
@@ -349,15 +349,6 @@ describe('StatusBar', () => {
expect(onInstallMcp).toHaveBeenCalledOnce()
})
it('does not call onInstallMcp when clicking MCP badge with no_claude_cli status', () => {
const onInstallMcp = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" onInstallMcp={onInstallMcp} />
)
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
it('shows Pull required label when syncStatus is pull_required', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as statusStyles from '../utils/statusStyles'
import { StatusDropdown } from './StatusDropdown'
describe('StatusDropdown extra coverage', () => {
const onSave = vi.fn()
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('wraps keyboard navigation to the create option and scrolls highlighted items into view', () => {
const scrollSpy = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {})
render(
<StatusDropdown
value="Active"
vaultStatuses={['Doing']}
onSave={onSave}
onCancel={onCancel}
/>,
)
const input = screen.getByTestId('status-search-input')
fireEvent.change(input, { target: { value: 'Needs Review' } })
fireEvent.keyDown(input, { key: 'ArrowUp' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(scrollSpy).toHaveBeenCalled()
expect(onSave).toHaveBeenCalledWith('Needs Review')
scrollSpy.mockRestore()
})
it('opens color pickers and persists the selected accent color', () => {
const setStatusColorSpy = vi.spyOn(statusStyles, 'setStatusColor')
render(
<StatusDropdown
value="Active"
vaultStatuses={['Active']}
onSave={onSave}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByTestId('status-color-swatch-Active'))
expect(screen.getByTestId('color-picker-Active')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('color-option-green'))
expect(setStatusColorSpy).toHaveBeenCalledWith('Active', 'green')
expect(screen.queryByTestId('color-picker-Active')).not.toBeInTheDocument()
})
})

View File

@@ -118,7 +118,7 @@ function MissingTypeWarning({
onCreateMissingType,
}: {
missingTypeName: string
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const canCreateMissingType = Boolean(onCreateMissingType)
@@ -148,9 +148,7 @@ function MissingTypeWarning({
<CreateTypeDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onCreate={async (typeName) => {
await onCreateMissingType?.(typeName)
}}
onCreate={(typeName) => onCreateMissingType?.(typeName)}
initialName={missingTypeName}
/>
)}
@@ -165,7 +163,7 @@ function TypeRowValue({
}: {
children: ReactNode
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
return (
<div className="flex min-w-0 items-center justify-start gap-1">
@@ -191,7 +189,7 @@ function ReadOnlyType({
customColorKey?: string | null
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
if (!isA) return null
return (
@@ -230,7 +228,7 @@ interface TypeSelectorProps {
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {

View File

@@ -1,6 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
import tolariaIcon from '@/assets/tolaria-icon.svg'
const dragRegionMouseDown = vi.fn()
@@ -30,19 +31,28 @@ describe('WelcomeScreen', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
})
it('shows all three option buttons', () => {
it('renders the local Tolaria branding icon', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
const brandIcon = screen.getByAltText('Tolaria icon')
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
})
it('shows the onboarding actions in the guided-first order', () => {
render(<WelcomeScreen {...defaultProps} />)
const optionButtons = screen.getAllByRole('button')
expect(optionButtons[0]).toBe(screen.getByTestId('welcome-create-vault'))
expect(optionButtons[1]).toBe(screen.getByTestId('welcome-create-new'))
expect(optionButtons[2]).toBe(screen.getByTestId('welcome-open-folder'))
})
it('focuses the first action for keyboard users', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
expect(screen.getByTestId('welcome-create-vault')).toHaveFocus()
})
it('shows the simplified template option description', () => {
@@ -101,13 +111,13 @@ describe('WelcomeScreen', () => {
})
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.keyDown(window, { key: 'Tab' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(onOpenFolder).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('disables all buttons while creating', () => {

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
import tolariaIcon from '@/assets/tolaria-icon.svg'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
justifyContent: 'center',
}
const BRAND_ICON_STYLE: React.CSSProperties = {
width: 64,
height: 64,
display: 'block',
}
const TITLE_STYLE: React.CSSProperties = {
fontSize: 28,
fontWeight: 700,
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
): WelcomeScreenPresentation {
if (mode === 'welcome') {
return {
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>,
heroBackground: 'transparent',
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
openFolderLabel: 'Open existing vault',
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
subtitle: 'Markdown knowledge management for the age of AI',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: 'Download the getting started vault',
@@ -303,18 +310,18 @@ function useWelcomeActionButtons({
> & {
busy: boolean
}) {
const primaryActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const templateActionRef = useRef<HTMLButtonElement>(null)
const createEmptyActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo(
() => [primaryActionRef, openFolderActionRef, templateActionRef],
() => [templateActionRef, createEmptyActionRef, openFolderActionRef],
[],
)
const actions = useMemo<WelcomeAction[]>(
() => ([
{ disabled: isOffline, run: onCreateVault },
{ disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder },
{ disabled: isOffline, run: onCreateVault },
]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
)
@@ -323,7 +330,7 @@ function useWelcomeActionButtons({
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
// onboarding always starts on "Create empty vault".
// onboarding always starts on the guided template flow.
focusWelcomeAction(actionButtonRefs, 0)
}, [actionButtonRefs, busy, mode])
@@ -355,9 +362,9 @@ function useWelcomeActionButtons({
}, [actionButtonRefs, actions, busy])
return {
primaryActionRef,
openFolderActionRef,
templateActionRef,
createEmptyActionRef,
openFolderActionRef,
}
}
@@ -375,7 +382,7 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
mode,
busy,
isOffline,
@@ -410,6 +417,21 @@ export function WelcomeScreen({
<div style={DIVIDER_STYLE} />
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
autoFocus
buttonRef={templateActionRef}
/>
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
@@ -421,8 +443,7 @@ export function WelcomeScreen({
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
buttonRef={primaryActionRef}
buttonRef={createEmptyActionRef}
/>
<OptionButton
@@ -435,20 +456,6 @@ export function WelcomeScreen({
testId="welcome-open-folder"
buttonRef={openFolderActionRef}
/>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
buttonRef={templateActionRef}
/>
</div>
{creatingAction === 'template' && (

View File

@@ -24,16 +24,16 @@ interface WikilinkSuggestionMenuProps {
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
return (
<div className="wikilink-menu">
<NoteSearchList
items={items}
selectedIndex={selectedIndex ?? 0}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => {
item.onItemClick()
onItemClick?.(item)
}}
emptyMessage="No results"
/>
<NoteSearchList
items={items}
selectedIndex={selectedIndex ?? 0}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => {
item.onItemClick()
onItemClick?.(item)
}}
emptyMessage="No results"
/>
</div>
)
}

View File

@@ -0,0 +1,198 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
useBlockNoteFormattingToolbarHoverGuard,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
function setupHoverDom() {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
const wrapper = document.createElement('div')
wrapper.className = 'bn-visual-media-wrapper'
fileBlock.appendChild(wrapper)
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(wrapper, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
return { container, toolbar }
}
function createEditor(options: {
selectionType?: string | null
selectionId?: string
cursorType?: string
cursorId?: string
setTextCursorPosition?: ReturnType<typeof vi.fn>
}) {
const {
selectionType = 'paragraph',
selectionId = 'other-block',
cursorType = 'paragraph',
cursorId = 'cursor-block',
setTextCursorPosition = vi.fn(),
} = options
return {
getSelection: () => (
selectionType
? { blocks: [{ type: selectionType, id: selectionId }] }
: null
),
getTextCursorPosition: () => ({ block: { type: cursorType, id: cursorId } }),
setTextCursorPosition,
}
}
function dispatchBridgeMousemove(target: EventTarget = document.body) {
const event = new MouseEvent('mousemove', {
bubbles: true,
clientX: 368,
clientY: 104,
})
Object.defineProperty(event, 'target', { configurable: true, value: target })
const stopPropagation = vi.fn()
event.stopPropagation = stopPropagation
act(() => {
window.dispatchEvent(event)
})
return stopPropagation
}
describe('blockNoteFormattingToolbarHoverGuard extra coverage', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
it('treats invisible rects and missing bridge inputs as non-suppressing', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 12, y: 12 },
rect(10, 10, 0, 20),
rect(10, 10, 20, 20),
),
).toBe(false)
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 12, y: 12 },
container: null,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
})
it('remembers the last selected file block while open and clears it when closed', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const { container } = setupHoverDom()
const setTextCursorPosition = vi.fn()
const editor = createEditor({ setTextCursorPosition })
const { rerender, unmount } = renderHook(
({ selectedFileBlockId, isOpen }) => useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId,
isOpen,
}),
{
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
},
)
expect(addSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
rerender({ selectedFileBlockId: null, isOpen: true })
const stopPropagation = dispatchBridgeMousemove()
expect(setTextCursorPosition).toHaveBeenCalledWith('image-block')
expect(stopPropagation).toHaveBeenCalledTimes(1)
rerender({ selectedFileBlockId: null, isOpen: false })
rerender({ selectedFileBlockId: null, isOpen: true })
dispatchBridgeMousemove()
expect(setTextCursorPosition).toHaveBeenCalledTimes(1)
unmount()
expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
})
it('skips listener setup when the environment is invalid and safely ignores already-active or failing restores', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const { container } = setupHoverDom()
const alreadySelectedEditor = createEditor({
selectionType: null,
cursorType: 'image',
cursorId: 'image-block',
setTextCursorPosition: vi.fn(),
})
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
editor: alreadySelectedEditor as never,
container: null,
selectedFileBlockId: 'image-block',
isOpen: true,
}))
expect(addSpy).not.toHaveBeenCalled()
const { unmount } = renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
editor: alreadySelectedEditor as never,
container,
selectedFileBlockId: 'image-block',
isOpen: true,
}))
dispatchBridgeMousemove()
expect(alreadySelectedEditor.setTextCursorPosition).not.toHaveBeenCalled()
unmount()
const throwingEditor = createEditor({
selectionType: null,
cursorType: 'paragraph',
cursorId: 'other-block',
setTextCursorPosition: vi.fn(() => {
throw new Error('gone')
}),
})
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
editor: throwingEditor as never,
container,
selectedFileBlockId: 'image-block',
isOpen: true,
}))
expect(() => dispatchBridgeMousemove()).not.toThrow()
})
})

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
useBlockNoteFormattingToolbarHoverGuard,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
@@ -12,7 +14,69 @@ function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
function appendFileBlock({
container,
blockId,
innerClass,
}: {
container: HTMLElement
blockId: string
innerClass: string
}) {
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = blockId
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
const inner = document.createElement('div')
inner.className = innerClass
fileBlock.appendChild(inner)
block.appendChild(fileBlock)
container.appendChild(block)
return inner
}
function createToolbarHoverScenario(withToolbarButton = false) {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = withToolbarButton ? document.createElement('button') : null
if (toolbarButton) toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
return { container, toolbarButton }
}
function makeEditor(overrides: Record<string, unknown> = {}) {
return {
getSelection: vi.fn(() => null),
getTextCursorPosition: vi.fn(() => ({ block: { id: 'paragraph-block', type: 'paragraph' } })),
setTextCursorPosition: vi.fn(),
...overrides,
}
}
describe('blockNoteFormattingToolbarHoverGuard', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
expect(
isWithinFormattingToolbarHoverBridge(
@@ -23,92 +87,210 @@ describe('blockNoteFormattingToolbarHoverGuard', () => {
).toBe(true)
})
it('suppresses hover updates when the pointer is already over the toolbar', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = document.createElement('button')
toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
it('ignores invisible rectangles and missing hover bridge context', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 10, y: 10 },
rect(300, 130, 0, 90),
rect(322, 78, 96, 24),
),
).toBe(false)
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: toolbarButton,
eventTarget: document.body,
point: { x: 350, y: 90 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 368, y: 104 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 520, y: 220 },
container,
container: null,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 350, y: 90 },
container: document.createElement('div'),
doc: document,
selectedFileBlockId: null,
}),
).toBe(false)
})
it.each([
{
name: 'suppresses hover updates when the pointer is already over the toolbar',
point: { x: 350, y: 90 },
eventTarget: 'toolbarButton',
expected: true,
withToolbarButton: true,
},
{
name: 'suppresses hover updates while the pointer crosses the image-toolbar bridge',
point: { x: 368, y: 104 },
eventTarget: 'body',
expected: true,
withToolbarButton: false,
},
{
name: 'leaves unrelated pointer movement alone',
point: { x: 520, y: 220 },
eventTarget: 'body',
expected: false,
withToolbarButton: false,
},
])('$name', ({ point, eventTarget, expected, withToolbarButton }) => {
const { container, toolbarButton } = createToolbarHoverScenario(withToolbarButton)
const target = eventTarget === 'toolbarButton' ? toolbarButton : document.body
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: target as EventTarget,
point,
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(expected)
})
it('uses the filename bridge fallback and restores the last selected file block while the toolbar stays open', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const bridge = appendFileBlock({
container,
blockId: 'image-block',
innerClass: 'bn-file-name-with-icon',
})
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(bridge, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
const editor = makeEditor()
const { rerender } = renderHook(
({ selectedFileBlockId, isOpen }) =>
useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId,
isOpen,
}),
{
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
},
)
rerender({ selectedFileBlockId: null, isOpen: true })
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('image-block')
vi.mocked(editor.setTextCursorPosition).mockClear()
rerender({ selectedFileBlockId: null, isOpen: false })
rerender({ selectedFileBlockId: null, isOpen: true })
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
it('uses the add-file-button fallback and swallows restore errors when the block disappears', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const bridge = appendFileBlock({
container,
blockId: 'image-block',
innerClass: 'bn-add-file-button',
})
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(bridge, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
const editor = makeEditor({
setTextCursorPosition: vi.fn(() => {
throw new Error('gone')
}),
})
const { rerender } = renderHook(
({ selectedFileBlockId, isOpen }) =>
useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId,
isOpen,
}),
{
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
},
)
rerender({ selectedFileBlockId: null, isOpen: true })
expect(() => {
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
}).not.toThrow()
})
it('leaves the cursor alone when the selected file block is already active', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const bridge = appendFileBlock({
container,
blockId: 'image-block',
innerClass: 'bn-file-name-with-icon',
})
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(bridge, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
const editor = makeEditor({
getSelection: vi.fn(() => ({
blocks: [{ id: 'image-block', type: 'image' }],
})),
})
renderHook(() =>
useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId: 'image-block',
isOpen: true,
}),
)
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,54 @@
import type { RefObject } from 'react'
import { PencilSimple, Trash } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
export interface FolderContextMenuState {
path: string
x: number
y: number
}
interface FolderContextMenuProps {
menu: FolderContextMenuState | null
menuRef: RefObject<HTMLDivElement | null>
onDelete?: (folderPath: string) => void
onRename: (folderPath: string) => void
}
export function FolderContextMenu({
menu,
menuRef,
onDelete,
onRename,
}: FolderContextMenuProps) {
if (!menu) return null
return (
<div
ref={menuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: menu.x, top: menu.y, minWidth: 180 }}
data-testid="folder-context-menu"
>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onRename(menu.path)}
>
<PencilSimple size={14} />
Rename folder
</Button>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm text-destructive hover:text-destructive"
onClick={() => onDelete?.(menu.path)}
data-testid="delete-folder-menu-item"
>
<Trash size={14} />
Delete folder
</Button>
</div>
)
}

View File

@@ -0,0 +1,228 @@
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import {
CaretDown,
CaretRight,
Folder,
FolderOpen,
PencilSimple,
Trash,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode } from '../../types'
import { useFolderRowInteractions } from './useFolderRowInteractions'
interface FolderItemRowProps {
contentInset: number
depthIndent: number
isExpanded: boolean
isSelected: boolean
node: FolderNode
onDeleteFolder?: (folderPath: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
}
export function FolderItemRow({
contentInset,
depthIndent,
isExpanded,
isSelected,
node,
onDeleteFolder,
onOpenMenu,
onSelect,
onStartRenameFolder,
onToggle,
}: FolderItemRowProps) {
const hasChildren = node.children.length > 0
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
hasChildren,
onRenameFolder: onStartRenameFolder ? () => onStartRenameFolder(node.path) : undefined,
onSelect,
onToggle: () => onToggle(node.path),
})
return (
<div
className={cn(
'group relative flex items-center gap-1 rounded transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
onContextMenu={(event) => {
onSelect()
onOpenMenu(node, event)
}}
>
<FolderToggleButton
expandLabel={expandLabel}
hasChildren={hasChildren}
isExpanded={isExpanded}
onToggle={() => onToggle(node.path)}
/>
<FolderSelectButton
contentInset={contentInset}
hasActions={hasActions}
hasChildren={hasChildren}
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onClick={handleSelectClick}
onDoubleClick={handleRenameDoubleClick}
/>
{hasActions && (
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onStartRenameFolder && (
<FolderActionButton
ariaLabel={`Rename ${node.name}`}
testId={`rename-folder-btn:${node.path}`}
title="Rename folder"
onClick={() => {
onSelect()
onStartRenameFolder(node.path)
}}
>
<PencilSimple size={12} />
</FolderActionButton>
)}
{onDeleteFolder && (
<FolderActionButton
ariaLabel={`Delete ${node.name}`}
testId={`delete-folder-btn:${node.path}`}
title="Delete folder"
destructive
onClick={() => {
onSelect()
onDeleteFolder(node.path)
}}
>
<Trash size={12} />
</FolderActionButton>
)}
</div>
)}
</div>
)
}
function FolderToggleButton({
expandLabel,
hasChildren,
isExpanded,
onToggle,
}: {
expandLabel: string
hasChildren: boolean
isExpanded: boolean
onToggle: () => void
}) {
if (!hasChildren) return null
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
aria-label={expandLabel}
>
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</Button>
)
}
function FolderActionButton({
ariaLabel,
children,
destructive = false,
onClick,
testId,
title,
}: {
ariaLabel: string
children: ReactNode
destructive?: boolean
onClick: () => void
testId: string
title: string
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={ariaLabel}
title={title}
className={cn(
'h-5 w-5 rounded p-0 text-muted-foreground',
destructive ? 'hover:text-destructive' : 'hover:text-foreground',
)}
data-testid={testId}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
>
{children}
</Button>
)
}
function FolderSelectButton({
contentInset,
hasActions,
hasChildren,
isExpanded,
isSelected,
node,
onClick,
onDoubleClick,
}: {
contentInset: number
hasActions: boolean
hasChildren: boolean
isExpanded: boolean
isSelected: boolean
node: FolderNode
onClick: (clickDetail: number) => void
onDoubleClick: () => void
}) {
return (
<Button
type="button"
variant="ghost"
className={cn(
'h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium hover:bg-transparent',
isSelected ? 'text-primary hover:text-primary' : 'text-foreground hover:text-foreground',
)}
style={{
paddingTop: 6,
paddingBottom: 6,
paddingLeft: hasChildren ? 0 : contentInset,
paddingRight: hasActions ? 48 : 16,
}}
title={node.path}
onClick={(event) => onClick(event.detail)}
onDoubleClick={onDoubleClick}
data-testid={`folder-row:${node.path}`}
>
{isSelected || isExpanded ? (
<FolderOpen size={17} weight="fill" className="size-[17px] shrink-0" />
) : (
<Folder size={17} className="size-[17px] shrink-0" />
)}
<span className="truncate">{node.name}</span>
</Button>
)
}

View File

@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Folder } from '@phosphor-icons/react'
import { Input } from '@/components/ui/input'
interface FolderNameInputProps {
ariaLabel: string
initialValue: string
placeholder: string
leftInset?: number
selectTextOnFocus?: boolean
submitOnBlur?: boolean
testId: string
onCancel: () => void
onSubmit: (value: string) => Promise<boolean> | boolean
}
export function FolderNameInput({
ariaLabel,
initialValue,
placeholder,
leftInset = 16,
selectTextOnFocus = false,
submitOnBlur = false,
testId,
onCancel,
onSubmit,
}: FolderNameInputProps) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
const submittingRef = useRef(false)
useEffect(() => {
const input = inputRef.current
if (!input) return
input.focus()
if (selectTextOnFocus) input.select()
}, [selectTextOnFocus])
const handleSubmit = useCallback(async () => {
if (submittingRef.current) return false
submittingRef.current = true
try {
return await onSubmit(value)
} finally {
submittingRef.current = false
}
}, [onSubmit, value])
return (
<div className="flex items-center gap-2 rounded" style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 16, paddingLeft: leftInset, borderRadius: 4 }}>
<Folder size={17} className="size-[17px] shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
aria-label={ariaLabel}
className="h-auto min-h-0 flex-1 rounded-sm px-2 py-[3px] text-[13px] font-medium"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleSubmit()
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}}
placeholder={placeholder}
data-testid={testId}
/>
</div>
)
}

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