Compare commits
68 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a27a70e552 | ||
|
|
ca92bf4f77 | ||
|
|
9fda0a67ec | ||
|
|
0ffb7c65a9 | ||
|
|
633d9f1496 | ||
|
|
ba3d413b94 | ||
|
|
f3286922ad | ||
|
|
b0b476c9e5 | ||
|
|
dac696b752 | ||
|
|
cda87ee6c0 | ||
|
|
33324a5c79 | ||
|
|
f28626bbfe | ||
|
|
1bc2e3d1e0 | ||
|
|
8e6bdab3e6 | ||
|
|
d52362ad9b | ||
|
|
bf442646f5 | ||
|
|
1b3a444368 | ||
|
|
c4da7f1f2f | ||
|
|
c5fe291a9a | ||
|
|
59268acd04 | ||
|
|
3e4e2ebdc6 | ||
|
|
05375258a2 | ||
|
|
0c0354bfb8 | ||
|
|
6023e95d5b | ||
|
|
88d5694353 | ||
|
|
db6b38843b | ||
|
|
ad9901bbf5 | ||
|
|
b785c53ef9 | ||
|
|
8ae1ade220 | ||
|
|
35b035643e | ||
|
|
d3d663af0b | ||
|
|
8f624350d8 | ||
|
|
0e54173834 | ||
|
|
a6f1524a78 | ||
|
|
4cd5baeb55 | ||
|
|
fb1265e088 | ||
|
|
ca89662231 | ||
|
|
97f285e3cb | ||
|
|
4bd4ef43dc | ||
|
|
be29880a3e | ||
|
|
7be5519f61 | ||
|
|
8cefbe949a | ||
|
|
dd0a55b8e8 | ||
|
|
610276b1bc | ||
|
|
e6c968e37e | ||
|
|
367a66f32a | ||
|
|
6a4046915c | ||
|
|
ec8b637c84 | ||
|
|
3ee42fb856 | ||
|
|
eacf05a9f8 | ||
|
|
a199fad803 | ||
|
|
e956c806e5 | ||
|
|
7a6e3863ad | ||
|
|
e0e2dc4063 | ||
|
|
0020ade6d7 | ||
|
|
503e482c7d | ||
|
|
0d0e6af4c9 | ||
|
|
dbaf361878 | ||
|
|
d3741c82f5 | ||
|
|
b90d2f8612 | ||
|
|
9536efee0e | ||
|
|
c86a6ab9c5 | ||
|
|
1d7c027700 | ||
|
|
74e50c64bb | ||
|
|
8b97ef711f | ||
|
|
08855cee92 | ||
|
|
03042bb49c | ||
|
|
cee4bef179 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.89
|
||||
AVERAGE_THRESHOLD=9.75
|
||||
HOTSPOT_THRESHOLD=9.9
|
||||
AVERAGE_THRESHOLD=9.79
|
||||
|
||||
11
.github/workflows/README.md
vendored
11
.github/workflows/README.md
vendored
@@ -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
|
||||
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -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.
|
||||
|
||||
89
.github/workflows/release-stable.yml
vendored
89
.github/workflows/release-stable.yml
vendored
@@ -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 }}
|
||||
|
||||
133
.github/workflows/release.yml
vendored
133
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
15
README.md
15
README.md
@@ -1,4 +1,4 @@
|
||||
[](https://codescene.io/projects/76865) [](https://codescene.io/projects/76865)
|
||||
 [](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [](https://codecov.io/gh/refactoringhq/tolaria) [](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 project’s trademark policy.
|
||||
|
||||
55
SECURITY.md
Normal file
55
SECURITY.md
Normal 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.
|
||||
@@ -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,13 @@ 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.
|
||||
- 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 +280,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 +308,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 +404,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 +519,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 +679,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.
|
||||
|
||||
@@ -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. 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` — stage crash-safe file renames, update `title` frontmatter, 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,14 @@ 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 |
|
||||
| `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 +685,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
|
||||
|
||||
@@ -755,7 +768,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.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
@@ -778,6 +791,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 +799,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 +829,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
48
docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md
Normal file
48
docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md
Normal 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.
|
||||
33
docs/adr/0072-confirmed-vault-paths-gate-startup-state.md
Normal file
33
docs/adr/0072-confirmed-vault-paths-gate-startup-state.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal file
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal 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.
|
||||
@@ -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,8 @@ 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 |
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
101
patches/@tiptap__extension-link@3.19.0.patch
Normal file
101
patches/@tiptap__extension-link@3.19.0.patch
Normal 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
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
|
||||
23
src-tauri/src/commands/folders.rs
Normal file
23
src-tauri/src/commands/folders.rs
Normal 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)
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¬e_path);
|
||||
vault::auto_rename_untitled(&vault_path, ¬e_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(¬e).unwrap();
|
||||
@@ -372,22 +91,86 @@ mod tests {
|
||||
let note = dir.path().join("test.md");
|
||||
std::fs::write(¬e, "---\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(¬e, "---\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_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 +213,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(
|
||||
|
||||
282
src-tauri/src/commands/vault/boundary.rs
Normal file
282
src-tauri/src/commands/vault/boundary.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
149
src-tauri/src/commands/vault/file_cmds.rs
Normal file
149
src-tauri/src/commands/vault/file_cmds.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
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)
|
||||
}
|
||||
|
||||
#[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_validated_path(
|
||||
path.to_string_lossy().as_ref(),
|
||||
vault_path
|
||||
.as_ref()
|
||||
.map(|value| value.to_string_lossy())
|
||||
.as_deref(),
|
||||
ValidatedPathMode::Writable,
|
||||
|validated_path| vault::save_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)
|
||||
}
|
||||
48
src-tauri/src/commands/vault/frontmatter_cmds.rs
Normal file
48
src-tauri/src/commands/vault/frontmatter_cmds.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
90
src-tauri/src/commands/vault/lifecycle_cmds.rs
Normal file
90
src-tauri/src/commands/vault/lifecycle_cmds.rs
Normal 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())
|
||||
}
|
||||
69
src-tauri/src/commands/vault/rename_cmds.rs
Normal file
69
src-tauri/src/commands/vault/rename_cmds.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::{self, DetectedRename, RenameResult};
|
||||
|
||||
use super::boundary::with_existing_path_in_requested_vault;
|
||||
|
||||
#[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 auto_rename_untitled(
|
||||
vault_path: String,
|
||||
note_path: String,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
with_existing_path_in_requested_vault(
|
||||
&vault_path,
|
||||
¬e_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)
|
||||
}
|
||||
51
src-tauri/src/commands/vault/scan_cmds.rs
Normal file
51
src-tauri/src/commands/vault/scan_cmds.rs
Normal 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))?
|
||||
}
|
||||
37
src-tauri/src/commands/vault/view_cmds.rs
Normal file
37
src-tauri/src/commands/vault/view_cmds.rs
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,6 +147,49 @@ 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![
|
||||
@@ -198,6 +240,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 +259,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 +293,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)
|
||||
|
||||
@@ -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""#);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
183
src-tauri/src/vault/folders.rs
Normal file
183
src-tauri/src/vault/folders.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -19,6 +21,7 @@ pub use config_seed::{
|
||||
};
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
38
src-tauri/src/vault/mod_tests/journal_type_visibility.rs
Normal file
38
src-tauri/src/vault/mod_tests/journal_type_visibility.rs
Normal 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()]),
|
||||
);
|
||||
}
|
||||
@@ -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,22 @@ 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(RenameResult {
|
||||
new_path,
|
||||
updated_files,
|
||||
})
|
||||
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 +491,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 +513,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 +611,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 +628,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]
|
||||
@@ -909,36 +976,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 +1012,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());
|
||||
}
|
||||
}
|
||||
|
||||
287
src-tauri/src/vault/rename_transaction.rs
Normal file
287
src-tauri/src/vault/rename_transaction.rs
Normal 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(())
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
151
src/App.tsx
151
src/App.tsx
@@ -19,6 +19,7 @@ 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 { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
@@ -58,6 +59,7 @@ 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'
|
||||
@@ -74,6 +76,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 +223,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()
|
||||
@@ -403,20 +408,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 +479,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 +509,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 +647,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 +674,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 +747,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,7 +934,8 @@ 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)
|
||||
@@ -1106,6 +1183,8 @@ function App() {
|
||||
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,
|
||||
@@ -1121,7 +1200,7 @@ function App() {
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
onInstallMcp: openMcpSetupDialog,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
@@ -1211,7 +1290,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 +1308,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
|
||||
@@ -1246,7 +1330,7 @@ function 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} />
|
||||
<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} />
|
||||
</>
|
||||
@@ -1322,7 +1406,7 @@ function App() {
|
||||
</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() }} />
|
||||
<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} />
|
||||
@@ -1358,6 +1442,7 @@ function App() {
|
||||
/>
|
||||
<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
|
||||
@@ -1369,6 +1454,16 @@ function App() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
10
src/assets/tolaria-icon.svg
Normal file
10
src/assets/tolaria-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_111_151)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_151">
|
||||
<rect width="266" height="363" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 889 B |
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
69
src/components/ConflictResolverModal.extra.test.tsx
Normal file
69
src/components/ConflictResolverModal.extra.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal file
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -34,37 +34,91 @@ 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('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:areas'))
|
||||
expect(onStartRenameFolder).toHaveBeenCalledWith('areas')
|
||||
})
|
||||
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
65
src/components/McpSetupDialog.test.tsx
Normal file
65
src/components/McpSetupDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
109
src/components/McpSetupDialog.tsx
Normal file
109
src/components/McpSetupDialog.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
325
src/components/PropertyValueCells.extra.test.tsx
Normal file
325
src/components/PropertyValueCells.extra.test.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
390
src/components/PropertyValueCells.test.tsx
Normal file
390
src/components/PropertyValueCells.test.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
315
src/components/RawEditorView.behavior.test.tsx
Normal file
315
src/components/RawEditorView.behavior.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.coverage.test.tsx
Normal file
276
src/components/RawEditorView.coverage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
304
src/components/SingleEditorView.test.tsx
Normal file
304
src/components/SingleEditorView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
59
src/components/StatusDropdown.extra.test.tsx
Normal file
59
src/components/StatusDropdown.extra.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)' }}>✦</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' && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
54
src/components/folder-tree/FolderContextMenu.tsx
Normal file
54
src/components/folder-tree/FolderContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/folder-tree/FolderNameInput.tsx
Normal file
72
src/components/folder-tree/FolderNameInput.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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
|
||||
selectTextOnFocus?: boolean
|
||||
submitOnBlur?: boolean
|
||||
testId: string
|
||||
onCancel: () => void
|
||||
onSubmit: (value: string) => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
export function FolderNameInput({
|
||||
ariaLabel,
|
||||
initialValue,
|
||||
placeholder,
|
||||
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" style={{ padding: '4px 8px' }}>
|
||||
<Folder size={18} className="shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label={ariaLabel}
|
||||
className="h-7 flex-1 rounded-sm px-2 text-[13px]"
|
||||
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>
|
||||
)
|
||||
}
|
||||
283
src/components/folder-tree/FolderTreeRow.tsx
Normal file
283
src/components/folder-tree/FolderTreeRow.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { memo, useCallback, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FolderNode, SidebarSelection } from '../../types'
|
||||
import { FolderNameInput } from './FolderNameInput'
|
||||
|
||||
interface FolderTreeRowProps {
|
||||
depth: number
|
||||
expanded: Record<string, boolean>
|
||||
node: FolderNode
|
||||
onDeleteFolder?: (folderPath: string) => void
|
||||
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
|
||||
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
onToggle: (path: string) => void
|
||||
onCancelRenameFolder?: () => void
|
||||
renamingFolderPath?: string | null
|
||||
selection: SidebarSelection
|
||||
}
|
||||
|
||||
function FolderRenameRow({
|
||||
indentation,
|
||||
node,
|
||||
onCancelRenameFolder,
|
||||
onRenameFolder,
|
||||
}: {
|
||||
indentation: number
|
||||
node: FolderNode
|
||||
onCancelRenameFolder: () => void
|
||||
onRenameFolder: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ paddingLeft: indentation }}>
|
||||
<FolderNameInput
|
||||
ariaLabel="Folder name"
|
||||
initialValue={node.name}
|
||||
placeholder="Folder name"
|
||||
selectTextOnFocus={true}
|
||||
testId="rename-folder-input"
|
||||
onCancel={onCancelRenameFolder}
|
||||
onSubmit={(nextName) => onRenameFolder(node.path, nextName)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderItemRow({
|
||||
indentation,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
node,
|
||||
onOpenMenu,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
onToggle,
|
||||
}: {
|
||||
indentation: number
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
node: FolderNode
|
||||
onOpenMenu: FolderTreeRowProps['onOpenMenu']
|
||||
onSelect: () => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
onToggle: (path: string) => void
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0
|
||||
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-[5px] transition-colors',
|
||||
isSelected
|
||||
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
|
||||
: 'text-foreground hover:bg-accent',
|
||||
)}
|
||||
style={{ paddingLeft: indentation }}
|
||||
onContextMenu={(event) => {
|
||||
onSelect()
|
||||
onOpenMenu(node, event)
|
||||
}}
|
||||
>
|
||||
<FolderToggleButton
|
||||
expandLabel={expandLabel}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => onToggle(node.path)}
|
||||
/>
|
||||
<FolderSelectButton
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderToggleButton({
|
||||
expandLabel,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
expandLabel: string
|
||||
hasChildren: boolean
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
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"
|
||||
disabled={!hasChildren}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (hasChildren) onToggle()
|
||||
}}
|
||||
aria-label={hasChildren ? expandLabel : undefined}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />
|
||||
) : (
|
||||
<span className="block h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderSelectButton({
|
||||
isExpanded,
|
||||
isSelected,
|
||||
node,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
}: {
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
node: FolderNode
|
||||
onSelect: () => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-7 flex-1 justify-start gap-2 rounded-[5px] px-2 py-0 text-left text-[13px]',
|
||||
isSelected ? 'font-medium text-primary hover:text-primary' : 'hover:text-foreground',
|
||||
)}
|
||||
title={node.path}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={() => {
|
||||
onSelect()
|
||||
onStartRenameFolder?.(node.path)
|
||||
}}
|
||||
data-testid={`folder-row:${node.path}`}
|
||||
>
|
||||
{isSelected || isExpanded ? (
|
||||
<FolderOpen size={18} weight="fill" className="shrink-0" />
|
||||
) : (
|
||||
<Folder size={18} className="shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderChildren({
|
||||
depth,
|
||||
expanded,
|
||||
node,
|
||||
onDeleteFolder,
|
||||
onOpenMenu,
|
||||
onRenameFolder,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
onToggle,
|
||||
onCancelRenameFolder,
|
||||
renamingFolderPath,
|
||||
selection,
|
||||
}: FolderTreeRowProps) {
|
||||
const isExpanded = expanded[node.path] ?? false
|
||||
const hasChildren = node.children.length > 0
|
||||
if (!isExpanded || !hasChildren) return null
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<FolderTreeRow
|
||||
key={child.path}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
node={child}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenMenu={onOpenMenu}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={onToggle}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
selection={selection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FolderTreeRow = memo(function FolderTreeRow({
|
||||
depth,
|
||||
expanded,
|
||||
node,
|
||||
onDeleteFolder,
|
||||
onOpenMenu,
|
||||
onRenameFolder,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
onToggle,
|
||||
onCancelRenameFolder,
|
||||
renamingFolderPath,
|
||||
selection,
|
||||
}: FolderTreeRowProps) {
|
||||
const isExpanded = expanded[node.path] ?? false
|
||||
const isRenaming = renamingFolderPath === node.path
|
||||
const isSelected = selection.kind === 'folder' && selection.path === node.path
|
||||
const indentation = 8 + depth * 16
|
||||
const selectFolder = useCallback(() => {
|
||||
onSelect({ kind: 'folder', path: node.path })
|
||||
}, [node.path, onSelect])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRenaming && onRenameFolder && onCancelRenameFolder ? (
|
||||
<FolderRenameRow
|
||||
indentation={indentation}
|
||||
node={node}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
/>
|
||||
) : (
|
||||
<FolderItemRow
|
||||
indentation={indentation}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
onOpenMenu={onOpenMenu}
|
||||
onSelect={selectFolder}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
<FolderChildren
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
node={node}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenMenu={onOpenMenu}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={onToggle}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
selection={selection}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
18
src/components/folder-tree/folderTreeUtils.ts
Normal file
18
src/components/folder-tree/folderTreeUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function expandedTreePaths(path: string): string[] {
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
return segments.map((_, index) => segments.slice(0, index + 1).join('/'))
|
||||
}
|
||||
|
||||
export function mergeExpandedPaths(
|
||||
current: Record<string, boolean>,
|
||||
paths: string[],
|
||||
): Record<string, boolean> {
|
||||
let changed = false
|
||||
const nextExpanded = { ...current }
|
||||
for (const path of paths) {
|
||||
if (nextExpanded[path]) continue
|
||||
nextExpanded[path] = true
|
||||
changed = true
|
||||
}
|
||||
return changed ? nextExpanded : current
|
||||
}
|
||||
65
src/components/folder-tree/useFolderContextMenu.ts
Normal file
65
src/components/folder-tree/useFolderContextMenu.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { FolderNode } from '../../types'
|
||||
import type { FolderContextMenuState } from './FolderContextMenu'
|
||||
|
||||
interface UseFolderContextMenuInput {
|
||||
onDeleteFolder?: (folderPath: string) => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
}
|
||||
|
||||
export function useFolderContextMenu({
|
||||
onDeleteFolder,
|
||||
onStartRenameFolder,
|
||||
}: UseFolderContextMenuInput) {
|
||||
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeContextMenu = useCallback(() => setContextMenu(null), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) closeContextMenu()
|
||||
}
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeContextMenu()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [closeContextMenu, contextMenu])
|
||||
|
||||
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setContextMenu({
|
||||
path: node.path,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleRenameFromMenu = useCallback((folderPath: string) => {
|
||||
closeContextMenu()
|
||||
onStartRenameFolder?.(folderPath)
|
||||
}, [closeContextMenu, onStartRenameFolder])
|
||||
|
||||
const handleDeleteFromMenu = useCallback((folderPath: string) => {
|
||||
closeContextMenu()
|
||||
onDeleteFolder?.(folderPath)
|
||||
}, [closeContextMenu, onDeleteFolder])
|
||||
|
||||
return {
|
||||
closeContextMenu,
|
||||
contextMenu,
|
||||
handleDeleteFromMenu,
|
||||
handleOpenMenu,
|
||||
handleRenameFromMenu,
|
||||
menuRef,
|
||||
}
|
||||
}
|
||||
66
src/components/folder-tree/useFolderTreeDisclosure.ts
Normal file
66
src/components/folder-tree/useFolderTreeDisclosure.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
import { expandedTreePaths, mergeExpandedPaths } from './folderTreeUtils'
|
||||
|
||||
interface UseFolderTreeDisclosureInput {
|
||||
collapsed?: boolean
|
||||
onToggle?: () => void
|
||||
renamingFolderPath?: string | null
|
||||
selection: SidebarSelection
|
||||
}
|
||||
|
||||
export function useFolderTreeDisclosure({
|
||||
collapsed: externalCollapsed,
|
||||
onToggle,
|
||||
renamingFolderPath,
|
||||
selection,
|
||||
}: UseFolderTreeDisclosureInput) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
const [manualExpanded, setManualExpanded] = useState<Record<string, boolean>>({})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const baseSectionCollapsed = externalCollapsed ?? internalCollapsed
|
||||
const sectionCollapsed = !isCreating && !renamingFolderPath && baseSectionCollapsed
|
||||
const requiredExpandedPaths = useMemo(() => {
|
||||
const nextPaths: string[] = []
|
||||
if (selection.kind === 'folder') nextPaths.push(...expandedTreePaths(selection.path))
|
||||
if (renamingFolderPath) nextPaths.push(...expandedTreePaths(renamingFolderPath))
|
||||
return [...new Set(nextPaths)]
|
||||
}, [renamingFolderPath, selection])
|
||||
|
||||
const expanded = useMemo(
|
||||
() => mergeExpandedPaths(manualExpanded, requiredExpandedPaths),
|
||||
[manualExpanded, requiredExpandedPaths],
|
||||
)
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
if (onToggle) {
|
||||
onToggle()
|
||||
return
|
||||
}
|
||||
setInternalCollapsed((current) => !current)
|
||||
}, [onToggle])
|
||||
|
||||
const openCreateForm = useCallback(() => {
|
||||
if (baseSectionCollapsed) {
|
||||
if (onToggle) onToggle()
|
||||
else setInternalCollapsed(false)
|
||||
}
|
||||
setIsCreating(true)
|
||||
}, [baseSectionCollapsed, onToggle])
|
||||
|
||||
const closeCreateForm = useCallback(() => setIsCreating(false), [])
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setManualExpanded((current) => ({ ...current, [path]: !current[path] }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
closeCreateForm,
|
||||
expanded,
|
||||
handleToggleSection,
|
||||
isCreating,
|
||||
openCreateForm,
|
||||
sectionCollapsed,
|
||||
toggleFolder,
|
||||
}
|
||||
}
|
||||
100
src/components/inlineWikilinkDom.test.ts
Normal file
100
src/components/inlineWikilinkDom.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
applySelectionRange,
|
||||
readSelectionIndex,
|
||||
readSelectionRange,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
|
||||
function createChip(target: string): HTMLSpanElement {
|
||||
const chip = document.createElement('span')
|
||||
chip.dataset.chipTarget = target
|
||||
chip.textContent = target
|
||||
return chip
|
||||
}
|
||||
|
||||
function createMixedInlineRoot(): HTMLDivElement {
|
||||
const root = document.createElement('div')
|
||||
root.append('A')
|
||||
root.append(createChip('Project'))
|
||||
root.append('B')
|
||||
document.body.append(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function setSelectionRange(
|
||||
startContainer: Node,
|
||||
startOffset: number,
|
||||
endContainer: Node,
|
||||
endOffset: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.setStart(startContainer, startOffset)
|
||||
range.setEnd(endContainer, endOffset)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
describe('inlineWikilinkDom', () => {
|
||||
it('serializes text, chips, breaks, and nested content into inline markdown', () => {
|
||||
const root = document.createElement('div')
|
||||
const nested = document.createElement('span')
|
||||
|
||||
root.append('A\u00A0B\u200B\n')
|
||||
root.append(createChip('Project'))
|
||||
root.append(document.createElement('br'))
|
||||
nested.textContent = 'Tail'
|
||||
root.append(nested)
|
||||
|
||||
expect(serializeInlineNode(root)).toBe('A B [[Project]]Tail')
|
||||
})
|
||||
|
||||
it('reads selections inside the editor and falls back to the editor end for outside selections', () => {
|
||||
const root = createMixedInlineRoot()
|
||||
const [startText, , endText] = Array.from(root.childNodes)
|
||||
const outside = document.createElement('div')
|
||||
|
||||
outside.textContent = 'Outside'
|
||||
document.body.append(outside)
|
||||
|
||||
setSelectionRange(startText as Node, 1, endText as Node, 1)
|
||||
|
||||
expect(readSelectionRange(root)).toEqual({ start: 1, end: 13 })
|
||||
expect(readSelectionIndex(root)).toBe(13)
|
||||
|
||||
setSelectionRange(outside.firstChild as Node, 0, outside.firstChild as Node, 7)
|
||||
|
||||
expect(readSelectionRange(root)).toEqual({ start: 13, end: 13 })
|
||||
})
|
||||
|
||||
it('applies selection ranges across chips and clamps to the editor end', () => {
|
||||
const root = createMixedInlineRoot()
|
||||
|
||||
applySelectionRange(root, { start: 2, end: 999 })
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
expect(selection?.anchorNode?.textContent).toBe('B')
|
||||
expect(selection?.anchorOffset).toBe(0)
|
||||
expect(selection?.focusNode?.textContent).toBe('B')
|
||||
expect(selection?.focusOffset).toBe(1)
|
||||
})
|
||||
|
||||
it('applies collapsed indices before leading chips without requiring text nodes', () => {
|
||||
const root = document.createElement('div')
|
||||
|
||||
root.append(createChip('Task'))
|
||||
document.body.append(root)
|
||||
|
||||
applySelectionIndex(root, 0)
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
expect(selection?.anchorNode).toBe(root)
|
||||
expect(selection?.anchorOffset).toBe(0)
|
||||
expect(selection?.focusNode).toBe(root)
|
||||
expect(selection?.focusOffset).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
|
||||
import { humanizePropertyKey } from '../../utils/propertyLabels'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { PROPERTY_PANEL_LABEL_CLASS_NAME } from '../propertyPanelLayout'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { ActionTooltip } from '../ui/action-tooltip'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
export interface ReferencedByItem {
|
||||
@@ -16,44 +20,65 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry[]>()
|
||||
const map = new Map<string, { entriesByPath: Map<string, VaultEntry>; inverseKeys: Set<string> }>()
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.viaKey)
|
||||
if (existing) existing.push(item.entry)
|
||||
else map.set(item.viaKey, [item.entry])
|
||||
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
|
||||
const group = map.get(label) ?? { entriesByPath: new Map<string, VaultEntry>(), inverseKeys: new Set<string>() }
|
||||
group.entriesByPath.set(item.entry.path, item.entry)
|
||||
group.inverseKeys.add(humanizePropertyKey(item.viaKey))
|
||||
map.set(label, group)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
|
||||
return orderInverseRelationshipLabels(map.keys())
|
||||
.map((label) => {
|
||||
const group = map.get(label)
|
||||
if (!group) return null
|
||||
|
||||
return {
|
||||
entries: [...group.entriesByPath.values()],
|
||||
inverseKeys: [...group.inverseKeys].sort((left, right) => left.localeCompare(right)),
|
||||
label,
|
||||
}
|
||||
})
|
||||
.filter((group): group is { entries: VaultEntry[]; inverseKeys: string[]; label: string } => group !== null && group.entries.length > 0)
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
if (grouped.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="referenced-by-panel">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([viaKey, groupEntries]) => (
|
||||
<div key={viaKey}>
|
||||
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
|
||||
← {humanizePropertyKey(viaKey)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
noteIcon={e.icon}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="referenced-by-panel flex flex-col gap-3">
|
||||
<Separator data-testid="derived-relationships-separator" />
|
||||
<div className="flex flex-col gap-3">
|
||||
{grouped.map(({ label, entries: groupEntries, inverseKeys }) => {
|
||||
const tooltip = `Derived inverse relationship, inverse of ${inverseKeys.join(' and ')}.`
|
||||
|
||||
return (
|
||||
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
|
||||
<ActionTooltip copy={{ label: tooltip }} side="top" align="start">
|
||||
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} tabIndex={0} data-testid="derived-relationship-label">
|
||||
{label}
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
noteIcon={e.icon}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render as rtlRender, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DynamicRelationshipsPanel } from './RelationshipsPanel'
|
||||
import { ReferencedByPanel, type ReferencedByItem } from './ReferencedByPanel'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
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',
|
||||
@@ -105,14 +108,46 @@ describe('relationship display labels', () => {
|
||||
expect(relationshipRow).toHaveStyle({ gridColumn: '1 / -1' })
|
||||
})
|
||||
|
||||
it('humanizes snake_case keys in the referenced-by panel', () => {
|
||||
it('humanizes snake_case keys in the referenced-by panel', async () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
fireEvent.focus(screen.getByText('Children'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dedupes canonical and legacy inverse keys into a single normalized inspector group', () => {
|
||||
const entry = makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' })
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry, viaKey: 'belongs_to' },
|
||||
{ entry, viaKey: 'Belongs to' },
|
||||
{ entry: makeEntry({ path: '/vault/topic-alpha.md', filename: 'topic-alpha.md', title: 'Topic Alpha', isA: 'Note' }), viaKey: 'related_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Project Alpha')).toHaveLength(1)
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show empty preferred inverse groups', () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,7 +10,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr
|
||||
|
||||
const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
@@ -19,12 +20,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
sidebarCollapsed?: boolean
|
||||
searchVisible: boolean
|
||||
search: string
|
||||
isSearching: boolean
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
propertyPicker?: ListPropertiesPopoverProps | null
|
||||
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
onCreateNote: () => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -51,7 +55,24 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
</div>
|
||||
{searchVisible && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
|
||||
<div className="relative flex-1" aria-live="polite">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search notes..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
className="h-8 pr-8 text-[13px]"
|
||||
/>
|
||||
{isSearching && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||
data-testid="note-list-search-loading"
|
||||
>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -192,12 +192,18 @@ export function NoteListLayout({
|
||||
sidebarCollapsed,
|
||||
searchVisible,
|
||||
search,
|
||||
isSearching,
|
||||
searchInputRef,
|
||||
propertyPicker,
|
||||
handleSortChange,
|
||||
handleCreateNote,
|
||||
onOpenType,
|
||||
toggleSearch,
|
||||
setSearch,
|
||||
handleSearchKeyDown,
|
||||
noteListPanelRef,
|
||||
handleNoteListPanelBlurCapture,
|
||||
handleNoteListPanelFocusCapture,
|
||||
handleListKeyDown,
|
||||
noteListContainerRef,
|
||||
handleNoteListBlur,
|
||||
@@ -230,8 +236,11 @@ export function NoteListLayout({
|
||||
}: NoteListLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
ref={noteListPanelRef}
|
||||
className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground"
|
||||
style={{ height: '100%' }}
|
||||
onBlurCapture={handleNoteListPanelBlurCapture}
|
||||
onFocusCapture={handleNoteListPanelFocusCapture}
|
||||
>
|
||||
<NoteListHeader
|
||||
title={title}
|
||||
@@ -243,12 +252,15 @@ export function NoteListLayout({
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
searchVisible={searchVisible}
|
||||
search={search}
|
||||
isSearching={isSearching}
|
||||
searchInputRef={searchInputRef}
|
||||
propertyPicker={propertyPicker}
|
||||
onSortChange={handleSortChange}
|
||||
onCreateNote={handleCreateNote}
|
||||
onOpenType={onOpenType}
|
||||
onToggleSearch={toggleSearch}
|
||||
onSearchChange={setSearch}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<NoteListBody
|
||||
handleListKeyDown={handleListKeyDown}
|
||||
|
||||
91
src/components/note-list/NoteListSearchKeyboard.test.tsx
Normal file
91
src/components/note-list/NoteListSearchKeyboard.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeIndexedEntry, renderNoteList } from '../../test-utils/noteListTestUtils'
|
||||
|
||||
function installAnimationFrameStub() {
|
||||
let nextId = 1
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
|
||||
return {
|
||||
flushAnimationFrame: () => {
|
||||
const pending = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
pending.forEach((callback) => callback(0))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteList search keyboard behavior', () => {
|
||||
let flushAnimationFrame: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
;({ flushAnimationFrame } = installAnimationFrameStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('toggles note-list search with Cmd+F while the note list is active', () => {
|
||||
renderNoteList()
|
||||
const noteList = screen.getByTestId('note-list-container')
|
||||
|
||||
act(() => {
|
||||
noteList.focus()
|
||||
fireEvent.focus(noteList)
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true })
|
||||
})
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
act(() => {
|
||||
flushAnimationFrame()
|
||||
})
|
||||
expect(searchInput).toHaveFocus()
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true })
|
||||
flushAnimationFrame()
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
expect(noteList).toHaveFocus()
|
||||
})
|
||||
|
||||
it('debounces note-list filtering and shows loading feedback while waiting', () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(index + 1)),
|
||||
makeIndexedEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
fireEvent.change(searchInput, { target: { value: 'Strategy' } })
|
||||
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
expect(screen.getByText('Note 1')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(180)
|
||||
flushAnimationFrame()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Note 1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user