Compare commits
36 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.
|
||||
|
||||
44
.github/workflows/release-stable.yml
vendored
44
.github/workflows/release-stable.yml
vendored
@@ -136,9 +136,22 @@ jobs:
|
||||
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 ("'", '"'):
|
||||
@@ -150,9 +163,28 @@ jobs:
|
||||
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 bool(parsed.netloc)
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
@@ -167,13 +199,13 @@ jobs:
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
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")
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
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)
|
||||
|
||||
88
.github/workflows/release.yml
vendored
88
.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 = (
|
||||
@@ -179,9 +195,22 @@ jobs:
|
||||
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 ("'", '"'):
|
||||
@@ -193,9 +222,28 @@ jobs:
|
||||
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 bool(parsed.netloc)
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
@@ -210,13 +258,13 @@ jobs:
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
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")
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
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)
|
||||
@@ -275,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
|
||||
|
||||
11
README.md
11
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,7 +61,7 @@ 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
|
||||
|
||||
@@ -510,6 +510,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
|
||||
|
||||
@@ -669,6 +670,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -782,6 +782,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:
|
||||
@@ -789,7 +790,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
|
||||
@@ -819,7 +820,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.
|
||||
|
||||
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.
|
||||
@@ -126,3 +126,6 @@ proposed → active → superseded
|
||||
| [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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -56,6 +56,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 +78,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()?)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()]),
|
||||
);
|
||||
}
|
||||
@@ -495,6 +495,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(() => {
|
||||
|
||||
17
src/App.tsx
17
src/App.tsx
@@ -446,19 +446,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,
|
||||
@@ -642,7 +641,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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
@@ -340,6 +343,15 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
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', () => {
|
||||
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
|
||||
const input = openInlineAdd('AI')
|
||||
@@ -347,6 +359,15 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
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', () => {
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd()
|
||||
@@ -452,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'))
|
||||
@@ -548,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('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', () => {
|
||||
@@ -572,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', () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
@@ -322,7 +357,7 @@ describe('NoteList rendering', () => {
|
||||
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,7 +379,7 @@ 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()
|
||||
|
||||
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,
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
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,57 +20,65 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Map<string, VaultEntry>>()
|
||||
const map = new Map<string, { entriesByPath: Map<string, VaultEntry>; inverseKeys: Set<string> }>()
|
||||
for (const item of items) {
|
||||
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
|
||||
const entriesByPath = map.get(label) ?? new Map<string, VaultEntry>()
|
||||
entriesByPath.set(item.entry.path, item.entry)
|
||||
map.set(label, entriesByPath)
|
||||
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 orderInverseRelationshipLabels(map.keys()).map((label) => [
|
||||
label,
|
||||
[...(map.get(label)?.values() ?? [])],
|
||||
] as const)
|
||||
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="mb-2 flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
|
||||
Derived relationships
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
Read-only groups sourced from other notes.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([label, groupEntries]) => (
|
||||
<div key={label}>
|
||||
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
|
||||
{label}
|
||||
</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,7 +108,7 @@ 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' },
|
||||
]
|
||||
@@ -113,6 +116,8 @@ describe('relationship display labels', () => {
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -133,4 +138,16 @@ describe('relationship display labels', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModifiedFile, VaultEntry, ViewFile } from '../../types'
|
||||
import { saveSortPreferences } from '../../utils/noteListHelpers'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useMultiSelectKeyboard,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
} from './noteListHooks'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const {
|
||||
multiSelectState,
|
||||
noteListKeyboardState,
|
||||
prefetchNoteContentMock,
|
||||
routeNoteClickMock,
|
||||
} = vi.hoisted(() => ({
|
||||
multiSelectState: {
|
||||
clear: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
selectRange: vi.fn(),
|
||||
setAnchor: vi.fn(),
|
||||
isMultiSelecting: false,
|
||||
},
|
||||
noteListKeyboardState: {
|
||||
highlightedPath: null as string | null,
|
||||
handleKeyDown: vi.fn(),
|
||||
lastOptions: null as null | Record<string, unknown>,
|
||||
},
|
||||
prefetchNoteContentMock: vi.fn(),
|
||||
routeNoteClickMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useMultiSelect', () => ({
|
||||
useMultiSelect: () => multiSelectState,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useNoteListKeyboard', () => ({
|
||||
useNoteListKeyboard: (options: Record<string, unknown>) => {
|
||||
noteListKeyboardState.lastOptions = options
|
||||
return {
|
||||
highlightedPath: noteListKeyboardState.highlightedPath,
|
||||
handleKeyDown: noteListKeyboardState.handleKeyDown,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useTabManagement', () => ({
|
||||
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
|
||||
}))
|
||||
|
||||
vi.mock('./noteListUtils', async () => {
|
||||
const actual = await vi.importActual<typeof import('./noteListUtils')>('./noteListUtils')
|
||||
return {
|
||||
...actual,
|
||||
routeNoteClick: (...args: unknown[]) => routeNoteClickMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/a.md',
|
||||
filename: 'a.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 100,
|
||||
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 makeDeletedEntry(): VaultEntry {
|
||||
return makeEntry({
|
||||
path: '/vault/note/deleted.md',
|
||||
filename: 'deleted.md',
|
||||
title: 'Deleted',
|
||||
__deletedNotePreview: true,
|
||||
__deletedRelativePath: 'note/deleted.md',
|
||||
__changeAddedLines: 0,
|
||||
__changeDeletedLines: 4,
|
||||
__changeBinary: false,
|
||||
} as Partial<VaultEntry>)
|
||||
}
|
||||
|
||||
describe('noteListHooks extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
multiSelectState.isMultiSelecting = false
|
||||
noteListKeyboardState.highlightedPath = null
|
||||
noteListKeyboardState.lastOptions = null
|
||||
routeNoteClickMock.mockImplementation((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onReplace: (value: VaultEntry) => void },
|
||||
) => {
|
||||
actions.onReplace(entry)
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles search visibility and clears the search when closing it', () => {
|
||||
const { result } = renderHook(() => useNoteListSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSearch()
|
||||
result.current.setSearch(' HELLO ')
|
||||
})
|
||||
|
||||
expect(result.current.searchVisible).toBe(true)
|
||||
expect(result.current.query).toBe('hello')
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSearch()
|
||||
})
|
||||
|
||||
expect(result.current.searchVisible).toBe(false)
|
||||
expect(result.current.search).toBe('')
|
||||
})
|
||||
|
||||
it('migrates stored list sorting into type documents', async () => {
|
||||
const typeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
sort: null,
|
||||
})
|
||||
const projectEntry = makeEntry()
|
||||
const onUpdateTypeSort = vi.fn()
|
||||
const updateEntry = vi.fn()
|
||||
|
||||
saveSortPreferences({
|
||||
__list__: { option: 'title', direction: 'asc' },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [typeDocument, projectEntry],
|
||||
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
onUpdateTypeSort,
|
||||
updateEntry,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'title:asc')
|
||||
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'title:asc' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'modified', 'desc')
|
||||
})
|
||||
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'modified:desc')
|
||||
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'modified:desc' })
|
||||
})
|
||||
|
||||
it('stores list sorting locally when no persistence target is available', () => {
|
||||
const entry = makeEntry({ isA: 'Note' })
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [entry],
|
||||
selection: { kind: 'filter', filter: 'all' },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'title', 'asc')
|
||||
})
|
||||
|
||||
expect(result.current.sortPrefs.__list__).toEqual({ option: 'title', direction: 'asc' })
|
||||
})
|
||||
|
||||
it('prefers selected view sort config and persists list sort changes back to the view definition', () => {
|
||||
const onUpdateViewDefinition = vi.fn()
|
||||
const view: ViewFile = {
|
||||
filename: 'work.view',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: 'title:asc',
|
||||
filters: { all: [] },
|
||||
},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [makeEntry()],
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
views: [view],
|
||||
onUpdateViewDefinition,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.listSort).toBe('title')
|
||||
expect(result.current.listDirection).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'modified', 'desc')
|
||||
})
|
||||
|
||||
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, { sort: 'modified:desc' })
|
||||
})
|
||||
|
||||
it('handles keyboard shortcuts for multi-select flows and ignores select-all in focused inputs', () => {
|
||||
const onArchive = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
|
||||
multiSelectState.isMultiSelecting = true
|
||||
renderHook(() => useMultiSelectKeyboard(multiSelectState as never, false, onArchive, onDelete))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
expect(multiSelectState.clear).toHaveBeenCalled()
|
||||
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
expect(multiSelectState.selectAll).not.toHaveBeenCalled()
|
||||
|
||||
input.blur()
|
||||
input.remove()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'e',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(multiSelectState.selectAll).toHaveBeenCalledOnce()
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('matches change status by relative path suffix and returns undefined outside the changes view', () => {
|
||||
const modifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/changes/note/alpha.md', relativePath: 'note/alpha.md', status: 'deleted' },
|
||||
]
|
||||
|
||||
const enabled = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
|
||||
expect(enabled.result.current('/mirror/worktree/note/alpha.md')).toBe('deleted')
|
||||
|
||||
const disabled = renderHook(() => useChangeStatusResolver(false, modifiedFiles))
|
||||
expect(disabled.result.current('/vault/changes/note/alpha.md')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for change-status lookups that do not match any modified file', () => {
|
||||
const modifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/note/a.md', relativePath: 'note/a.md', status: 'modified' },
|
||||
]
|
||||
const { result } = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
|
||||
|
||||
expect(result.current('/vault/note/a.md')).toBe('modified')
|
||||
expect(result.current('/vault/note/missing.md')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a type property picker that persists the chosen columns', () => {
|
||||
const typeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: ['status'],
|
||||
})
|
||||
const projectEntry = makeEntry({
|
||||
properties: { priority: 'High' },
|
||||
relationships: { related_to: ['Beta'] },
|
||||
})
|
||||
const onUpdateTypeSort = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListPropertyPicker({
|
||||
entries: [typeDocument, projectEntry],
|
||||
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
|
||||
inboxPeriod: 'month',
|
||||
typeDocument,
|
||||
typeEntryMap: { Project: typeDocument },
|
||||
onUpdateTypeSort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyPicker?.scope).toBe('type')
|
||||
|
||||
act(() => {
|
||||
result.current.propertyPicker?.onSave(['status', 'priority'])
|
||||
})
|
||||
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(
|
||||
typeDocument.path,
|
||||
'_list_properties_display',
|
||||
['status', 'priority'],
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the view property picker and saves view-specific list property display overrides', () => {
|
||||
const onUpdateViewDefinition = vi.fn()
|
||||
const view: ViewFile = {
|
||||
filename: 'focus.view',
|
||||
definition: {
|
||||
name: 'Focus',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
listPropertiesDisplay: [],
|
||||
},
|
||||
}
|
||||
const focusTypeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: ['status'],
|
||||
})
|
||||
const projectEntry = makeEntry({
|
||||
properties: { priority: 'High' },
|
||||
relationships: { related_to: ['Beta'] },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListPropertyPicker({
|
||||
entries: [focusTypeDocument, projectEntry],
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
inboxPeriod: 'month',
|
||||
typeDocument: null,
|
||||
typeEntryMap: { Project: focusTypeDocument },
|
||||
views: [view],
|
||||
onUpdateViewDefinition,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyPicker?.scope).toBe('view')
|
||||
|
||||
act(() => {
|
||||
result.current.propertyPicker?.onSave(null)
|
||||
})
|
||||
|
||||
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, {
|
||||
listPropertiesDisplay: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('routes deleted-note interactions through the deleted preview handlers and auto-triggers diffs for live changes', () => {
|
||||
vi.useFakeTimers()
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const onReplaceActiveTab = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
const onAutoTriggerDiff = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListInteractions({
|
||||
searched: [deletedEntry, liveEntry],
|
||||
searchedGroups: [],
|
||||
selectedNotePath: deletedEntry.path,
|
||||
selection: { kind: 'filter', filter: 'changes' },
|
||||
noteListFilter: 'open',
|
||||
isChangesView: true,
|
||||
entityEntry: null,
|
||||
searchVisible: false,
|
||||
toggleSearch: vi.fn(),
|
||||
onReplaceActiveTab,
|
||||
onOpenDeletedNote,
|
||||
onAutoTriggerDiff,
|
||||
openContextMenuForEntry: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const keyboardOptions = noteListKeyboardState.lastOptions as {
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onPrefetch: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
act(() => {
|
||||
keyboardOptions.onOpen(deletedEntry)
|
||||
keyboardOptions.onPrefetch(liveEntry)
|
||||
routeNoteClickMock.mockImplementationOnce((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onEnterNeighborhood?: (value: VaultEntry) => void },
|
||||
) => {
|
||||
actions.onEnterNeighborhood?.(entry)
|
||||
})
|
||||
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
|
||||
result.current.handleClickNote(liveEntry, {} as React.MouseEvent)
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
|
||||
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens live notes into Neighborhood mode and routes deleted clicks through the deleted preview action', async () => {
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const onReplaceActiveTab = vi.fn(async () => {})
|
||||
const onEnterNeighborhood = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
|
||||
routeNoteClickMock.mockImplementation((
|
||||
_entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onEnterNeighborhood: () => void },
|
||||
) => {
|
||||
actions.onEnterNeighborhood()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListInteractions({
|
||||
searched: [deletedEntry, liveEntry],
|
||||
searchedGroups: [],
|
||||
selectedNotePath: liveEntry.path,
|
||||
selection: { kind: 'filter', filter: 'changes' },
|
||||
noteListFilter: 'open',
|
||||
isChangesView: true,
|
||||
entityEntry: null,
|
||||
searchVisible: false,
|
||||
toggleSearch: vi.fn(),
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
openContextMenuForEntry: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const keyboardOptions = noteListKeyboardState.lastOptions as {
|
||||
onEnterNeighborhood: (entry: VaultEntry) => Promise<void>
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await keyboardOptions.onEnterNeighborhood(deletedEntry)
|
||||
await keyboardOptions.onEnterNeighborhood(liveEntry)
|
||||
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
|
||||
})
|
||||
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onEnterNeighborhood).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
})
|
||||
})
|
||||
@@ -264,22 +264,27 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
|
||||
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
|
||||
}
|
||||
|
||||
function persistOrSaveGroupSort(
|
||||
groupLabel: string,
|
||||
option: SortOption,
|
||||
direction: SortDirection,
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
|
||||
typeDocument: VaultEntry | null,
|
||||
selectedView: ViewFile | null,
|
||||
persistence: SortPersistence | null,
|
||||
) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
|
||||
if (!persistenceTarget || !persistence) {
|
||||
saveGroupSort(groupLabel, option, direction, setSortPrefs)
|
||||
function persistOrSaveGroupSort(params: {
|
||||
groupLabel: string
|
||||
option: SortOption
|
||||
direction: SortDirection
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>
|
||||
typeDocument: VaultEntry | null
|
||||
selectedView: ViewFile | null
|
||||
persistence: SortPersistence | null
|
||||
}) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(
|
||||
params.groupLabel,
|
||||
params.typeDocument,
|
||||
params.selectedView,
|
||||
params.persistence,
|
||||
)
|
||||
if (!persistenceTarget || !params.persistence) {
|
||||
saveGroupSort(params.groupLabel, params.option, params.direction, params.setSortPrefs)
|
||||
return
|
||||
}
|
||||
|
||||
persistListSort(persistenceTarget, { option, direction }, persistence)
|
||||
persistListSort(persistenceTarget, { option: params.option, direction: params.direction }, params.persistence)
|
||||
}
|
||||
|
||||
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
|
||||
@@ -332,7 +337,7 @@ export function useNoteListSort({
|
||||
}, [typeDocument, sortPrefs, persistence])
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
|
||||
persistOrSaveGroupSort(
|
||||
persistOrSaveGroupSort({
|
||||
groupLabel,
|
||||
option,
|
||||
direction,
|
||||
@@ -340,7 +345,7 @@ export function useNoteListSort({
|
||||
typeDocument,
|
||||
selectedView,
|
||||
persistence,
|
||||
)
|
||||
})
|
||||
}, [typeDocument, selectedView, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries({
|
||||
@@ -735,6 +740,59 @@ interface UseListPropertyPickerParams {
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function resolvePropertyPicker(options: {
|
||||
selectedView: ViewFile | null
|
||||
viewAvailableProperties: string[]
|
||||
viewDefaultDisplay: string[]
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
isAllNotesView: boolean
|
||||
allNotesAvailableProperties: string[]
|
||||
hasCustomAllNotesProperties: boolean
|
||||
allNotesNoteListProperties?: string[] | null
|
||||
allNotesDefaultDisplay: string[]
|
||||
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
|
||||
isInboxView: boolean
|
||||
inboxAvailableProperties: string[]
|
||||
hasCustomInboxProperties: boolean
|
||||
inboxNoteListProperties?: string[] | null
|
||||
inboxDefaultDisplay: string[]
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
isSectionGroup: boolean
|
||||
typeDocument: VaultEntry | null
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
typeAvailableProperties: string[]
|
||||
}) {
|
||||
return buildViewPropertyPicker({
|
||||
selectedView: options.selectedView,
|
||||
availableProperties: options.viewAvailableProperties,
|
||||
defaultDisplay: options.viewDefaultDisplay,
|
||||
onUpdateViewDefinition: options.onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: options.isAllNotesView,
|
||||
availableProperties: options.allNotesAvailableProperties,
|
||||
hasCustomProperties: options.hasCustomAllNotesProperties,
|
||||
noteListProperties: options.allNotesNoteListProperties,
|
||||
defaultDisplay: options.allNotesDefaultDisplay,
|
||||
onSave: options.onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: options.isInboxView,
|
||||
availableProperties: options.inboxAvailableProperties,
|
||||
hasCustomProperties: options.hasCustomInboxProperties,
|
||||
noteListProperties: options.inboxNoteListProperties,
|
||||
defaultDisplay: options.inboxDefaultDisplay,
|
||||
onSave: options.onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
isSectionGroup: options.isSectionGroup,
|
||||
typeDocument: options.typeDocument,
|
||||
onUpdateTypeSort: options.onUpdateTypeSort,
|
||||
typeAvailableProperties: options.typeAvailableProperties,
|
||||
})
|
||||
}
|
||||
|
||||
export function useListPropertyPicker({
|
||||
entries,
|
||||
selection,
|
||||
@@ -773,30 +831,23 @@ export function useListPropertyPicker({
|
||||
})
|
||||
|
||||
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
|
||||
return buildViewPropertyPicker({
|
||||
return resolvePropertyPicker({
|
||||
selectedView: viewState.selectedView,
|
||||
availableProperties: viewState.availableProperties,
|
||||
defaultDisplay: viewState.defaultDisplay,
|
||||
viewAvailableProperties: viewState.availableProperties,
|
||||
viewDefaultDisplay: viewState.defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: isAllNotesView,
|
||||
availableProperties: allNotesState.availableProperties,
|
||||
hasCustomProperties: hasCustomAllNotesProperties,
|
||||
noteListProperties: allNotesNoteListProperties,
|
||||
defaultDisplay: allNotesState.defaultDisplay,
|
||||
onSave: onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: isInboxView,
|
||||
availableProperties: inboxState.availableProperties,
|
||||
hasCustomProperties: hasCustomInboxProperties,
|
||||
noteListProperties: inboxNoteListProperties,
|
||||
defaultDisplay: inboxState.defaultDisplay,
|
||||
onSave: onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties: allNotesState.availableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay: allNotesState.defaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
isInboxView,
|
||||
inboxAvailableProperties: inboxState.availableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay: inboxState.defaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
@@ -838,6 +889,8 @@ interface UseNoteListInteractionsParams {
|
||||
noteListFilter: NoteListFilter
|
||||
isChangesView: boolean
|
||||
entityEntry: VaultEntry | null
|
||||
searchVisible: boolean
|
||||
toggleSearch: () => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
|
||||
@@ -886,6 +939,8 @@ function useKeyboardInteractionState({
|
||||
searchedGroups,
|
||||
entityEntry,
|
||||
selectedNotePath,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -895,6 +950,8 @@ function useKeyboardInteractionState({
|
||||
| 'searchedGroups'
|
||||
| 'entityEntry'
|
||||
| 'selectedNotePath'
|
||||
| 'searchVisible'
|
||||
| 'toggleSearch'
|
||||
| 'onReplaceActiveTab'
|
||||
| 'onEnterNeighborhood'
|
||||
| 'onOpenDeletedNote'
|
||||
@@ -928,6 +985,8 @@ function useKeyboardInteractionState({
|
||||
onOpen: handleKeyboardOpen,
|
||||
onEnterNeighborhood: handleNeighborhoodOpen,
|
||||
onPrefetch: handleKeyboardPrefetch,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
enabled: true,
|
||||
})
|
||||
const multiSelect = useMultiSelect(keyboardEntries, selectedNotePath)
|
||||
@@ -1025,6 +1084,8 @@ export function useNoteListInteractions({
|
||||
noteListFilter,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -1040,6 +1101,8 @@ export function useNoteListInteractions({
|
||||
searchedGroups,
|
||||
entityEntry,
|
||||
selectedNotePath,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useEffect, useMemo, useCallback } from 'react'
|
||||
import type {
|
||||
VaultEntry,
|
||||
SidebarSelection,
|
||||
@@ -15,18 +15,19 @@ import { prefetchNoteContent } from '../../hooks/useTabManagement'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
|
||||
import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch'
|
||||
import { useNoteListSearchState } from './useNoteListSearchState'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useModifiedFilesState,
|
||||
useNoteListData,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
useTypeEntryMap,
|
||||
useVisibleNotesSync,
|
||||
} from './noteListHooks'
|
||||
import { useChangesContextMenu } from './NoteListChangesMenu'
|
||||
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
|
||||
|
||||
type EntitySelection = Extract<SidebarSelection, { kind: 'entity' }>
|
||||
|
||||
@@ -138,7 +139,16 @@ function useNoteListContent({
|
||||
onUpdateViewDefinition,
|
||||
updateEntry,
|
||||
})
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const {
|
||||
closeSearch,
|
||||
isSearching,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
setSearch,
|
||||
toggleSearch,
|
||||
} = useNoteListSearchState()
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { displayPropsOverride, propertyPicker } = useListPropertyPicker({
|
||||
entries,
|
||||
@@ -191,15 +201,18 @@ function useNoteListContent({
|
||||
entityEntry,
|
||||
handleSortChange,
|
||||
isArchivedView,
|
||||
isSearching,
|
||||
isEntityView,
|
||||
listDirection,
|
||||
listSort,
|
||||
propertyPicker,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
searched,
|
||||
searchedGroups,
|
||||
closeSearch,
|
||||
setSearch,
|
||||
sortPrefs,
|
||||
toggleSearch,
|
||||
@@ -217,6 +230,8 @@ interface UseNoteListInteractionStateParams {
|
||||
isArchivedView: boolean
|
||||
isChangesView: boolean
|
||||
entityEntry: VaultEntry | null
|
||||
searchVisible: boolean
|
||||
toggleSearch: () => void
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
@@ -238,6 +253,8 @@ function useNoteListInteractionState({
|
||||
isArchivedView,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
modifiedFiles,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
@@ -266,6 +283,8 @@ function useNoteListInteractionState({
|
||||
noteListFilter,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -401,7 +420,9 @@ function buildNoteListLayoutModel(params: {
|
||||
filterCounts: ReturnType<typeof useFilterCounts>
|
||||
onNoteListFilterChange: (filter: NoteListFilter) => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
content: ReturnType<typeof useNoteListContent>
|
||||
content: ReturnType<typeof useNoteListContent> & {
|
||||
handleSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}
|
||||
interaction: ReturnType<typeof useNoteListInteractionState> & {
|
||||
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
|
||||
entitySelection: EntitySelection | null
|
||||
@@ -417,13 +438,19 @@ function buildNoteListLayoutModel(params: {
|
||||
sidebarCollapsed: params.sidebarCollapsed,
|
||||
searchVisible: params.content.searchVisible,
|
||||
search: params.content.search,
|
||||
isSearching: params.content.isSearching,
|
||||
searchInputRef: params.content.searchInputRef,
|
||||
propertyPicker: params.content.propertyPicker,
|
||||
handleSortChange: params.content.handleSortChange,
|
||||
handleCreateNote: params.interaction.handleCreateNote,
|
||||
onOpenType: params.onOpenType,
|
||||
toggleSearch: params.content.toggleSearch,
|
||||
setSearch: params.content.setSearch,
|
||||
handleSearchKeyDown: params.content.handleSearchKeyDown,
|
||||
handleListKeyDown: params.interaction.handleListKeyDown,
|
||||
noteListPanelRef: params.interaction.noteListKeyboard.panelRef,
|
||||
handleNoteListPanelBlurCapture: params.interaction.noteListKeyboard.handlePanelBlurCapture,
|
||||
handleNoteListPanelFocusCapture: params.interaction.noteListKeyboard.handlePanelFocusCapture,
|
||||
noteListContainerRef: params.interaction.noteListKeyboard.containerRef,
|
||||
handleNoteListBlur: params.interaction.noteListKeyboard.handleBlur,
|
||||
handleNoteListFocus: params.interaction.noteListKeyboard.handleFocus,
|
||||
@@ -518,6 +545,8 @@ export function useNoteListModel({
|
||||
isArchivedView: content.isArchivedView,
|
||||
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
|
||||
entityEntry: content.entityEntry,
|
||||
searchVisible: content.searchVisible,
|
||||
toggleSearch: content.toggleSearch,
|
||||
modifiedFiles,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
@@ -543,6 +572,27 @@ export function useNoteListModel({
|
||||
multiSelect: interaction.multiSelect,
|
||||
noteListKeyboard: interaction.noteListKeyboard,
|
||||
})
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
|
||||
event.preventDefault()
|
||||
content.closeSearch()
|
||||
requestAnimationFrame(() => {
|
||||
interaction.noteListKeyboard.focusList()
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive)
|
||||
return () => dispatchNoteListSearchAvailability(false)
|
||||
}, [interaction.noteListKeyboard.isPanelActive])
|
||||
|
||||
useEffect(() => {
|
||||
return addNoteListSearchToggleListener(() => {
|
||||
if (!interaction.noteListKeyboard.isPanelActive) return
|
||||
interaction.noteListKeyboard.toggleSearchShortcut()
|
||||
})
|
||||
}, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut])
|
||||
|
||||
return buildNoteListLayoutModel({
|
||||
selection,
|
||||
@@ -553,7 +603,10 @@ export function useNoteListModel({
|
||||
noteListFilter,
|
||||
filterCounts,
|
||||
onNoteListFilterChange,
|
||||
content,
|
||||
content: {
|
||||
...content,
|
||||
handleSearchKeyDown,
|
||||
},
|
||||
interaction: {
|
||||
...interaction,
|
||||
renderItem,
|
||||
|
||||
70
src/components/note-list/useNoteListSearchState.ts
Normal file
70
src/components/note-list/useNoteListSearchState.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const NOTE_LIST_SEARCH_DEBOUNCE_MS = 180
|
||||
|
||||
function normalizeSearch(search: string): string {
|
||||
return search.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function useNoteListSearchState() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const normalizedSearch = normalizeSearch(search)
|
||||
const query = useDeferredValue(debouncedQuery)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDebouncedQuery(normalizedSearch)
|
||||
}, NOTE_LIST_SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [normalizedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchVisible) return
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus()
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frameId)
|
||||
}, [searchVisible])
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearch('')
|
||||
setDebouncedQuery('')
|
||||
}, [])
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
setSearchVisible(true)
|
||||
}, [])
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setSearchVisible(false)
|
||||
clearSearch()
|
||||
}, [clearSearch])
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchVisible((visible) => {
|
||||
if (visible) clearSearch()
|
||||
return !visible
|
||||
})
|
||||
}, [clearSearch])
|
||||
|
||||
const isSearching = normalizedSearch.length > 0
|
||||
&& (normalizedSearch !== debouncedQuery || debouncedQuery !== query)
|
||||
|
||||
return {
|
||||
closeSearch,
|
||||
isSearching,
|
||||
openSearch,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
setSearch,
|
||||
toggleSearch,
|
||||
}
|
||||
}
|
||||
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openExternalUrlMock = vi.fn()
|
||||
|
||||
vi.mock('@/components/ui/action-tooltip', () => ({
|
||||
ActionTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" onClick={onClick} onKeyDown={onKeyDown} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrlMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./useDismissibleLayer', () => ({
|
||||
useDismissibleLayer: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
CommitBadge,
|
||||
ConflictBadge,
|
||||
NoRemoteBadge,
|
||||
SyncBadge,
|
||||
} from './StatusBarBadges'
|
||||
|
||||
describe('StatusBarBadges extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens commit links externally and falls back to a plain hash badge without a URL', () => {
|
||||
const { rerender } = render(
|
||||
<CommitBadge info={{ shortHash: 'abc1234', commitUrl: 'https://example.com/commit/abc1234' }} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-commit-link'))
|
||||
expect(openExternalUrlMock).toHaveBeenCalledWith('https://example.com/commit/abc1234')
|
||||
|
||||
rerender(<CommitBadge info={{ shortHash: 'def5678', commitUrl: null }} />)
|
||||
expect(screen.getByTestId('status-commit-hash')).toHaveTextContent('def5678')
|
||||
})
|
||||
|
||||
it('renders actionable and passive no-remote badges', () => {
|
||||
const onAddRemote = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NoRemoteBadge
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
onAddRemote={onAddRemote}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-no-remote'))
|
||||
expect(onAddRemote).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<NoRemoteBadge
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
|
||||
expect(screen.getByTestId('status-no-remote')).toHaveAttribute(
|
||||
'title',
|
||||
'This git vault has no remote configured. Commits stay local until you add one.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows sync popup details, handles pull actions, and covers no-remote summaries', () => {
|
||||
const onTriggerSync = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SyncBadge
|
||||
status="idle"
|
||||
lastSyncTime={Date.now() - 120_000}
|
||||
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
|
||||
onTriggerSync={onTriggerSync}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
|
||||
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('main')
|
||||
expect(screen.getByText('↑ 2 ahead')).toBeInTheDocument()
|
||||
expect(screen.getByText('↓ 1 behind')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Status: Synced/)).toBeInTheDocument()
|
||||
|
||||
const pullButton = screen.getByTestId('git-status-pull-btn')
|
||||
fireEvent.mouseEnter(pullButton)
|
||||
expect(pullButton.style.background).toBe('var(--hover)')
|
||||
fireEvent.mouseLeave(pullButton)
|
||||
expect(pullButton.style.background).toBe('transparent')
|
||||
|
||||
fireEvent.click(pullButton)
|
||||
expect(onTriggerSync).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SyncBadge
|
||||
status="idle"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={null}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('No remote configured')
|
||||
})
|
||||
|
||||
it('routes conflict and pull-required sync states to their dedicated actions', () => {
|
||||
const onOpenConflictResolver = vi.fn()
|
||||
const onPullAndPush = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SyncBadge
|
||||
status="conflict"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: true }}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onOpenConflictResolver).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SyncBadge
|
||||
status="pull_required"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 2, hasRemote: true }}
|
||||
onPullAndPush={onPullAndPush}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onPullAndPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders clickable conflict badges with plural copy', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ConflictBadge count={2} onClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-conflict-count'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('status-conflict-count')).toHaveTextContent('2 conflicts')
|
||||
})
|
||||
})
|
||||
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const {
|
||||
blockHasTypeMock,
|
||||
editorHasBlockWithTypeMock,
|
||||
formattingToolbarStore,
|
||||
hoverGuardMock,
|
||||
positionPopoverState,
|
||||
showState,
|
||||
useBlockNoteEditorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
blockHasTypeMock: vi.fn(() => true),
|
||||
editorHasBlockWithTypeMock: vi.fn(() => true),
|
||||
formattingToolbarStore: { setState: vi.fn() },
|
||||
hoverGuardMock: vi.fn(),
|
||||
positionPopoverState: { lastProps: null as null | Record<string, unknown> },
|
||||
showState: { value: true },
|
||||
useBlockNoteEditorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
function MockIcon() {
|
||||
return <svg data-testid="mock-icon" />
|
||||
}
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
FormattingToolbar: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="mock-formatting-toolbar">{children}</div>
|
||||
),
|
||||
getFormattingToolbarItems: () => [
|
||||
<div key="blockTypeSelect" />,
|
||||
<div key="boldStyleButton" />,
|
||||
<div key="italicStyleButton" />,
|
||||
<div key="strikeStyleButton" />,
|
||||
<div key="createLinkButton" />,
|
||||
],
|
||||
PositionPopover: (props: Record<string, unknown> & { children?: ReactNode }) => {
|
||||
positionPopoverState.lastProps = props
|
||||
return <div data-testid="mock-position-popover">{props.children}</div>
|
||||
},
|
||||
useBlockNoteEditor: useBlockNoteEditorMock,
|
||||
useComponentsContext: () => ({
|
||||
FormattingToolbar: {
|
||||
Button: ({
|
||||
children,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
}),
|
||||
useEditorState: ({ editor, selector }: { editor: unknown; selector: (context: { editor: unknown }) => unknown }) => selector({ editor }),
|
||||
useExtension: () => ({ store: formattingToolbarStore }),
|
||||
useExtensionState: () => showState.value,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
blockHasType: blockHasTypeMock,
|
||||
defaultProps: { textAlignment: 'left' },
|
||||
editorHasBlockWithType: editorHasBlockWithTypeMock,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
FormattingToolbarExtension: Symbol('FormattingToolbarExtension'),
|
||||
}))
|
||||
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
|
||||
CheckIcon: () => <span data-testid="mantine-check">check</span>,
|
||||
Menu: Object.assign(
|
||||
({ children }: { children?: ReactNode }) => <div data-testid="mantine-menu">{children}</div>,
|
||||
{
|
||||
Target: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
Dropdown: ({ children, ...props }: { children?: ReactNode }) => <div {...props}>{children}</div>,
|
||||
Item: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Bold: MockIcon,
|
||||
ChevronDown: MockIcon,
|
||||
Code2: MockIcon,
|
||||
Italic: MockIcon,
|
||||
Strikethrough: MockIcon,
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormattingConfig', () => ({
|
||||
filterTolariaFormattingToolbarItems: (items: ReactNode[]) => items,
|
||||
getTolariaBlockTypeSelectItems: () => [
|
||||
{ name: 'Paragraph', type: 'paragraph', props: {}, icon: MockIcon },
|
||||
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: MockIcon },
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('./blockNoteFormattingToolbarHoverGuard', () => ({
|
||||
useBlockNoteFormattingToolbarHoverGuard: hoverGuardMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
TolariaFormattingToolbar,
|
||||
TolariaFormattingToolbarController,
|
||||
} from './tolariaEditorFormatting'
|
||||
|
||||
function createMockEditor(blockType = 'image') {
|
||||
const selectedBlock = {
|
||||
id: 'file-block',
|
||||
type: blockType,
|
||||
props: { textAlignment: 'center', level: 1 },
|
||||
content: [{ type: 'text', text: 'Selected block' }],
|
||||
}
|
||||
|
||||
return {
|
||||
isEditable: true,
|
||||
schema: {
|
||||
styleSchema: {
|
||||
bold: { type: 'bold', propSchema: 'boolean' },
|
||||
italic: { type: 'italic', propSchema: 'boolean' },
|
||||
strike: { type: 'strike', propSchema: 'boolean' },
|
||||
code: { type: 'code', propSchema: 'boolean' },
|
||||
},
|
||||
},
|
||||
prosemirrorState: { selection: { from: 1, to: 5 } },
|
||||
domElement: document.createElement('div'),
|
||||
focus: vi.fn(),
|
||||
getActiveStyles: () => ({ bold: true }),
|
||||
getSelection: () => ({ blocks: [selectedBlock] }),
|
||||
getTextCursorPosition: () => ({ block: selectedBlock }),
|
||||
toggleStyles: vi.fn(),
|
||||
transact: vi.fn((callback: () => void) => callback()),
|
||||
updateBlock: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('tolariaEditorFormatting behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
positionPopoverState.lastProps = null
|
||||
showState.value = true
|
||||
useBlockNoteEditorMock.mockReturnValue(createMockEditor())
|
||||
})
|
||||
|
||||
it('renders toolbar controls, inserts the inline code button, and updates block types', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
|
||||
expect(editor.transact).toHaveBeenCalledTimes(1)
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'file-block' }),
|
||||
{ type: 'heading', props: { level: 1 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('controls the floating toolbar placement, hover guard, and escape-key close behavior', () => {
|
||||
const editor = createMockEditor()
|
||||
const toolbarComponent = () => <div data-testid="custom-toolbar">Toolbar</div>
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={toolbarComponent}
|
||||
floatingUIOptions={{ useFloatingOptions: { placement: 'top-start' } }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument()
|
||||
expect(hoverGuardMock).toHaveBeenCalledWith({
|
||||
editor,
|
||||
container: editor.domElement,
|
||||
selectedFileBlockId: 'file-block',
|
||||
isOpen: true,
|
||||
})
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
open: true,
|
||||
placement: 'top-start',
|
||||
}),
|
||||
}))
|
||||
|
||||
const onOpenChange = positionPopoverState.lastProps?.useFloatingOptions as {
|
||||
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
|
||||
}
|
||||
|
||||
onOpenChange.onOpenChange(false, undefined, 'escape-key')
|
||||
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
expect(editor.focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses block alignment when deciding the floating placement', () => {
|
||||
const editor = createMockEditor()
|
||||
editor.getTextCursorPosition = () => ({
|
||||
block: {
|
||||
id: 'paragraph-block',
|
||||
type: 'paragraph',
|
||||
props: { textAlignment: 'right' },
|
||||
content: [{ type: 'text', text: 'Paragraph' }],
|
||||
},
|
||||
})
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
placement: 'top-end',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('falls back to top-start and focuses the block type trigger on mouse down', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const focusSpy = vi.spyOn(HTMLButtonElement.prototype, 'focus').mockImplementation(() => {})
|
||||
|
||||
blockHasTypeMock.mockReturnValue(false)
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Paragraph' })[0] as HTMLButtonElement)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
placement: 'top-start',
|
||||
}),
|
||||
}))
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the toolbar open during close grace and clears the timeout on unmount', () => {
|
||||
vi.useFakeTimers()
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
const editor = createMockEditor('paragraph')
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
const { rerender, unmount } = render(<TolariaFormattingToolbarController />)
|
||||
|
||||
showState.value = false
|
||||
rerender(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(screen.getByTestId('mock-position-popover')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
|
||||
clearTimeoutSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores internal pointer and focus transitions before closing on external blur', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
|
||||
|
||||
fireEvent.pointerEnter(toolbarWrapper)
|
||||
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
|
||||
fireEvent.focus(toolbarWrapper)
|
||||
fireEvent.blur(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
|
||||
|
||||
expect(screen.getByTestId('toolbar-action')).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: document.body })
|
||||
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
|
||||
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
|
||||
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/editorLinkAutolink', () => ({
|
||||
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
|
||||
}))
|
||||
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
|
||||
function Harness({ editor }: { editor: unknown }) {
|
||||
useFilenameAutolinkGuard(editor as never)
|
||||
return null
|
||||
}
|
||||
|
||||
function createEditor({
|
||||
nodes = [],
|
||||
docChanged = true,
|
||||
withEvents = true,
|
||||
withLinkMark = true,
|
||||
}: {
|
||||
nodes?: Array<{ node: unknown; pos: number }>
|
||||
docChanged?: boolean
|
||||
withEvents?: boolean
|
||||
withLinkMark?: boolean
|
||||
}) {
|
||||
let updateHandler:
|
||||
| ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void)
|
||||
| undefined
|
||||
|
||||
const removeMark = vi.fn()
|
||||
const setMeta = vi.fn()
|
||||
const dispatch = vi.fn()
|
||||
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
|
||||
for (const entry of nodes) {
|
||||
callback(entry.node, entry.pos)
|
||||
}
|
||||
})
|
||||
|
||||
const tiptap = {
|
||||
schema: {
|
||||
marks: {
|
||||
...(withLinkMark ? { link: 'link-mark' } : {}),
|
||||
},
|
||||
},
|
||||
state: {
|
||||
doc: { descendants },
|
||||
tr: {
|
||||
docChanged,
|
||||
removeMark,
|
||||
setMeta,
|
||||
},
|
||||
},
|
||||
...(withEvents
|
||||
? {
|
||||
on: vi.fn((event: string, handler: typeof updateHandler) => {
|
||||
if (event === 'update') {
|
||||
updateHandler = handler
|
||||
}
|
||||
}),
|
||||
off: vi.fn(),
|
||||
}
|
||||
: {}),
|
||||
view: {
|
||||
dispatch,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
editor: {
|
||||
_tiptapEditor: tiptap,
|
||||
},
|
||||
tiptap,
|
||||
descendants,
|
||||
removeMark,
|
||||
setMeta,
|
||||
dispatch,
|
||||
getUpdateHandler: () => updateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFilenameAutolinkGuard extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not subscribe when the editor lacks an event API', () => {
|
||||
const fixture = createEditor({ withEvents: false })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
|
||||
expect('on' in fixture.tiptap).toBe(false)
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips traversal when the editor has no link mark registered', () => {
|
||||
const fixture = createEditor({ withLinkMark: false })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
fixture.getUpdateHandler()?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores text nodes whose marks are not accidental filename links', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(false)
|
||||
const fixture = createEditor({
|
||||
nodes: [
|
||||
{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{ type: 'link-mark', attrs: { href: 'draft.md' } }],
|
||||
},
|
||||
pos: 5,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 6,
|
||||
text: 'notes',
|
||||
marks: [{ type: 'other-mark', attrs: { href: 'notes' } }],
|
||||
},
|
||||
pos: 20,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
isText: false,
|
||||
nodeSize: 3,
|
||||
text: null,
|
||||
marks: [],
|
||||
},
|
||||
pos: 40,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
fixture.getUpdateHandler()?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(shouldStripAutoLinkedLocalFileMarkMock).toHaveBeenCalledWith({
|
||||
href: { raw: 'draft.md' },
|
||||
text: { raw: 'draft.md' },
|
||||
})
|
||||
expect(fixture.removeMark).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
|
||||
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/editorLinkAutolink', () => ({
|
||||
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
|
||||
}))
|
||||
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
|
||||
function Harness({ editor }: { editor: unknown }) {
|
||||
useFilenameAutolinkGuard(editor as never)
|
||||
return null
|
||||
}
|
||||
|
||||
function createEditor({
|
||||
nodes,
|
||||
docChanged = true,
|
||||
}: {
|
||||
nodes: Array<{ node: unknown; pos: number }>
|
||||
docChanged?: boolean
|
||||
}) {
|
||||
let updateHandler: ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void) | undefined
|
||||
const removeMark = vi.fn()
|
||||
const setMeta = vi.fn()
|
||||
const dispatch = vi.fn()
|
||||
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
|
||||
for (const entry of nodes) {
|
||||
callback(entry.node, entry.pos)
|
||||
}
|
||||
})
|
||||
|
||||
const tr = {
|
||||
docChanged,
|
||||
removeMark,
|
||||
setMeta,
|
||||
}
|
||||
|
||||
const tiptap = {
|
||||
schema: {
|
||||
marks: {
|
||||
link: 'link-mark',
|
||||
},
|
||||
},
|
||||
state: {
|
||||
doc: { descendants },
|
||||
tr,
|
||||
},
|
||||
on: vi.fn((event: string, handler: typeof updateHandler) => {
|
||||
if (event === 'update') {
|
||||
updateHandler = handler
|
||||
}
|
||||
}),
|
||||
off: vi.fn(),
|
||||
view: {
|
||||
dispatch,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
editor: {
|
||||
_tiptapEditor: tiptap,
|
||||
},
|
||||
tiptap,
|
||||
tr,
|
||||
descendants,
|
||||
dispatch,
|
||||
getUpdateHandler: () => updateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFilenameAutolinkGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('removes accidental filename link marks and tags the transaction to avoid loops', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
|
||||
const fixture = createEditor({
|
||||
nodes: [{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{
|
||||
type: 'link-mark',
|
||||
attrs: { href: 'draft.md' },
|
||||
}],
|
||||
},
|
||||
pos: 4,
|
||||
}],
|
||||
})
|
||||
|
||||
const { unmount } = render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
expect(updateHandler).toBeTypeOf('function')
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.tr.removeMark).toHaveBeenCalledWith(4, 12, 'link-mark')
|
||||
expect(fixture.tr.setMeta).toHaveBeenCalledWith('tolaria-filename-autolink-guard', true)
|
||||
expect(fixture.dispatch).toHaveBeenCalledWith(fixture.tr)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(fixture.tiptap.off).toHaveBeenCalledWith('update', updateHandler)
|
||||
})
|
||||
|
||||
it('skips guard runs that are already tagged or have no document changes', () => {
|
||||
const fixture = createEditor({ nodes: [] })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: false,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => true),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch when the stripped ranges leave the document unchanged', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
|
||||
const fixture = createEditor({
|
||||
docChanged: false,
|
||||
nodes: [{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{
|
||||
type: 'link-mark',
|
||||
attrs: { href: 'draft.md' },
|
||||
}],
|
||||
},
|
||||
pos: 10,
|
||||
}],
|
||||
})
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.tr.removeMark).toHaveBeenCalledWith(10, 18, 'link-mark')
|
||||
expect(fixture.tr.setMeta).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -79,18 +79,21 @@ function useTrackRawBuffer({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
}: {
|
||||
activeTabContent: string | null
|
||||
activeTabPath: string | null
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTabPath) {
|
||||
rawLatestContentRef.current = null
|
||||
rawInitialContentRef.current = null
|
||||
rawBufferPathRef.current = null
|
||||
rawSourceContentRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,21 +104,25 @@ function useTrackRawBuffer({
|
||||
rawLatestContentRef.current = activeTabContent
|
||||
rawInitialContentRef.current = activeTabContent
|
||||
rawBufferPathRef.current = activeTabContent === null ? null : activeTabPath
|
||||
}, [activeTabContent, activeTabPath, rawBufferPathRef, rawInitialContentRef, rawLatestContentRef])
|
||||
rawSourceContentRef.current = activeTabContent
|
||||
}, [activeTabContent, activeTabPath, rawBufferPathRef, rawInitialContentRef, rawLatestContentRef, rawSourceContentRef])
|
||||
}
|
||||
|
||||
function resetRawBufferState({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
}: {
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
rawInitialContentRef.current = null
|
||||
rawBufferPathRef.current = null
|
||||
rawLatestContentRef.current = null
|
||||
rawSourceContentRef.current = null
|
||||
}
|
||||
|
||||
function useHandleFlushPending({
|
||||
@@ -124,6 +131,7 @@ function useHandleFlushPending({
|
||||
activeTabContent,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
@@ -133,11 +141,13 @@ function useHandleFlushPending({
|
||||
activeTabContent: string | null
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
return useCallback(async () => {
|
||||
rawSourceContentRef.current = activeTabContent
|
||||
const syncedContent = syncActiveTabIntoRawBuffer({
|
||||
editor,
|
||||
activeTabPath,
|
||||
@@ -164,6 +174,7 @@ function useHandleFlushPending({
|
||||
pendingRoundTripRawRestoreRef,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
setRawModeContentOverride,
|
||||
])
|
||||
}
|
||||
@@ -175,6 +186,7 @@ function useHandleBeforeRawEnd({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
@@ -187,6 +199,7 @@ function useHandleBeforeRawEnd({
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRichRestoreRef: React.MutableRefObject<RawEditorPositionSnapshot | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
@@ -205,7 +218,7 @@ function useHandleBeforeRawEnd({
|
||||
onContentChange,
|
||||
}))
|
||||
setRawModeContentOverride(null)
|
||||
resetRawBufferState({ rawInitialContentRef, rawBufferPathRef, rawLatestContentRef })
|
||||
resetRawBufferState({ rawInitialContentRef, rawBufferPathRef, rawLatestContentRef, rawSourceContentRef })
|
||||
}, [
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
@@ -216,11 +229,41 @@ function useHandleBeforeRawEnd({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
setPendingRawExitContent,
|
||||
setRawModeContentOverride,
|
||||
])
|
||||
}
|
||||
|
||||
function useSyncRawModeContentOverride({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
rawSourceContentRef,
|
||||
setRawModeContentOverride,
|
||||
}: {
|
||||
activeTabContent: string | null
|
||||
activeTabPath: string | null
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
const syncRawModeContentOverride = (
|
||||
current: PendingRawExitContent | null,
|
||||
nextContent: string,
|
||||
) => {
|
||||
if (!current) return current
|
||||
if (current.path !== activeTabPath || current.content === nextContent) return current
|
||||
return { path: activeTabPath, content: nextContent }
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTabPath || activeTabContent === null) return
|
||||
if (rawSourceContentRef.current === null || activeTabContent === rawSourceContentRef.current) return
|
||||
const nextContent = activeTabContent
|
||||
|
||||
setRawModeContentOverride((current) => syncRawModeContentOverride(current, nextContent))
|
||||
}, [activeTabContent, activeTabPath, rawSourceContentRef, setRawModeContentOverride])
|
||||
}
|
||||
|
||||
export function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
@@ -230,6 +273,7 @@ export function useRawModeWithFlush(
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const rawInitialContentRef = useRef<string | null>(null)
|
||||
const rawBufferPathRef = useRef<string | null>(null)
|
||||
const rawSourceContentRef = useRef<string | null>(null)
|
||||
const pendingRawRestoreRef = useRef<CodeMirrorRestoreState | null>(null)
|
||||
const pendingRichRestoreRef = useRef<RawEditorPositionSnapshot | null>(null)
|
||||
const pendingRoundTripRawRestoreRef = useRef<PendingRoundTripRawRestore | null>(null)
|
||||
@@ -241,6 +285,13 @@ export function useRawModeWithFlush(
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
})
|
||||
useSyncRawModeContentOverride({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
rawSourceContentRef,
|
||||
setRawModeContentOverride,
|
||||
})
|
||||
|
||||
const handleFlushPending = useHandleFlushPending({
|
||||
@@ -249,6 +300,7 @@ export function useRawModeWithFlush(
|
||||
activeTabContent,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
@@ -260,6 +312,7 @@ export function useRawModeWithFlush(
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
|
||||
161
src/extensions/zoomCursorFix.behavior.test.ts
Normal file
161
src/extensions/zoomCursorFix.behavior.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { defineMock } = vi.hoisted(() => ({
|
||||
defineMock: vi.fn((factory: (view: unknown) => unknown) => factory),
|
||||
}))
|
||||
|
||||
vi.mock('@codemirror/view', () => ({
|
||||
EditorView: class EditorView {},
|
||||
ViewPlugin: {
|
||||
define: defineMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
|
||||
|
||||
function mockComputedZoom(value: string) {
|
||||
const real = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
|
||||
const style = real(elt, pseudo)
|
||||
if (elt === document.documentElement) {
|
||||
return new Proxy(style, {
|
||||
get(target, prop) {
|
||||
if (prop === 'zoom') return value
|
||||
const next = Reflect.get(target, prop)
|
||||
return typeof next === 'function' ? next.bind(target) : next
|
||||
},
|
||||
})
|
||||
}
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
function mockInlineZoom(value: string) {
|
||||
return vi.spyOn(document.documentElement.style, 'getPropertyValue').mockImplementation((name: string) => {
|
||||
if (name === 'zoom') return value
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
function createView() {
|
||||
const contentDOM = document.createElement('div')
|
||||
const textNode = document.createTextNode('hello')
|
||||
contentDOM.appendChild(textNode)
|
||||
|
||||
const origPosAtCoords = vi.fn(() => 11)
|
||||
const origPosAndSideAtCoords = vi.fn(() => ({ pos: 13, assoc: -1 as const }))
|
||||
const prototype = {
|
||||
posAtCoords: origPosAtCoords,
|
||||
posAndSideAtCoords: origPosAndSideAtCoords,
|
||||
}
|
||||
|
||||
const view = Object.assign(Object.create(prototype), {
|
||||
contentDOM,
|
||||
posAtDOM: vi.fn(() => 17),
|
||||
})
|
||||
|
||||
return {
|
||||
view,
|
||||
textNode,
|
||||
origPosAtCoords,
|
||||
origPosAndSideAtCoords,
|
||||
}
|
||||
}
|
||||
|
||||
describe('zoomCursorFix behavior', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
delete (document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
||||
}).caretRangeFromPoint
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads inline percentage zoom values when computed zoom is normal', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('125%')
|
||||
|
||||
expect(getDocumentZoom()).toBe(1.25)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to 1 for invalid inline zoom values', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('banana')
|
||||
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses caretRangeFromPoint when CSS zoom is active and restores prototype methods on destroy', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('150%')
|
||||
const { view, textNode } = createView()
|
||||
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ startContainer: textNode, startOffset: 2 })),
|
||||
})
|
||||
|
||||
const pluginFactory = zoomCursorFix() as unknown as (view: typeof view) => { destroy: () => void }
|
||||
const plugin = pluginFactory(view)
|
||||
|
||||
expect(view.posAtCoords({ x: 30, y: 40 }, true)).toBe(17)
|
||||
expect(view.posAndSideAtCoords({ x: 30, y: 40 }, false)).toEqual({ pos: 17, assoc: 1 })
|
||||
expect(view.posAtDOM).toHaveBeenCalledWith(textNode, 2)
|
||||
expect(Object.hasOwn(view, 'posAtCoords')).toBe(true)
|
||||
|
||||
plugin.destroy()
|
||||
|
||||
expect(Object.hasOwn(view, 'posAtCoords')).toBe(false)
|
||||
expect(view.posAtCoords({ x: 5, y: 6 }, false)).toBe(11)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to adjusted coordinates when the browser API is unavailable or returns an unusable range', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('200%')
|
||||
|
||||
const pluginFactory = zoomCursorFix() as unknown as (view: ReturnType<typeof createView>['view']) => { destroy: () => void }
|
||||
|
||||
const first = createView()
|
||||
pluginFactory(first.view)
|
||||
expect(first.view.posAtCoords({ x: 40, y: 60 }, true)).toBe(11)
|
||||
expect(first.origPosAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, true)
|
||||
expect(first.view.posAndSideAtCoords({ x: 40, y: 60 }, false)).toEqual({ pos: 13, assoc: -1 })
|
||||
expect(first.origPosAndSideAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, false)
|
||||
|
||||
const second = createView()
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ startContainer: document.createTextNode('outside'), startOffset: 0 })),
|
||||
})
|
||||
pluginFactory(second.view)
|
||||
expect(second.view.posAtCoords({ x: 50, y: 70 }, false)).toBe(11)
|
||||
expect(second.origPosAtCoords).toHaveBeenCalledWith({ x: 25, y: 35 }, false)
|
||||
|
||||
const third = createView()
|
||||
third.view.posAtDOM.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ startContainer: third.textNode, startOffset: 1 })),
|
||||
})
|
||||
pluginFactory(third.view)
|
||||
expect(third.view.posAtCoords({ x: 60, y: 80 }, false)).toBe(11)
|
||||
expect(third.origPosAtCoords).toHaveBeenCalledWith({ x: 30, y: 40 }, false)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
113
src/extensions/zoomCursorFix.extra.test.ts
Normal file
113
src/extensions/zoomCursorFix.extra.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
|
||||
|
||||
function mockComputedZoom(value: string) {
|
||||
const realGetComputedStyle = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => {
|
||||
const style = realGetComputedStyle(element, pseudo)
|
||||
if (element === document.documentElement) {
|
||||
return new Proxy(style, {
|
||||
get(target, prop) {
|
||||
if (prop === 'zoom') return value
|
||||
const current = Reflect.get(target, prop)
|
||||
return typeof current === 'function' ? current.bind(target) : current
|
||||
},
|
||||
})
|
||||
}
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
describe('zoomCursorFix extra coverage', () => {
|
||||
let parent: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
parent = document.createElement('div')
|
||||
document.body.appendChild(parent)
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
document.body.innerHTML = ''
|
||||
delete (document as Document & { caretRangeFromPoint?: unknown }).caretRangeFromPoint
|
||||
})
|
||||
|
||||
function createView() {
|
||||
return new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: 'hello world',
|
||||
extensions: [zoomCursorFix()],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
it('falls back to inline zoom values when computed zoom is invalid', () => {
|
||||
mockComputedZoom('not-a-number')
|
||||
const inlineZoomSpy = vi.spyOn(document.documentElement.style, 'getPropertyValue')
|
||||
|
||||
inlineZoomSpy.mockReturnValueOnce('125%')
|
||||
expect(getDocumentZoom()).toBe(1.25)
|
||||
|
||||
inlineZoomSpy.mockReturnValueOnce('-20%')
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
})
|
||||
|
||||
it('uses caretRangeFromPoint for zoomed coordinates and restores prototype methods on destroy', () => {
|
||||
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockReturnValue(99)
|
||||
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockReturnValue({ pos: 99, assoc: -1 })
|
||||
const view = createView()
|
||||
mockComputedZoom('2')
|
||||
|
||||
const textNode = view.contentDOM.querySelector('.cm-line')?.firstChild
|
||||
expect(textNode).toBeTruthy()
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(textNode as Node, 3)
|
||||
range.setEnd(textNode as Node, 3)
|
||||
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => range),
|
||||
})
|
||||
|
||||
expect(view.posAtCoords({ x: 20, y: 30 }, true)).toBe(3)
|
||||
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
|
||||
.posAndSideAtCoords({ x: 20, y: 30 }, false)).toEqual({ pos: 3, assoc: 1 })
|
||||
expect(protoPosAtCoords).not.toHaveBeenCalled()
|
||||
expect(protoPosAndSideAtCoords).not.toHaveBeenCalled()
|
||||
|
||||
view.destroy()
|
||||
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(false)
|
||||
expect(Object.prototype.hasOwnProperty.call(view, 'posAndSideAtCoords')).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to original coordinate methods when zoom is 1 or caret lookup misses', () => {
|
||||
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockImplementation((coords) => (
|
||||
coords.x === 10 && coords.y === 15 ? 11 : 7
|
||||
))
|
||||
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockImplementation((coords) => (
|
||||
coords.x === 10 && coords.y === 15 ? { pos: 11, assoc: -1 } : { pos: 7, assoc: -1 }
|
||||
))
|
||||
const view = createView()
|
||||
|
||||
expect(view.posAtCoords({ x: 8, y: 12 }, true)).toBe(7)
|
||||
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 8, y: 12 }, true)
|
||||
|
||||
mockComputedZoom('2')
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => null),
|
||||
})
|
||||
|
||||
expect(view.posAtCoords({ x: 20, y: 30 }, false)).toBe(11)
|
||||
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, false)
|
||||
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
|
||||
.posAndSideAtCoords({ x: 20, y: 30 }, true)).toEqual({ pos: 11, assoc: -1 })
|
||||
expect(protoPosAndSideAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, true)
|
||||
})
|
||||
})
|
||||
93
src/hooks/editorFocusUtils.extra.test.ts
Normal file
93
src/hooks/editorFocusUtils.extra.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { focusEditorWithRetries } from './editorFocusUtils'
|
||||
|
||||
describe('editorFocusUtils extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('uses window focus and selection fallback before logging successful focus timing', () => {
|
||||
const editable = document.createElement('div')
|
||||
editable.className = 'ProseMirror'
|
||||
editable.contentEditable = 'true'
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
Object.defineProperty(editable, 'isContentEditable', { configurable: true, value: true })
|
||||
document.body.appendChild(editable)
|
||||
|
||||
const realFocus = HTMLElement.prototype.focus.bind(editable)
|
||||
let focusCalls = 0
|
||||
vi.spyOn(editable, 'focus').mockImplementation(() => {
|
||||
focusCalls += 1
|
||||
if (focusCalls >= 2) realFocus()
|
||||
})
|
||||
|
||||
const selection = {
|
||||
removeAllRanges: vi.fn(),
|
||||
addRange: vi.fn(),
|
||||
} as unknown as Selection
|
||||
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0')
|
||||
const windowFocusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue(selection)
|
||||
vi.spyOn(performance, 'now').mockReturnValue(150)
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
|
||||
focusEditorWithRetries({ focus: vi.fn() }, false, 100)
|
||||
|
||||
expect(windowFocusSpy).toHaveBeenCalled()
|
||||
expect(selection.removeAllRanges).toHaveBeenCalled()
|
||||
expect(selection.addRange).toHaveBeenCalledTimes(1)
|
||||
expect(debugSpy).toHaveBeenCalledWith('[perf] createNote → focus: 50.0ms')
|
||||
})
|
||||
|
||||
it('uses the fallback editable selector and treats mixed heading content as empty text', () => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'bn-editor'
|
||||
const editable = document.createElement('div')
|
||||
editable.contentEditable = 'true'
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
Object.defineProperty(editable, 'isContentEditable', { configurable: true, value: true })
|
||||
wrapper.appendChild(editable)
|
||||
document.body.appendChild(wrapper)
|
||||
|
||||
const realFocus = HTMLElement.prototype.focus.bind(editable)
|
||||
vi.spyOn(editable, 'focus').mockImplementation(() => realFocus())
|
||||
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 1
|
||||
})
|
||||
|
||||
const setTextCursorPosition = vi.fn()
|
||||
|
||||
focusEditorWithRetries({
|
||||
focus: vi.fn(),
|
||||
document: [
|
||||
{
|
||||
id: 'title',
|
||||
type: 'heading',
|
||||
content: [{ kind: 'ignored' }, { type: 'text' }],
|
||||
},
|
||||
],
|
||||
setTextCursorPosition,
|
||||
}, true, undefined)
|
||||
|
||||
expect(rAF).toHaveBeenCalled()
|
||||
expect(setTextCursorPosition).toHaveBeenCalledWith('title', 'start')
|
||||
})
|
||||
|
||||
it('schedules another animation frame when nothing focusable is available yet', () => {
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
|
||||
focusEditorWithRetries({ focus: vi.fn() }, false, undefined)
|
||||
|
||||
expect(rAF).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
62
src/hooks/rawEditorEntryState.ts
Normal file
62
src/hooks/rawEditorEntryState.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { frontmatterToEntryPatch } from './frontmatterOps'
|
||||
|
||||
function createRawEditorEntryState(): Partial<VaultEntry> {
|
||||
return {
|
||||
aliases: [],
|
||||
archived: false,
|
||||
belongsTo: [],
|
||||
color: null,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
icon: null,
|
||||
isA: null,
|
||||
listPropertiesDisplay: [],
|
||||
order: null,
|
||||
organized: false,
|
||||
properties: {},
|
||||
relatedTo: [],
|
||||
relationships: {},
|
||||
sidebarLabel: null,
|
||||
sort: null,
|
||||
status: null,
|
||||
template: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRelationships(target: Record<string, string[]>, source: Record<string, string[] | null> | null): void {
|
||||
if (!source) return
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (Array.isArray(value) && value.length > 0) target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProperties(
|
||||
target: Record<string, string | number | boolean | null>,
|
||||
source: Record<string, string | number | boolean | null> | null,
|
||||
): void {
|
||||
if (!source) return
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value !== null) target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveRawEditorEntryState(content: string): Partial<VaultEntry> {
|
||||
const derived = createRawEditorEntryState()
|
||||
const properties: Record<string, string | number | boolean | null> = {}
|
||||
const relationships: Record<string, string[]> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(parseFrontmatter(content))) {
|
||||
const { patch, relationshipPatch, propertiesPatch } = frontmatterToEntryPatch('update', key, value)
|
||||
Object.assign(derived, patch)
|
||||
mergeRelationships(relationships, relationshipPatch)
|
||||
mergeProperties(properties, propertiesPatch)
|
||||
}
|
||||
|
||||
derived.properties = properties
|
||||
derived.relationships = relationships
|
||||
return derived
|
||||
}
|
||||
201
src/hooks/useAutoSync.extra.test.ts
Normal file
201
src/hooks/useAutoSync.extra.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAutoSync } from './useAutoSync'
|
||||
import type { GitPullResult, GitRemoteStatus } from '../types'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
const LAST_COMMIT_INFO = {
|
||||
shortHash: 'a1b2c3d',
|
||||
commitUrl: 'https://github.com/owner/repo/commit/abc123',
|
||||
}
|
||||
|
||||
const REMOTE_STATUS: GitRemoteStatus = {
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
}
|
||||
|
||||
function upToDate(): GitPullResult {
|
||||
return {
|
||||
status: 'up_to_date',
|
||||
message: 'Already up to date',
|
||||
updatedFiles: [],
|
||||
conflictFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function updated(files: string[]): GitPullResult {
|
||||
return {
|
||||
status: 'updated',
|
||||
message: `${files.length} files updated`,
|
||||
updatedFiles: files,
|
||||
conflictFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function conflict(files: string[]): GitPullResult {
|
||||
return {
|
||||
status: 'conflict',
|
||||
message: 'Merge conflicts detected',
|
||||
updatedFiles: [],
|
||||
conflictFiles: files,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMockImplementation(command: string) {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
|
||||
return Promise.resolve(upToDate())
|
||||
}
|
||||
|
||||
function renderSync(overrides: Partial<Parameters<typeof useAutoSync>[0]> = {}) {
|
||||
const onVaultUpdated = vi.fn()
|
||||
const onSyncUpdated = vi.fn()
|
||||
const onConflict = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
const hook = renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
...overrides,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
...hook,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForInitialIdle(
|
||||
result: ReturnType<typeof renderSync>['result'],
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
expect(result.current.remoteStatus).toEqual(REMOTE_STATUS)
|
||||
})
|
||||
mockInvokeFn.mockClear()
|
||||
}
|
||||
|
||||
describe('useAutoSync extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(defaultMockImplementation)
|
||||
})
|
||||
|
||||
it('pulls, pushes, and refreshes remote status after a recovery sync', async () => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve({ ...REMOTE_STATUS, behind: 1 })
|
||||
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
|
||||
return Promise.resolve(updated(['notes/today.md']))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'])
|
||||
expect(hook.onSyncUpdated).toHaveBeenCalledOnce()
|
||||
expect(hook.onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
|
||||
expect(hook.result.current.syncStatus).toBe('idle')
|
||||
expect(hook.result.current.remoteStatus).toEqual({ ...REMOTE_STATUS, behind: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces conflicts from pullAndPush without attempting a push', async () => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
|
||||
return Promise.resolve(conflict(['plans/weekly.md']))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.onConflict).toHaveBeenCalledWith(['plans/weekly.md'])
|
||||
expect(hook.result.current.syncStatus).toBe('conflict')
|
||||
expect(hook.result.current.conflictFiles).toEqual(['plans/weekly.md'])
|
||||
})
|
||||
expect(
|
||||
mockInvokeFn.mock.calls.some(([command]) => command === 'git_push'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'marks pull_required when the follow-up push is rejected',
|
||||
pushResult: { status: 'rejected', message: 'Remote advanced again' },
|
||||
expectedStatus: 'pull_required',
|
||||
expectedToast: 'Push still rejected after pull — try again',
|
||||
},
|
||||
{
|
||||
name: 'surfaces follow-up push errors',
|
||||
pushResult: { status: 'network_error', message: 'Push failed: offline' },
|
||||
expectedStatus: 'error',
|
||||
expectedToast: 'Push failed: offline',
|
||||
},
|
||||
])('$name', async ({ pushResult, expectedStatus, expectedToast }) => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
|
||||
if (command === 'git_push') return Promise.resolve(pushResult)
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.syncStatus).toBe(expectedStatus)
|
||||
expect(hook.onToast).toHaveBeenCalledWith(expectedToast)
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes a manual pull-required state for rejected save flows', async () => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
act(() => {
|
||||
hook.result.current.handlePushRejected()
|
||||
})
|
||||
|
||||
expect(hook.result.current.syncStatus).toBe('pull_required')
|
||||
})
|
||||
})
|
||||
@@ -359,4 +359,137 @@ describe('useAutoSync', () => {
|
||||
expect(result.current.conflictFiles).toEqual(['conflict.md'])
|
||||
})
|
||||
})
|
||||
|
||||
it('pulls, pushes, and emits a success toast when recovery succeeds', async () => {
|
||||
const onSyncUpdated = vi.fn()
|
||||
let pullCount = 0
|
||||
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve([])
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
if (cmd === 'git_remote_status') return Promise.resolve(null)
|
||||
if (cmd === 'git_pull') {
|
||||
pullCount += 1
|
||||
return Promise.resolve(pullCount === 1 ? upToDate() : updated(['note.md']))
|
||||
}
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md'])
|
||||
expect(onSyncUpdated).toHaveBeenCalled()
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
it('marks pull_required when the recovery push is still rejected', async () => {
|
||||
let pullCount = 0
|
||||
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve([])
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
if (cmd === 'git_remote_status') return Promise.resolve(null)
|
||||
if (cmd === 'git_pull') {
|
||||
pullCount += 1
|
||||
return Promise.resolve(pullCount === 1 ? upToDate() : upToDate())
|
||||
}
|
||||
if (cmd === 'git_push') {
|
||||
return Promise.resolve({
|
||||
status: 'rejected',
|
||||
message: 'Push rejected: remote has new commits. Pull first, then push.',
|
||||
})
|
||||
}
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
const { result } = renderSync()
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('pull_required')
|
||||
expect(onToast).toHaveBeenCalledWith('Push still rejected after pull — try again')
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces pull conflicts and pull errors during recovery pushes', async () => {
|
||||
let mode: 'conflict' | 'error' = 'conflict'
|
||||
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve([])
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
if (cmd === 'git_remote_status') return Promise.resolve(null)
|
||||
if (cmd === 'git_pull') {
|
||||
return Promise.resolve(
|
||||
mode === 'conflict'
|
||||
? conflict(['note.md'])
|
||||
: { status: 'error', message: 'fetch failed', updatedFiles: [], conflictFiles: [] },
|
||||
)
|
||||
}
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
const { result } = renderSync()
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.pullAndPush()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('conflict')
|
||||
expect(result.current.conflictFiles).toEqual(['note.md'])
|
||||
})
|
||||
|
||||
mode = 'error'
|
||||
await act(async () => {
|
||||
result.current.pullAndPush()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('error')
|
||||
expect(onToast).toHaveBeenCalledWith('Pull failed: fetch failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes a direct push-rejected handler for external workflows', async () => {
|
||||
const { result } = renderSync()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handlePushRejected()
|
||||
})
|
||||
|
||||
expect(result.current.syncStatus).toBe('pull_required')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,14 +376,23 @@ describe('extractVaultTypes', () => {
|
||||
expect(extractVaultTypes(entries)).toEqual(['Note', 'Project'])
|
||||
})
|
||||
|
||||
it('omits the legacy Journal type from extracted command-palette types', () => {
|
||||
it('omits the legacy Journal type when no Type document defines it', () => {
|
||||
const entries = [
|
||||
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
{ path: '/note.md', title: 'General Note', isA: 'Note' },
|
||||
] as never[]
|
||||
|
||||
expect(extractVaultTypes(entries)).toEqual(['Note'])
|
||||
})
|
||||
|
||||
it('includes Journal when a real Type document defines it', () => {
|
||||
const entries = [
|
||||
{ path: '/journal.md', title: 'Journal', isA: 'Type' },
|
||||
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
{ path: '/note.md', title: 'General Note', isA: 'Note' },
|
||||
] as never[]
|
||||
|
||||
expect(extractVaultTypes(entries)).toEqual(['Note'])
|
||||
expect(extractVaultTypes(entries)).toEqual(['Journal', 'Note'])
|
||||
})
|
||||
|
||||
it('omits hidden types from extracted command-palette types', () => {
|
||||
|
||||
@@ -157,10 +157,10 @@ describe('useEditorSaveWithLinks', () => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Project',
|
||||
status: 'Active',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
@@ -185,9 +185,9 @@ describe('useEditorSaveWithLinks', () => {
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Essay',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
@@ -196,15 +196,66 @@ describe('useEditorSaveWithLinks', () => {
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Note',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs custom relationships and properties from raw frontmatter immediately', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
properties: { Owner: '[[person/alice]]', custom: 'value' },
|
||||
relationships: { Owner: ['[[person/alice]]'] },
|
||||
}))
|
||||
})
|
||||
|
||||
it('clears stale note-list and inspector metadata when raw frontmatter is removed', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
|
||||
})
|
||||
|
||||
updateEntry.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'Body without frontmatter')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
belongsTo: [],
|
||||
properties: {},
|
||||
relationships: {},
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps the last derived entry state while the raw frontmatter is temporarily incomplete', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\n---\nBody')
|
||||
})
|
||||
|
||||
updateEntry.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/old-title.md', '# Renamed Note\n\nBody', { title: 'Renamed Note', hasH1: true }],
|
||||
['/renamed-note.md', 'Body without a heading', { title: 'Renamed Note', hasH1: false }],
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { startTransition, useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import { contentToEntryPatch } from './frontmatterOps'
|
||||
import { deriveRawEditorEntryState } from './rawEditorEntryState'
|
||||
import { deriveDisplayTitleState } from '../utils/noteTitle'
|
||||
import { detectFrontmatterState } from '../utils/frontmatter'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const EMPTY_DERIVED_ENTRY_STATE_KEY = JSON.stringify(deriveRawEditorEntryState(''))
|
||||
|
||||
function shouldSyncFrontmatterState(content: string): boolean {
|
||||
const frontmatterState = detectFrontmatterState(content)
|
||||
if (frontmatterState === 'invalid') return false
|
||||
return !(frontmatterState === 'none' && content.startsWith('---\n'))
|
||||
}
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
@@ -34,7 +43,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
})
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)
|
||||
const prevTitleKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
@@ -44,19 +53,23 @@ export function useEditorSaveWithLinks(config: {
|
||||
prevLinksKeyRef.current = key
|
||||
updateEntry(path, { outgoingLinks: links })
|
||||
}
|
||||
const frontmatterPatch = contentToEntryPatch(content)
|
||||
const frontmatterPatch = shouldSyncFrontmatterState(content)
|
||||
? deriveRawEditorEntryState(content)
|
||||
: null
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const titlePatch = deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
frontmatterTitle: typeof frontmatterPatch?.title === 'string' ? frontmatterPatch.title : null,
|
||||
})
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
|
||||
if (frontmatterPatch) {
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
updateEntry(path, fmPatch)
|
||||
}
|
||||
}
|
||||
const titleKey = JSON.stringify(titlePatch)
|
||||
if (titleKey !== prevTitleKeyRef.current) {
|
||||
|
||||
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchMenuEvent, useMenuEvents, type MenuEventHandlers } from './useMenuEvents'
|
||||
|
||||
const isTauriMock = vi.fn(() => false)
|
||||
const listenMock = vi.fn()
|
||||
const invokeMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: (...args: unknown[]) => listenMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}))
|
||||
|
||||
function makeHandlers(): MenuEventHandlers {
|
||||
return {
|
||||
onSetViewMode: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onCreateType: vi.fn(),
|
||||
onQuickOpen: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onToggleInspector: vi.fn(),
|
||||
onCommandPalette: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
onToggleRawEditor: vi.fn(),
|
||||
onToggleDiff: vi.fn(),
|
||||
onToggleAIChat: vi.fn(),
|
||||
onGoBack: vi.fn(),
|
||||
onGoForward: vi.fn(),
|
||||
onCheckForUpdates: vi.fn(),
|
||||
onSelectFilter: vi.fn(),
|
||||
onOpenVault: vi.fn(),
|
||||
onRemoveActiveVault: vi.fn(),
|
||||
onRestoreGettingStarted: vi.fn(),
|
||||
onAddRemote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
onRestoreDeletedNote: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
multiSelectionCommandRef: { current: null },
|
||||
activeTabPath: '/vault/test.md',
|
||||
hasRestorableDeletedNote: false,
|
||||
hasNoRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMenuEvents note-list search bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('dispatches the note-list search toggle event for the native Cmd+F menu item', () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('laputa:toggle-note-list-search', listener)
|
||||
|
||||
dispatchMenuEvent('edit-toggle-note-list-search', makeHandlers())
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
window.removeEventListener('laputa:toggle-note-list-search', listener)
|
||||
})
|
||||
|
||||
it('syncs note-list search availability into the native menu state', async () => {
|
||||
isTauriMock.mockReturnValue(true)
|
||||
listenMock.mockResolvedValue(vi.fn())
|
||||
|
||||
renderHook(() => useMenuEvents(makeHandlers()))
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:note-list-search-availability', {
|
||||
detail: { enabled: true },
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenLastCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: true }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
isAppCommandId,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
import {
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
dispatchNoteListSearchToggle,
|
||||
readNoteListSearchAvailability,
|
||||
} from '../utils/noteListSearchEvents'
|
||||
|
||||
const NOTE_LIST_SEARCH_MENU_ID = 'edit-toggle-note-list-search'
|
||||
|
||||
export interface MenuEventHandlers extends AppCommandHandlers {
|
||||
activeTabPath: string | null
|
||||
@@ -21,6 +28,7 @@ interface MenuStatePayload {
|
||||
hasConflicts?: boolean
|
||||
hasRestorableDeletedNote?: boolean
|
||||
hasNoRemote?: boolean
|
||||
noteListSearchEnabled?: boolean
|
||||
}
|
||||
|
||||
function readCustomEventDetail(event: Event): string | null {
|
||||
@@ -118,8 +126,28 @@ function useNativeMenuStateSync(state: MenuStatePayload) {
|
||||
}, [state])
|
||||
}
|
||||
|
||||
function useNoteListSearchMenuState() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleAvailabilityEvent = (event: Event) => {
|
||||
const nextEnabled = readNoteListSearchAvailability(event)
|
||||
if (nextEnabled !== null) setEnabled(nextEnabled)
|
||||
}
|
||||
|
||||
window.addEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
}, [])
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
|
||||
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
if (id === NOTE_LIST_SEARCH_MENU_ID) {
|
||||
dispatchNoteListSearchToggle()
|
||||
return
|
||||
}
|
||||
if (!isAppCommandId(id)) return
|
||||
executeAppCommand(id, h, 'native-menu')
|
||||
}
|
||||
@@ -127,6 +155,7 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
|
||||
export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
const ref = useRef(handlers)
|
||||
const noteListSearchEnabled = useNoteListSearchMenuState()
|
||||
const hasActiveNote = handlers.activeTabPath !== null
|
||||
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
|
||||
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
|
||||
@@ -146,5 +175,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
hasConflicts,
|
||||
hasRestorableDeletedNote,
|
||||
hasNoRemote,
|
||||
noteListSearchEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,4 +55,31 @@ describe('useNoteActions frontmatter persistence', () => {
|
||||
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'update',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'delete',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
||||
},
|
||||
},
|
||||
])('flushes pending raw content before a frontmatter $label', async ({ run }) => {
|
||||
const flushBeforeFrontmatterChange = vi.fn().mockResolvedValue(undefined)
|
||||
const { result } = renderHook(() => useNoteActions({
|
||||
...makeConfig(vi.fn()),
|
||||
flushBeforeFrontmatterChange,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await run(result)
|
||||
})
|
||||
|
||||
expect(flushBeforeFrontmatterChange).toHaveBeenCalledWith('/vault/note.md')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface NoteActionsConfig {
|
||||
removeEntry: (path: string) => void
|
||||
entries: VaultEntry[]
|
||||
flushBeforeNoteSwitch?: (path: string) => Promise<void>
|
||||
flushBeforeFrontmatterChange?: (path: string) => Promise<void>
|
||||
flushBeforePathRename?: (path: string) => Promise<void>
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -128,6 +129,20 @@ async function flushBeforeTitleRename(
|
||||
}
|
||||
}
|
||||
|
||||
async function flushBeforeFrontmatterMutation(
|
||||
path: string,
|
||||
flushBeforeFrontmatterChange?: (path: string) => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (!flushBeforeFrontmatterChange) return true
|
||||
|
||||
try {
|
||||
await flushBeforeFrontmatterChange(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRenameAfterFrontmatterUpdate({
|
||||
path,
|
||||
key,
|
||||
@@ -167,6 +182,9 @@ async function updateFrontmatterAndMaybeRename({
|
||||
runFrontmatterOp,
|
||||
value,
|
||||
}: UpdateFrontmatterAndMaybeRenameParams): Promise<void> {
|
||||
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
|
||||
if (!canFlush) return
|
||||
|
||||
const canRename = await flushBeforeTitleRename(path, key, value, config.flushBeforePathRename)
|
||||
if (!canRename) return
|
||||
|
||||
@@ -239,12 +257,18 @@ function useFrontmatterActionHandlers({
|
||||
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
|
||||
|
||||
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
|
||||
if (!canFlush) return
|
||||
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
|
||||
if (!canFlush) return
|
||||
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
|
||||
@@ -9,6 +9,8 @@ interface NoteListKeyboardOptions {
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onPrefetch?: (entry: VaultEntry) => void
|
||||
searchVisible?: boolean
|
||||
toggleSearch?: () => void
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
@@ -55,6 +57,12 @@ function isListActive(container: HTMLDivElement | null): boolean {
|
||||
return activeElement instanceof Node && container.contains(activeElement)
|
||||
}
|
||||
|
||||
function isPanelActive(panel: HTMLDivElement | null): boolean {
|
||||
if (!panel) return false
|
||||
const activeElement = document.activeElement
|
||||
return activeElement instanceof Node && panel.contains(activeElement)
|
||||
}
|
||||
|
||||
function isEditableElement(element: Element | null): boolean {
|
||||
if (!element) return false
|
||||
if (
|
||||
@@ -119,6 +127,13 @@ function usesCommandModifier(event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey'>):
|
||||
return event.metaKey || event.ctrlKey
|
||||
}
|
||||
|
||||
function isToggleSearchShortcut(
|
||||
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
|
||||
): boolean {
|
||||
if (!usesCommandModifier(event) || event.altKey || event.shiftKey) return false
|
||||
return event.code === 'KeyF' || event.key.toLowerCase() === 'f'
|
||||
}
|
||||
|
||||
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
||||
return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey
|
||||
}
|
||||
@@ -334,6 +349,7 @@ function useProcessKeyDown({
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
onToggleSearchShortcut,
|
||||
}: {
|
||||
enabled: boolean
|
||||
items: VaultEntry[]
|
||||
@@ -342,34 +358,25 @@ function useProcessKeyDown({
|
||||
flushOpen: (entry?: VaultEntry) => void
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onToggleSearchShortcut?: () => void
|
||||
}) {
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
|
||||
if (!enabled || items.length === 0) return
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>) => {
|
||||
if (!enabled) return
|
||||
|
||||
if (isNeighborhoodKey(event)) {
|
||||
handleNeighborhoodActivation({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (usesCommandModifier(event) || event.altKey) return
|
||||
|
||||
if (handleArrowNavigation(event, moveHighlight)) return
|
||||
|
||||
if (event.key !== 'Enter') return
|
||||
|
||||
handleHighlightedOpen({
|
||||
if (handleSearchShortcutEvent(event, onToggleSearchShortcut)) return
|
||||
if (items.length === 0) return
|
||||
if (handleNeighborhoodShortcutEvent({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
})
|
||||
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood])
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})) return
|
||||
if (shouldIgnoreListKeyboardEvent(event)) return
|
||||
if (handleArrowNavigation(event, moveHighlight)) return
|
||||
|
||||
handleEnterShortcutEvent(event, items, highlightedPathRef, flushOpen)
|
||||
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood, onToggleSearchShortcut])
|
||||
}
|
||||
|
||||
function useFocusHandlers({
|
||||
@@ -402,44 +409,194 @@ function useFocusHandlers({
|
||||
return { focusList, handleBlur, handleFocus }
|
||||
}
|
||||
|
||||
function usePanelFocusState(panelRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const [isPanelActiveState, setIsPanelActiveState] = useState(false)
|
||||
|
||||
const syncPanelState = useCallback(() => {
|
||||
setIsPanelActiveState(isPanelActive(panelRef.current))
|
||||
}, [panelRef])
|
||||
|
||||
const handlePanelFocusCapture = useCallback(() => {
|
||||
setIsPanelActiveState(true)
|
||||
}, [])
|
||||
|
||||
const handlePanelBlurCapture = useCallback(() => {
|
||||
requestAnimationFrame(syncPanelState)
|
||||
}, [syncPanelState])
|
||||
|
||||
return {
|
||||
handlePanelBlurCapture,
|
||||
handlePanelFocusCapture,
|
||||
isPanelActive: isPanelActiveState,
|
||||
}
|
||||
}
|
||||
|
||||
function useGlobalKeyboardHandling({
|
||||
enabled,
|
||||
panelRef,
|
||||
containerRef,
|
||||
processKeyDown,
|
||||
}: {
|
||||
enabled: boolean
|
||||
panelRef: React.RefObject<HTMLDivElement | null>
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
processKeyDown: (event: KeyboardEvent) => void
|
||||
}) {
|
||||
const shouldSkipGlobalKeyDown = useCallback((activeElement: Element | null) => {
|
||||
if (isEditableElement(activeElement)) return true
|
||||
return Boolean(
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
)
|
||||
}, [containerRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
const activeElement = document.activeElement
|
||||
if (isEditableElement(activeElement)) return
|
||||
if (
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
) return
|
||||
processKeyDown(event)
|
||||
}
|
||||
const handleWindowKeyDown = createGlobalKeyDownHandler(panelRef, shouldSkipGlobalKeyDown, processKeyDown)
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [containerRef, enabled, processKeyDown])
|
||||
}, [enabled, panelRef, processKeyDown, shouldSkipGlobalKeyDown])
|
||||
}
|
||||
|
||||
function useSearchToggleShortcut({
|
||||
toggleSearch,
|
||||
searchVisible,
|
||||
focusList,
|
||||
}: {
|
||||
toggleSearch?: () => void
|
||||
searchVisible: boolean
|
||||
focusList: () => void
|
||||
}) {
|
||||
return useCallback(() => {
|
||||
if (!toggleSearch) return
|
||||
|
||||
toggleSearch()
|
||||
if (!searchVisible) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusList()
|
||||
})
|
||||
}, [focusList, searchVisible, toggleSearch])
|
||||
}
|
||||
|
||||
function useDirectKeyDownHandler(
|
||||
processKeyDown: (event: React.KeyboardEvent) => void,
|
||||
) {
|
||||
return useCallback((event: React.KeyboardEvent) => {
|
||||
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
||||
processKeyDown(event)
|
||||
}, [processKeyDown])
|
||||
}
|
||||
|
||||
function resolveStableHighlightedPath(items: VaultEntry[], highlightedPathState: string | null): string | null {
|
||||
return getItemIndex(items).entryByPath.has(highlightedPathState ?? '')
|
||||
? highlightedPathState
|
||||
: null
|
||||
}
|
||||
|
||||
function handleSearchShortcutEvent(
|
||||
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>,
|
||||
onToggleSearchShortcut?: () => void,
|
||||
): boolean {
|
||||
if (!isToggleSearchShortcut(event) || !onToggleSearchShortcut) return false
|
||||
event.preventDefault()
|
||||
onToggleSearchShortcut()
|
||||
return true
|
||||
}
|
||||
|
||||
function handleNeighborhoodShortcutEvent(options: {
|
||||
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>
|
||||
items: VaultEntry[]
|
||||
highlightedPathRef: React.RefObject<string | null>
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
}): boolean {
|
||||
const {
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
} = options
|
||||
|
||||
if (!isNeighborhoodKey(event)) return false
|
||||
handleNeighborhoodActivation({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldIgnoreListKeyboardEvent(
|
||||
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey'>,
|
||||
): boolean {
|
||||
return usesCommandModifier(event) || event.altKey
|
||||
}
|
||||
|
||||
function handleEnterShortcutEvent(
|
||||
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
|
||||
items: VaultEntry[],
|
||||
highlightedPathRef: React.RefObject<string | null>,
|
||||
flushOpen: (entry?: VaultEntry) => void,
|
||||
) {
|
||||
if (event.key !== 'Enter') return
|
||||
handleHighlightedOpen({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
})
|
||||
}
|
||||
|
||||
function createGlobalKeyDownHandler(
|
||||
panelRef: React.RefObject<HTMLDivElement | null>,
|
||||
shouldSkipGlobalKeyDown: (activeElement: Element | null) => boolean,
|
||||
processKeyDown: (event: KeyboardEvent) => void,
|
||||
) {
|
||||
return (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isToggleSearchShortcut(event) && isPanelActive(panelRef.current)) {
|
||||
processKeyDown(event)
|
||||
return
|
||||
}
|
||||
if (shouldSkipGlobalKeyDown(document.activeElement)) return
|
||||
processKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteListKeyboard({
|
||||
items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled,
|
||||
items,
|
||||
selectedNotePath,
|
||||
onOpen,
|
||||
onEnterNeighborhood,
|
||||
onPrefetch,
|
||||
searchVisible = false,
|
||||
toggleSearch,
|
||||
enabled,
|
||||
}: NoteListKeyboardOptions) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath)
|
||||
const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath()
|
||||
const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath)
|
||||
const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled)
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
const { handlePanelBlurCapture, handlePanelFocusCapture, isPanelActive: isPanelActiveState } = usePanelFocusState(panelRef)
|
||||
const handleToggleSearchShortcut = useSearchToggleShortcut({
|
||||
focusList,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
})
|
||||
const moveHighlight = useMoveHighlight({
|
||||
items,
|
||||
selectedNotePath,
|
||||
@@ -457,32 +614,28 @@ export function useNoteListKeyboard({
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
onToggleSearchShortcut: handleToggleSearchShortcut,
|
||||
})
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
||||
processKeyDown(event)
|
||||
}, [processKeyDown])
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown })
|
||||
const handleKeyDown = useDirectKeyDownHandler(processKeyDown)
|
||||
useGlobalKeyboardHandling({ enabled, panelRef, containerRef, processKeyDown })
|
||||
useEffect(() => {
|
||||
cancelOpen()
|
||||
}, [cancelOpen, selectedNotePath])
|
||||
|
||||
const highlightedPath = getItemIndex(items).entryByPath.has(highlightedPathState ?? '')
|
||||
? highlightedPathState
|
||||
: null
|
||||
const highlightedPath = resolveStableHighlightedPath(items, highlightedPathState)
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
focusList,
|
||||
handlePanelBlurCapture,
|
||||
handlePanelFocusCapture,
|
||||
highlightedPath,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
isPanelActive: isPanelActiveState,
|
||||
panelRef,
|
||||
toggleSearchShortcut: handleToggleSearchShortcut,
|
||||
virtuosoRef,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,21 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows welcome instead of vault-missing when no persisted active vault matches the missing path', async () => {
|
||||
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands({
|
||||
load_vault_list: {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = await renderOnboarding('/vault/deleted')
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
})
|
||||
|
||||
it('clears the persisted active vault when the saved path no longer exists', async () => {
|
||||
localStorage.setItem(LEGACY_APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands({
|
||||
|
||||
@@ -40,10 +40,10 @@ function markDismissed(): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
async function clearMissingActiveVault(missingPath: string): Promise<boolean> {
|
||||
try {
|
||||
const list = await tauriCall<PersistedVaultList>('load_vault_list', {})
|
||||
if (!list || list.active_vault !== missingPath) return
|
||||
if (!list || list.active_vault !== missingPath) return false
|
||||
await tauriCall('save_vault_list', {
|
||||
list: {
|
||||
vaults: list.vaults ?? [],
|
||||
@@ -51,8 +51,10 @@ async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
hidden_defaults: list.hidden_defaults ?? [],
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Best effort only — onboarding should still proceed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,17 +86,14 @@ export function useOnboarding(
|
||||
|
||||
if (exists) {
|
||||
setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
} else {
|
||||
await clearMissingActiveVault(initialVaultPath)
|
||||
if (cancelled) return
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
|
||||
if (wasDismissed()) {
|
||||
// User previously dismissed — show vault-missing instead of welcome
|
||||
const missingWasPersistedActiveVault = await clearMissingActiveVault(initialVaultPath)
|
||||
if (cancelled) return
|
||||
|
||||
if (wasDismissed() && missingWasPersistedActiveVault) {
|
||||
// Only show vault-missing when a previously selected vault path truly disappeared.
|
||||
setState({ status: 'vault-missing', vaultPath: initialVaultPath, defaultPath })
|
||||
} else {
|
||||
setState({ status: 'welcome', defaultPath })
|
||||
|
||||
191
src/hooks/usePropertyPanelState.extra.test.ts
Normal file
191
src/hooks/usePropertyPanelState.extra.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
|
||||
const propertyModeState = vi.hoisted(() => ({
|
||||
overrides: {} as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../components/DynamicPropertiesPanel', () => ({
|
||||
containsWikilinks: (value: unknown) => typeof value === 'string' && value.includes('[['),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/propertyTypes', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/propertyTypes')>('../utils/propertyTypes')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
loadDisplayModeOverrides: vi.fn(() => ({ ...propertyModeState.overrides })),
|
||||
saveDisplayModeOverride: vi.fn((key: string, mode: string) => {
|
||||
propertyModeState.overrides[key] = mode
|
||||
}),
|
||||
removeDisplayModeOverride: vi.fn((key: string) => {
|
||||
delete propertyModeState.overrides[key]
|
||||
}),
|
||||
getEffectiveDisplayMode: vi.fn((key: string, value: unknown, overrides: Record<string, string>) => (
|
||||
overrides[key] ?? actual.detectPropertyType(key, value as never)
|
||||
)),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
loadDisplayModeOverrides,
|
||||
removeDisplayModeOverride,
|
||||
saveDisplayModeOverride,
|
||||
} from '../utils/propertyTypes'
|
||||
import { usePropertyPanelState } from './usePropertyPanelState'
|
||||
|
||||
function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 1,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createFrontmatter(overrides: ParsedFrontmatter = {}): ParsedFrontmatter {
|
||||
return {
|
||||
Status: 'Active',
|
||||
Alias: 'shown',
|
||||
Icon: 'sparkle',
|
||||
_icon: 'duplicate-hidden',
|
||||
Tags: ['alpha', 'beta'],
|
||||
Related_to: '[[Linked note]]',
|
||||
Workspace: 'hidden',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePropertyPanelState extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
propertyModeState.overrides = {}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('derives type, status, tag, and visible property metadata from entries and frontmatter', () => {
|
||||
const { result } = renderHook(() => usePropertyPanelState({
|
||||
entries: [
|
||||
entry({ title: 'Project', isA: 'Type', color: 'sky', icon: 'rocket' }),
|
||||
entry({ title: 'Topic', isA: 'Type', color: 'mint', icon: 'hash' }),
|
||||
entry({ title: 'Roadmap', status: 'Paused', properties: { Tags: ['alpha', 'gamma'], People: ['Brian'] } }),
|
||||
],
|
||||
entryIsA: 'Project',
|
||||
frontmatter: createFrontmatter(),
|
||||
}))
|
||||
|
||||
expect(result.current.availableTypes).toEqual(['Project', 'Topic'])
|
||||
expect(result.current.customColorKey).toBe('sky')
|
||||
expect(result.current.typeColorKeys).toEqual({ Project: 'sky', Topic: 'mint' })
|
||||
expect(result.current.typeIconKeys).toEqual({ Project: 'rocket', Topic: 'hash' })
|
||||
expect(result.current.vaultStatuses).toEqual(['Paused'])
|
||||
expect(result.current.vaultTagsByKey).toEqual({
|
||||
People: ['Brian'],
|
||||
Tags: ['alpha', 'gamma'],
|
||||
})
|
||||
expect(result.current.propertyEntries).toEqual([
|
||||
['Status', 'Active'],
|
||||
['Alias', 'shown'],
|
||||
['Icon', 'sparkle'],
|
||||
['Tags', ['alpha', 'beta']],
|
||||
])
|
||||
})
|
||||
|
||||
it('saves scalar values using number-aware coercion and deletes empty numeric values', () => {
|
||||
propertyModeState.overrides = { Estimate: 'number' }
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: { Estimate: 2, Done: false },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setEditingKey('Estimate')
|
||||
result.current.handleSaveValue('Estimate', ' 42 ')
|
||||
result.current.handleSaveValue('Estimate', ' ')
|
||||
result.current.handleSaveValue('Done', 'true')
|
||||
})
|
||||
|
||||
expect(result.current.editingKey).toBeNull()
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Estimate', 42)
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Estimate')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Done', true)
|
||||
})
|
||||
|
||||
it('reconciles list values, persists added display modes, and supports clearing overrides', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const onUpdateProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: {},
|
||||
onAddProperty,
|
||||
onDeleteProperty,
|
||||
onUpdateProperty,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAddDialog(true)
|
||||
result.current.handleSaveList('Tags', [])
|
||||
result.current.handleSaveList('Tags', ['solo'])
|
||||
result.current.handleSaveList('Tags', ['alpha', 'beta'])
|
||||
result.current.handleAdd('Priority', ' 3 ', 'number')
|
||||
result.current.handleAdd('People', ' Luca, Brian ', 'tags')
|
||||
result.current.handleAdd('Enabled', 'TRUE', 'boolean')
|
||||
result.current.handleAdd(' ', 'ignored', 'text')
|
||||
result.current.handleDisplayModeChange('Priority', null)
|
||||
})
|
||||
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Tags')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', 'solo')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Priority', 3)
|
||||
expect(onAddProperty).toHaveBeenCalledWith('People', ['Luca', 'Brian'])
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Enabled', true)
|
||||
expect(onAddProperty).toHaveBeenCalledTimes(3)
|
||||
expect(saveDisplayModeOverride).toHaveBeenCalledWith('Priority', 'number')
|
||||
expect(saveDisplayModeOverride).toHaveBeenCalledWith('People', 'tags')
|
||||
expect(saveDisplayModeOverride).toHaveBeenCalledWith('Enabled', 'boolean')
|
||||
expect(loadDisplayModeOverrides).toHaveBeenCalled()
|
||||
expect(removeDisplayModeOverride).toHaveBeenCalledWith('Priority')
|
||||
expect(result.current.showAddDialog).toBe(false)
|
||||
})
|
||||
})
|
||||
202
src/hooks/usePropertyPanelState.test.ts
Normal file
202
src/hooks/usePropertyPanelState.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
vi.mock('../components/DynamicPropertiesPanel', () => ({
|
||||
containsWikilinks: (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => String(item).includes('[['))
|
||||
}
|
||||
return String(value).includes('[[')
|
||||
},
|
||||
}))
|
||||
|
||||
import { initDisplayModeOverrides } from '../utils/propertyTypes'
|
||||
import { usePropertyPanelState } from './usePropertyPanelState'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 1,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePropertyPanelState', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initDisplayModeOverrides({})
|
||||
})
|
||||
|
||||
it('derives visible properties, available types, statuses, and aggregated tags', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Topic', isA: 'Type', color: '#334455', icon: 'book' }),
|
||||
makeEntry({ title: 'Project', isA: 'Type', color: '#112233', icon: 'rocket' }),
|
||||
makeEntry({
|
||||
title: 'Alpha',
|
||||
status: 'Doing',
|
||||
properties: {
|
||||
Tags: ['alpha', 'beta'],
|
||||
Areas: ['ops'],
|
||||
},
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Beta',
|
||||
status: 'Review',
|
||||
properties: {
|
||||
Tags: ['beta', 'gamma'],
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const frontmatter = {
|
||||
_icon: 'sparkles',
|
||||
icon: 'duplicate',
|
||||
title: 'Hidden',
|
||||
Count: 3,
|
||||
Custom: 'value',
|
||||
'Related to': '[[Alpha]]',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries,
|
||||
entryIsA: 'Project',
|
||||
frontmatter,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.availableTypes).toEqual(['Project', 'Topic'])
|
||||
expect(result.current.customColorKey).toBe('#112233')
|
||||
expect(result.current.typeIconKeys.Project).toBe('rocket')
|
||||
expect(result.current.vaultStatuses).toEqual(['Doing', 'Review'])
|
||||
expect(result.current.vaultTagsByKey).toEqual({
|
||||
Areas: ['ops'],
|
||||
Tags: ['alpha', 'beta', 'gamma'],
|
||||
})
|
||||
expect(result.current.propertyEntries).toEqual([
|
||||
['_icon', 'sparkles'],
|
||||
['Count', 3],
|
||||
['Custom', 'value'],
|
||||
])
|
||||
})
|
||||
|
||||
it('saves scalar and list properties through the correct handlers', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: {
|
||||
Count: 7,
|
||||
Flag: false,
|
||||
Title: 'kept',
|
||||
},
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setEditingKey('Count')
|
||||
result.current.handleSaveValue('Count', ' ')
|
||||
})
|
||||
expect(result.current.editingKey).toBeNull()
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Count')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSaveValue('Flag', 'true')
|
||||
})
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Flag', true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSaveList('Tags', [])
|
||||
result.current.handleSaveList('Tags', ['solo'])
|
||||
result.current.handleSaveList('Tags', ['solo', 'duo'])
|
||||
})
|
||||
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Tags')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', 'solo')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', ['solo', 'duo'])
|
||||
})
|
||||
|
||||
it('adds properties, persists non-text display modes, and supports clearing overrides', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: {},
|
||||
onAddProperty,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAddDialog(true)
|
||||
result.current.handleAdd(' ', 'ignored', 'text')
|
||||
})
|
||||
expect(onAddProperty).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleAdd('Rating', '42', 'number')
|
||||
})
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Rating', 42)
|
||||
expect(result.current.displayOverrides.Rating).toBe('number')
|
||||
expect(result.current.showAddDialog).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.handleAdd('Labels', 'alpha, beta', 'tags')
|
||||
})
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Labels', ['alpha', 'beta'])
|
||||
expect(result.current.displayOverrides.Labels).toBe('tags')
|
||||
|
||||
act(() => {
|
||||
result.current.handleDisplayModeChange('Rating', null)
|
||||
})
|
||||
expect(result.current.displayOverrides.Rating).toBeUndefined()
|
||||
})
|
||||
})
|
||||
173
src/hooks/useStatusBarAddRemote.test.ts
Normal file
173
src/hooks/useStatusBarAddRemote.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitRemoteStatus } from '../types'
|
||||
import { REQUEST_ADD_REMOTE_EVENT } from '../utils/addRemoteEvents'
|
||||
import { useStatusBarAddRemote } from './useStatusBarAddRemote'
|
||||
|
||||
const invokeMock = vi.fn()
|
||||
const mockInvokeMock = vi.fn()
|
||||
const isTauriMock = vi.fn(() => false)
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriMock(),
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeMock(...args),
|
||||
}))
|
||||
|
||||
function remoteStatus(hasRemote: boolean): GitRemoteStatus {
|
||||
return {
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useStatusBarAddRemote', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriMock.mockReturnValue(false)
|
||||
mockInvokeMock.mockResolvedValue(remoteStatus(false))
|
||||
invokeMock.mockResolvedValue(remoteStatus(false))
|
||||
})
|
||||
|
||||
it('delegates to onAddRemote when provided', async () => {
|
||||
const onAddRemote = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remoteStatus(false),
|
||||
onAddRemote,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(onAddRemote).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvokeMock).not.toHaveBeenCalled()
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when the vault is not git-backed', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: false,
|
||||
remoteStatus: remoteStatus(false),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
expect(mockInvokeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens when the refreshed remote status has no remote and closes when it does', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ remote }) =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remote,
|
||||
}),
|
||||
{
|
||||
initialProps: { remote: remoteStatus(false) },
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(mockInvokeMock).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
|
||||
expect(result.current.showAddRemote).toBe(true)
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(false))
|
||||
|
||||
mockInvokeMock.mockResolvedValue(remoteStatus(true))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRemoteConnected('connected')
|
||||
})
|
||||
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
|
||||
await act(async () => {
|
||||
result.current.closeAddRemote()
|
||||
})
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
|
||||
rerender({ remote: remoteStatus(false) })
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
})
|
||||
|
||||
it('stays closed when the latest refresh already has a remote', async () => {
|
||||
mockInvokeMock.mockResolvedValue(remoteStatus(true))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remoteStatus(false),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
})
|
||||
|
||||
it('reacts to the global add-remote request event', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remoteStatus(false),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(REQUEST_ADD_REMOTE_EVENT))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showAddRemote).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the Tauri invoke path in native mode and tolerates refresh failures', async () => {
|
||||
isTauriMock.mockReturnValue(true)
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(remoteStatus(true))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: null,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
expect(result.current.showAddRemote).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRemoteConnected('connected')
|
||||
})
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
})
|
||||
})
|
||||
298
src/hooks/useVaultLoader.extra.test.ts
Normal file
298
src/hooks/useVaultLoader.extra.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useVaultLoader } from './useVaultLoader'
|
||||
import type { ModifiedFile, VaultEntry, ViewFile } from '../types'
|
||||
|
||||
const clearPrefetchCache = vi.fn()
|
||||
const backendInvokeFn = vi.fn()
|
||||
let mockIsTauri = false
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => backendInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => mockIsTauri,
|
||||
mockInvoke: (command: string, args?: Record<string, unknown>) => backendInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
vi.mock('./useTabManagement', () => ({
|
||||
clearPrefetchCache: () => clearPrefetchCache(),
|
||||
}))
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/hello.md',
|
||||
filename: 'hello.md',
|
||||
title: 'Hello',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 100,
|
||||
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 makeModifiedFile(overrides: Partial<ModifiedFile> = {}): ModifiedFile {
|
||||
return {
|
||||
path: '/vault/note/hello.md',
|
||||
relativePath: 'note/hello.md',
|
||||
status: 'modified',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeView(name: string): ViewFile {
|
||||
return {
|
||||
path: `/vault/.views/${name.toLowerCase()}.yml`,
|
||||
filename: `${name.toLowerCase()}.yml`,
|
||||
definition: {
|
||||
name,
|
||||
icon: 'Folder',
|
||||
filters: [],
|
||||
sort: null,
|
||||
listPropertiesDisplay: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function configureBackend(overrides: Partial<Record<string, unknown | Error>> = {}) {
|
||||
const defaults: Record<string, unknown> = {
|
||||
list_vault: [makeEntry()],
|
||||
reload_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
git_commit: 'committed',
|
||||
git_push: { status: 'ok', message: 'pushed' },
|
||||
}
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
const value = command in overrides ? overrides[command] : defaults[command]
|
||||
if (value instanceof Error) return Promise.reject(value)
|
||||
return Promise.resolve(value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForEntries(
|
||||
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, string>>['result'],
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useVaultLoader extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsTauri = false
|
||||
configureBackend()
|
||||
})
|
||||
|
||||
it('uses native commit and push commands when Tauri mode is active', async () => {
|
||||
mockIsTauri = true
|
||||
configureBackend({
|
||||
reload_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
let response = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('save current note')
|
||||
})
|
||||
|
||||
expect(response.status).toBe('ok')
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', {
|
||||
vaultPath: '/vault',
|
||||
message: 'save current note',
|
||||
})
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_push', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
it('tracks pending saves and replaces entries in place', async () => {
|
||||
const initialEntry = makeEntry()
|
||||
configureBackend({
|
||||
list_vault: [initialEntry],
|
||||
get_modified_files: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
act(() => {
|
||||
result.current.addPendingSave(initialEntry.path)
|
||||
})
|
||||
expect(result.current.getNoteStatus(initialEntry.path)).toBe('pendingSave')
|
||||
|
||||
act(() => {
|
||||
result.current.removePendingSave(initialEntry.path)
|
||||
result.current.replaceEntry(initialEntry.path, {
|
||||
path: '/vault/note/renamed.md',
|
||||
title: 'Renamed',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus(initialEntry.path)).toBe('clean')
|
||||
expect(result.current.entries[0]?.path).toBe('/vault/note/renamed.md')
|
||||
expect(result.current.entries[0]?.title).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('surfaces modified-file refresh failures with an empty fallback list', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
get_modified_files: new Error('backend offline'),
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFilesError).toBe('Failed to load changes')
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads the vault, refreshes modified files, and clears the prefetch cache', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
reload_vault: [makeEntry({ path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' })],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'reload_vault') {
|
||||
return Promise.resolve([
|
||||
makeEntry({ path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' }),
|
||||
])
|
||||
}
|
||||
if (command === 'get_modified_files') {
|
||||
return Promise.resolve([
|
||||
makeModifiedFile({ path: '/vault/note/fresh.md', relativePath: 'note/fresh.md' }),
|
||||
])
|
||||
}
|
||||
if (command === 'list_vault_folders' || command === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve([makeEntry()])
|
||||
})
|
||||
|
||||
let reloaded: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
reloaded = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(clearPrefetchCache).toHaveBeenCalledOnce()
|
||||
expect(reloaded.map((entry) => entry.title)).toEqual(['Fresh'])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries[0]?.title).toBe('Fresh')
|
||||
expect(result.current.modifiedFiles[0]?.path).toBe('/vault/note/fresh.md')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty list when vault reload fails', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
reload_vault: new Error('reload failed'),
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
let reloaded: VaultEntry[] = [makeEntry({ title: 'sentinel' })]
|
||||
await act(async () => {
|
||||
reloaded = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(reloaded).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads views when the backend succeeds', async () => {
|
||||
const views = [makeView('Projects')]
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
list_views: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'list_views') return Promise.resolve(views)
|
||||
if (command === 'get_modified_files' || command === 'list_vault_folders') return Promise.resolve([])
|
||||
return Promise.resolve([makeEntry()])
|
||||
})
|
||||
|
||||
let reloaded: ViewFile[] = []
|
||||
await act(async () => {
|
||||
reloaded = await result.current.reloadViews()
|
||||
})
|
||||
|
||||
expect(reloaded.map((view) => view.definition.name)).toEqual(['Projects'])
|
||||
expect(result.current.views[0]?.definition.name).toBe('Projects')
|
||||
})
|
||||
|
||||
it('returns empty arrays when folder or view reloads fail', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
list_vault_folders: [{ name: 'projects', path: 'projects', children: [] }],
|
||||
list_views: [makeView('Inbox')],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'list_vault_folders' || command === 'list_views') {
|
||||
return Promise.reject(new Error('unavailable'))
|
||||
}
|
||||
if (command === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve([makeEntry()])
|
||||
})
|
||||
|
||||
let folders: unknown[] = []
|
||||
let views: ViewFile[] = []
|
||||
await act(async () => {
|
||||
folders = await result.current.reloadFolders()
|
||||
views = await result.current.reloadViews()
|
||||
})
|
||||
|
||||
expect(folders).toEqual([])
|
||||
expect(views).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -26,39 +26,124 @@ const mockGitHistory: GitCommit[] = [
|
||||
{ hash: 'abc1234567', shortHash: 'abc1234', message: 'initial commit', author: 'luca', date: 1700000000 },
|
||||
]
|
||||
|
||||
function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'get_file_history') return Promise.resolve(mockGitHistory)
|
||||
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
|
||||
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
return Promise.resolve(null)
|
||||
type MockCommandHandler = (args?: Record<string, unknown>) => unknown
|
||||
|
||||
const defaultMockHandlers: Record<string, MockCommandHandler> = {
|
||||
list_vault: () => mockEntries,
|
||||
reload_vault: () => mockEntries,
|
||||
get_all_content: () => mockContent,
|
||||
get_modified_files: () => mockModifiedFiles,
|
||||
get_file_history: () => mockGitHistory,
|
||||
get_file_diff: () => '--- a/note.md\n+++ b/note.md',
|
||||
get_file_diff_at_commit: (args) => `diff for ${(args as Record<string, string>)?.commitHash}`,
|
||||
git_commit: () => 'committed',
|
||||
git_push: () => ({ status: 'ok', message: 'Pushed to remote' }),
|
||||
}
|
||||
|
||||
const mockInvokeFn = vi.fn(defaultMockInvoke)
|
||||
function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
const handler = defaultMockHandlers[cmd]
|
||||
return Promise.resolve(handler ? handler(args) : null)
|
||||
}
|
||||
|
||||
let mockIsTauri = false
|
||||
const backendInvokeFn = vi.fn(defaultMockInvoke)
|
||||
|
||||
function isVaultLoadCommand(cmd: string) {
|
||||
return cmd === 'list_vault' || cmd === 'reload_vault'
|
||||
}
|
||||
|
||||
function buildVaultLoaderMock(options: {
|
||||
entries?: VaultEntry[]
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
pushResult?: { status: string; message: string }
|
||||
failHistory?: boolean
|
||||
} = {}) {
|
||||
const {
|
||||
entries = mockEntries,
|
||||
modifiedFiles = mockModifiedFiles,
|
||||
pushResult,
|
||||
failHistory = false,
|
||||
} = options
|
||||
|
||||
return ((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(entries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(modifiedFiles)
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
if (cmd === 'get_file_history' && failHistory) return Promise.reject(new Error('fail'))
|
||||
if (cmd === 'git_push' && pushResult) return Promise.resolve(pushResult)
|
||||
return defaultMockInvoke(cmd, args)
|
||||
}) as typeof defaultMockInvoke
|
||||
}
|
||||
|
||||
function buildReloadVaultPathMock(loads: Record<string, Promise<VaultEntry[]>>) {
|
||||
return ((cmd: string, args?: Record<string, unknown>) => {
|
||||
const path = typeof args?.path === 'string' ? args.path : undefined
|
||||
if (cmd === 'reload_vault' && path) return loads[path] ?? Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
|
||||
isTauri: () => mockIsTauri,
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => backendInvokeFn(cmd, args),
|
||||
}))
|
||||
|
||||
async function waitForEntries(
|
||||
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, undefined>>['result'],
|
||||
length = 1,
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(length)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForModifiedFiles(
|
||||
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, undefined>>['result'],
|
||||
length = 1,
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(length)
|
||||
})
|
||||
}
|
||||
|
||||
/** Render the vault loader hook and wait for initial data to load. */
|
||||
async function renderVaultLoader() {
|
||||
const hook = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(hook.result.current.entries).toHaveLength(1) })
|
||||
await waitForEntries(hook.result)
|
||||
return hook
|
||||
}
|
||||
|
||||
async function enableTauriMode() {
|
||||
mockIsTauri = true
|
||||
const tauri = await import('@tauri-apps/api/core')
|
||||
vi.mocked(tauri.invoke).mockImplementation((command: string, args?: Record<string, unknown>) =>
|
||||
backendInvokeFn(command, args),
|
||||
)
|
||||
}
|
||||
|
||||
describe('useVaultLoader', () => {
|
||||
beforeEach(() => {
|
||||
mockInvokeFn.mockImplementation(defaultMockInvoke)
|
||||
mockIsTauri = false
|
||||
backendInvokeFn.mockReset()
|
||||
backendInvokeFn.mockImplementation(defaultMockInvoke)
|
||||
})
|
||||
|
||||
it('loads entries on mount', async () => {
|
||||
@@ -70,13 +155,97 @@ describe('useVaultLoader', () => {
|
||||
it('loads modified files on mount', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
await waitForModifiedFiles(result)
|
||||
|
||||
expect(result.current.modifiedFiles[0].status).toBe('modified')
|
||||
})
|
||||
|
||||
it('does nothing until a real vault path exists', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader(''))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toEqual([])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
expect(result.current.modifiedFilesError).toBeNull()
|
||||
})
|
||||
|
||||
expect(backendInvokeFn).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('loads initial vault entries from a fresh reload in Tauri mode', async () => {
|
||||
await enableTauriMode()
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') {
|
||||
return Promise.resolve([
|
||||
{ ...mockEntries[0], path: '/vault/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' },
|
||||
])
|
||||
}
|
||||
if (cmd === 'reload_vault') {
|
||||
return Promise.resolve([
|
||||
{ ...mockEntries[0], path: '/vault/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' },
|
||||
{ ...mockEntries[0], path: '/vault/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
])
|
||||
}
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11'])
|
||||
})
|
||||
const issuedCommands = backendInvokeFn.mock.calls.map(([command]) => command)
|
||||
expect(issuedCommands).toContain('reload_vault')
|
||||
expect(issuedCommands).not.toContain('list_vault')
|
||||
})
|
||||
|
||||
it('ignores stale reload_vault results after the vault path changes', async () => {
|
||||
await enableTauriMode()
|
||||
const firstLoad = createDeferred<VaultEntry[]>()
|
||||
const secondLoad = createDeferred<VaultEntry[]>()
|
||||
|
||||
backendInvokeFn.mockImplementation(buildReloadVaultPathMock({
|
||||
'/vault-a': firstLoad.promise,
|
||||
'/vault-b': secondLoad.promise,
|
||||
}))
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ path }) => useVaultLoader(path),
|
||||
{ initialProps: { path: '/vault-a' } },
|
||||
)
|
||||
|
||||
rerender({ path: '/vault-b' })
|
||||
|
||||
await act(async () => {
|
||||
firstLoad.resolve([
|
||||
{ ...mockEntries[0], path: '/vault-a/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' },
|
||||
])
|
||||
await firstLoad.promise
|
||||
})
|
||||
|
||||
expect(result.current.entries).toEqual([])
|
||||
|
||||
await act(async () => {
|
||||
secondLoad.resolve([
|
||||
{ ...mockEntries[0], path: '/vault-b/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' },
|
||||
{ ...mockEntries[0], path: '/vault-b/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
])
|
||||
await secondLoad.promise
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addEntry', () => {
|
||||
it('prepends new entry', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
@@ -174,54 +343,44 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns new for git-untracked files (saved but not committed)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/brand-new.md', relativePath: 'note/brand-new.md', status: 'untracked' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
it.each([
|
||||
{
|
||||
name: 'returns new for git-untracked files (saved but not committed)',
|
||||
path: '/vault/note/brand-new.md',
|
||||
relativePath: 'note/brand-new.md',
|
||||
status: 'untracked',
|
||||
},
|
||||
{
|
||||
name: 'returns new for git-added files (staged but not committed)',
|
||||
path: '/vault/note/staged.md',
|
||||
relativePath: 'note/staged.md',
|
||||
status: 'added',
|
||||
},
|
||||
{
|
||||
name: 'treats untracked files as new (green dot, not orange)',
|
||||
path: '/vault/note/hello.md',
|
||||
relativePath: 'note/hello.md',
|
||||
status: 'untracked',
|
||||
},
|
||||
])('$name', async ({ path, relativePath, status }) => {
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
modifiedFiles: [{ path, relativePath, status }],
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
await waitForModifiedFiles(result)
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns new for git-added files (staged but not committed)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/staged.md', relativePath: 'note/staged.md', status: 'added' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/staged.md')).toBe('new')
|
||||
expect(result.current.getNoteStatus(path)).toBe('new')
|
||||
})
|
||||
|
||||
it('new status takes priority over git modified', async () => {
|
||||
// If a path is both new and in modifiedFiles, it should show as new
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
modifiedFiles: [
|
||||
{ path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
],
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
@@ -284,33 +443,24 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('treats untracked files as new (green dot, not orange)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
it('tracks and clears pendingSave states separately from unsaved/new markers', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
act(() => {
|
||||
result.current.addPendingSave('/vault/note/hello.md')
|
||||
})
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('pendingSave')
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('new')
|
||||
act(() => {
|
||||
result.current.removePendingSave('/vault/note/hello.md')
|
||||
})
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadGitHistory', () => {
|
||||
it('returns git commits for a file', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let history: GitCommit[] = []
|
||||
await act(async () => {
|
||||
@@ -322,13 +472,11 @@ describe('useVaultLoader', () => {
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'get_file_history') return Promise.reject(new Error('fail'))
|
||||
if (cmd === 'list_vault') return Promise.resolve([])
|
||||
if (cmd === 'get_all_content') return Promise.resolve({})
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
entries: [],
|
||||
modifiedFiles: [],
|
||||
failHistory: true,
|
||||
}))
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
@@ -345,11 +493,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
describe('loadDiff', () => {
|
||||
it('returns diff string for a file', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let diff = ''
|
||||
await act(async () => {
|
||||
@@ -362,11 +506,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
describe('loadDiffAtCommit', () => {
|
||||
it('returns diff for a specific commit', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let diff = ''
|
||||
await act(async () => {
|
||||
@@ -379,11 +519,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
describe('commitAndPush', () => {
|
||||
it('commits and pushes in mock mode', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
@@ -393,56 +529,63 @@ describe('useVaultLoader', () => {
|
||||
expect(response.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns rejected status when push is rejected', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
it('commits and pushes through the Tauri invoke path', async () => {
|
||||
await enableTauriMode()
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
await waitForEntries(result)
|
||||
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
response = await result.current.commitAndPush('tauri commit')
|
||||
})
|
||||
|
||||
expect(response.status).toBe('rejected')
|
||||
expect(response.message).toContain('Pull first')
|
||||
expect(response.status).toBe('ok')
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', {
|
||||
vaultPath: '/vault',
|
||||
message: 'tauri commit',
|
||||
})
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_push', {
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns network error status on network failure', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
it.each([
|
||||
{
|
||||
name: 'returns rejected status when push is rejected',
|
||||
pushResult: { status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' },
|
||||
expectedStatus: 'rejected',
|
||||
expectedMessage: 'Pull first',
|
||||
},
|
||||
{
|
||||
name: 'returns network error status on network failure',
|
||||
pushResult: { status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' },
|
||||
expectedStatus: 'network_error',
|
||||
expectedMessage: 'network error',
|
||||
},
|
||||
])('$name', async ({ pushResult, expectedStatus, expectedMessage }) => {
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
modifiedFiles: [],
|
||||
pushResult,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response.status).toBe('network_error')
|
||||
expect(response.message).toContain('network error')
|
||||
expect(response.status).toBe(expectedStatus)
|
||||
expect(response.message).toContain(expectedMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadFolders', () => {
|
||||
it('refreshes folder tree from backend', async () => {
|
||||
const folders = [{ name: 'projects', path: 'projects', children: [] }]
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve(folders)
|
||||
return Promise.resolve(null)
|
||||
@@ -453,7 +596,7 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.folders).toEqual(folders)
|
||||
|
||||
const updatedFolders = [...folders, { name: 'journal', path: 'journal', children: [] }]
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve(updatedFolders)
|
||||
return defaultMockInvoke(cmd)
|
||||
}) as typeof defaultMockInvoke)
|
||||
@@ -462,15 +605,32 @@ describe('useVaultLoader', () => {
|
||||
|
||||
expect(result.current.folders).toEqual(updatedFolders)
|
||||
})
|
||||
|
||||
it('returns an empty folder list when the refresh fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.reject(new Error('no folders'))
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
let folders: Array<{ name: string; path: string; children: [] }> = []
|
||||
await act(async () => {
|
||||
folders = await result.current.reloadFolders()
|
||||
})
|
||||
|
||||
expect(folders).toEqual([])
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModifiedFiles', () => {
|
||||
it('refreshes modified files list', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadModifiedFiles()
|
||||
@@ -478,6 +638,158 @@ describe('useVaultLoader', () => {
|
||||
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('captures backend errors when modified files cannot be loaded', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.reject('git unavailable')
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
expect(result.current.modifiedFilesError).toBe('git unavailable')
|
||||
})
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to load modified files:', 'git unavailable')
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceEntry', () => {
|
||||
it('replaces an entry path and metadata in place', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
act(() => {
|
||||
result.current.replaceEntry('/vault/note/hello.md', {
|
||||
path: '/vault/note/renamed.md',
|
||||
filename: 'renamed.md',
|
||||
title: 'Renamed',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.entries[0]).toEqual(expect.objectContaining({
|
||||
path: '/vault/note/renamed.md',
|
||||
filename: 'renamed.md',
|
||||
title: 'Renamed',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadVault', () => {
|
||||
it('refreshes entries from reload_vault and reloads modified files', async () => {
|
||||
const reloadedEntry = {
|
||||
...mockEntries[0],
|
||||
path: '/vault/note/reloaded.md',
|
||||
filename: 'reloaded.md',
|
||||
title: 'Reloaded',
|
||||
}
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'reload_vault') return Promise.resolve([reloadedEntry])
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let entries: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
entries = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(entries.map((entry) => entry.title)).toEqual(['Reloaded'])
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Reloaded'])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty list when reloading the vault fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'reload_vault') return Promise.reject(new Error('reload failed'))
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let entries: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
entries = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(entries).toEqual([])
|
||||
expect(result.current.entries).toEqual(mockEntries)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadViews', () => {
|
||||
it('refreshes views and falls back to an empty array when they are unavailable', async () => {
|
||||
const initialViews = [{
|
||||
filename: 'work.view',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}]
|
||||
const updatedViews = [{
|
||||
filename: 'projects.view',
|
||||
definition: {
|
||||
name: 'Projects',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}]
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve(initialViews)
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
expect(result.current.views).toEqual(initialViews)
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_views') return Promise.resolve(updatedViews)
|
||||
return defaultMockInvoke(cmd)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
await act(async () => {
|
||||
const views = await result.current.reloadViews()
|
||||
expect(views).toEqual(updatedViews)
|
||||
})
|
||||
expect(result.current.views).toEqual(updatedViews)
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_views') return Promise.reject(new Error('views unavailable'))
|
||||
return defaultMockInvoke(cmd)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
await act(async () => {
|
||||
const views = await result.current.reloadViews()
|
||||
expect(views).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -508,6 +820,10 @@ describe('resolveNoteStatus', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified')
|
||||
})
|
||||
|
||||
it('returns clean for unsupported git statuses', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'renamed')])).toBe('clean')
|
||||
})
|
||||
|
||||
it('newPaths takes priority over git modified', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
|
||||
@@ -8,13 +8,58 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
}
|
||||
|
||||
function hasVaultPath(vaultPath: string): boolean {
|
||||
return vaultPath.trim().length > 0
|
||||
}
|
||||
|
||||
function loadVaultEntries(vaultPath: string): Promise<VaultEntry[]> {
|
||||
const command = isTauri() ? 'reload_vault' : 'list_vault'
|
||||
return tauriCall<VaultEntry[]>(command, { path: vaultPath })
|
||||
}
|
||||
|
||||
async function loadVaultData(vaultPath: string) {
|
||||
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
|
||||
const entries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
const entries = await loadVaultEntries(vaultPath)
|
||||
console.log(`Vault scan complete: ${entries.length} entries found`)
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function loadVaultFolders(vaultPath: string): Promise<FolderNode[]> {
|
||||
return tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
}
|
||||
|
||||
function loadVaultViews(vaultPath: string): Promise<ViewFile[]> {
|
||||
return tauriCall<ViewFile[]>('list_views', { vaultPath })
|
||||
}
|
||||
|
||||
function resetVaultState(options: {
|
||||
clearNewPaths: () => void
|
||||
clearUnsaved: () => void
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
options.setEntries([])
|
||||
options.setFolders([])
|
||||
options.setViews([])
|
||||
options.setModifiedFiles([])
|
||||
options.setModifiedFilesError(null)
|
||||
options.clearNewPaths()
|
||||
options.clearUnsaved()
|
||||
}
|
||||
|
||||
function useCurrentVaultPathGuard(vaultPath: string) {
|
||||
const currentPathRef = useRef(vaultPath)
|
||||
|
||||
useEffect(() => {
|
||||
currentPathRef.current = vaultPath
|
||||
}, [vaultPath])
|
||||
|
||||
return useCallback((path: string) => currentPathRef.current === path, [])
|
||||
}
|
||||
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { message })
|
||||
@@ -96,32 +141,65 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const tracker = useNewNoteTracker()
|
||||
const pendingSave = usePendingSaveTracker()
|
||||
const unsaved = useUnsavedTracker()
|
||||
const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setFolders([]); setViews([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e }) => { setEntries(e) })
|
||||
const path = vaultPath
|
||||
resetVaultState({
|
||||
clearNewPaths: tracker.clear,
|
||||
clearUnsaved: unsaved.clearAll,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
})
|
||||
|
||||
if (!hasVaultPath(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
loadVaultData(path)
|
||||
.then(({ entries: e }) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setEntries(e)
|
||||
})
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
.then((f) => { setFolders(f ?? []) })
|
||||
loadVaultFolders(path)
|
||||
.then((f) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setFolders(f ?? [])
|
||||
})
|
||||
.catch(() => { /* folders are optional — ignore errors */ })
|
||||
tauriCall<ViewFile[]>('list_views', { vaultPath })
|
||||
.then((v) => { setViews(v ?? []) })
|
||||
loadVaultViews(path)
|
||||
.then((v) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setViews(v ?? [])
|
||||
})
|
||||
.catch(() => { /* views are optional — ignore errors */ })
|
||||
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
|
||||
}, [vaultPath, tracker.clear, unsaved.clearAll, isCurrentVaultPath])
|
||||
|
||||
const loadModifiedFiles = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
setModifiedFilesError(null)
|
||||
|
||||
if (!hasVaultPath(path)) {
|
||||
setModifiedFiles([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setModifiedFilesError(null)
|
||||
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
|
||||
const files = await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath: path }, {})
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setModifiedFiles(files)
|
||||
} catch (err) {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
const message = typeof err === 'string' ? err : 'Failed to load changes'
|
||||
console.warn('Failed to load modified files:', err)
|
||||
setModifiedFilesError(message)
|
||||
setModifiedFiles([])
|
||||
}
|
||||
}, [vaultPath])
|
||||
}, [vaultPath, isCurrentVaultPath])
|
||||
|
||||
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
|
||||
|
||||
@@ -176,27 +254,47 @@ export function useVaultLoader(vaultPath: string) {
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
|
||||
const reloadFolders = useCallback(
|
||||
() => tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
.then((f) => { setFolders(f ?? []) })
|
||||
.catch(() => { /* folders are optional — ignore errors */ }),
|
||||
[vaultPath],
|
||||
() => {
|
||||
const path = vaultPath
|
||||
return loadVaultFolders(path)
|
||||
.then((f) => {
|
||||
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
|
||||
const nextFolders = f ?? []
|
||||
setFolders(nextFolders)
|
||||
return nextFolders
|
||||
})
|
||||
.catch(() => [] as FolderNode[])
|
||||
},
|
||||
[vaultPath, isCurrentVaultPath],
|
||||
)
|
||||
|
||||
const reloadVault = useCallback(
|
||||
() => {
|
||||
const path = vaultPath
|
||||
clearPrefetchCache()
|
||||
return tauriCall<VaultEntry[]>('reload_vault', { path: vaultPath })
|
||||
.then((entries) => { setEntries(entries); loadModifiedFiles(); return entries })
|
||||
return tauriCall<VaultEntry[]>('reload_vault', { path })
|
||||
.then((entries) => {
|
||||
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
|
||||
setEntries(entries)
|
||||
void loadModifiedFiles()
|
||||
return entries
|
||||
})
|
||||
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] })
|
||||
},
|
||||
[vaultPath, loadModifiedFiles],
|
||||
[vaultPath, loadModifiedFiles, isCurrentVaultPath],
|
||||
)
|
||||
|
||||
const reloadViews = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
try {
|
||||
setViews(await tauriCall<ViewFile[]>('list_views', { vaultPath }) ?? [])
|
||||
const nextViews = await loadVaultViews(path)
|
||||
if (!isCurrentVaultPath(path)) return []
|
||||
const resolvedViews = nextViews ?? []
|
||||
setViews(resolvedViews)
|
||||
return resolvedViews
|
||||
} catch { /* views are optional */ }
|
||||
}, [vaultPath])
|
||||
return []
|
||||
}, [vaultPath, isCurrentVaultPath])
|
||||
|
||||
return {
|
||||
entries, folders, views, modifiedFiles, modifiedFilesError,
|
||||
|
||||
243
src/lib/aiAgentConversation.test.ts
Normal file
243
src/lib/aiAgentConversation.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
buildAgentSystemPromptMock,
|
||||
formatMessageWithHistoryMock,
|
||||
nextMessageIdMock,
|
||||
trimHistoryMock,
|
||||
} = vi.hoisted(() => ({
|
||||
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
|
||||
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
|
||||
nextMessageIdMock: vi.fn(),
|
||||
trimHistoryMock: vi.fn((history: unknown) => history),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-agent', () => ({
|
||||
buildAgentSystemPrompt: buildAgentSystemPromptMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
MAX_HISTORY_TOKENS: 100_000,
|
||||
formatMessageWithHistory: formatMessageWithHistoryMock,
|
||||
nextMessageId: nextMessageIdMock,
|
||||
trimHistory: trimHistoryMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
appendLocalResponse,
|
||||
appendStreamingMessage,
|
||||
buildFormattedMessage,
|
||||
createMissingAgentResponse,
|
||||
type AiAgentMessage,
|
||||
} from './aiAgentConversation'
|
||||
import {
|
||||
markReasoningDone,
|
||||
updateMessage,
|
||||
updateToolAction,
|
||||
} from './aiAgentMessageState'
|
||||
|
||||
function createMessageStore(initial: AiAgentMessage[] = []) {
|
||||
let messages = initial
|
||||
|
||||
return {
|
||||
getMessages: () => messages,
|
||||
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
|
||||
messages = typeof next === 'function' ? next(messages) : next
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('aiAgentConversation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
|
||||
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
|
||||
trimHistoryMock.mockImplementation((history: unknown) => history)
|
||||
})
|
||||
|
||||
it('creates a missing-agent response using the agent label', () => {
|
||||
expect(createMissingAgentResponse('codex')).toContain('Codex is not available on this machine')
|
||||
})
|
||||
|
||||
it('appends local responses with the normalized message shape', () => {
|
||||
nextMessageIdMock.mockReturnValue('msg-local')
|
||||
const store = createMessageStore()
|
||||
|
||||
appendLocalResponse(
|
||||
store.setMessages,
|
||||
{ text: 'Explain this', references: [{ path: '/vault/note.md', title: 'Note' }] },
|
||||
'Sure',
|
||||
)
|
||||
|
||||
expect(store.getMessages()).toEqual([
|
||||
{
|
||||
userMessage: 'Explain this',
|
||||
references: [{ path: '/vault/note.md', title: 'Note' }],
|
||||
actions: [],
|
||||
response: 'Sure',
|
||||
id: 'msg-local',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('appends streaming messages and returns the generated message id', () => {
|
||||
nextMessageIdMock.mockReturnValue('msg-stream')
|
||||
const store = createMessageStore()
|
||||
|
||||
const messageId = appendStreamingMessage(store.setMessages, { text: 'Draft reply' })
|
||||
|
||||
expect(messageId).toBe('msg-stream')
|
||||
expect(store.getMessages()).toEqual([
|
||||
{
|
||||
userMessage: 'Draft reply',
|
||||
references: undefined,
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
id: 'msg-stream',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('builds a formatted message from completed history only', () => {
|
||||
const messages: AiAgentMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'First question',
|
||||
actions: [],
|
||||
response: 'First answer',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
userMessage: 'Still streaming',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildFormattedMessage(
|
||||
{ agent: 'codex', ready: true, vaultPath: '/vault' },
|
||||
messages,
|
||||
{ text: 'Latest question' },
|
||||
)
|
||||
|
||||
expect(buildAgentSystemPromptMock).toHaveBeenCalledTimes(1)
|
||||
expect(trimHistoryMock).toHaveBeenCalledWith([
|
||||
{ role: 'user', content: 'First question', id: 'msg-1' },
|
||||
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
|
||||
], 100_000)
|
||||
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
|
||||
{ role: 'user', content: 'First question', id: 'msg-1' },
|
||||
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
|
||||
], 'Latest question')
|
||||
expect(result).toEqual({
|
||||
formattedMessage: 'formatted:Latest question',
|
||||
systemPrompt: 'SYSTEM',
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers a system prompt override when provided', () => {
|
||||
const result = buildFormattedMessage(
|
||||
{ agent: 'codex', ready: true, vaultPath: '/vault', systemPromptOverride: 'OVERRIDE' },
|
||||
[],
|
||||
{ text: 'Prompt' },
|
||||
)
|
||||
|
||||
expect(buildAgentSystemPromptMock).not.toHaveBeenCalled()
|
||||
expect(result.systemPrompt).toBe('OVERRIDE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiAgentMessageState', () => {
|
||||
it('updates only the targeted message', () => {
|
||||
const store = createMessageStore([
|
||||
{ id: 'keep', userMessage: 'Keep', actions: [] },
|
||||
{ id: 'edit', userMessage: 'Edit', actions: [] },
|
||||
])
|
||||
|
||||
updateMessage(store.setMessages, 'edit', (message) => ({
|
||||
...message,
|
||||
response: 'Updated',
|
||||
}))
|
||||
|
||||
expect(store.getMessages()).toEqual([
|
||||
{ id: 'keep', userMessage: 'Keep', actions: [] },
|
||||
{ id: 'edit', userMessage: 'Edit', actions: [], response: 'Updated' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks reasoning as done only once', () => {
|
||||
const store = createMessageStore([
|
||||
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
|
||||
{ id: 'pending', userMessage: 'Another', actions: [] },
|
||||
])
|
||||
|
||||
markReasoningDone(store.setMessages, 'done')
|
||||
markReasoningDone(store.setMessages, 'pending')
|
||||
|
||||
expect(store.getMessages()).toEqual([
|
||||
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
|
||||
{ id: 'pending', userMessage: 'Another', actions: [], reasoningDone: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('adds new tool actions with the expected labels', () => {
|
||||
const baseMessage: AiAgentMessage = {
|
||||
id: 'msg',
|
||||
userMessage: 'Question',
|
||||
actions: [],
|
||||
}
|
||||
|
||||
expect(updateToolAction(baseMessage, 'Bash', 'tool-1', 'ls')).toMatchObject({
|
||||
actions: [{
|
||||
tool: 'Bash',
|
||||
toolId: 'tool-1',
|
||||
label: 'Ran shell command',
|
||||
status: 'pending',
|
||||
input: 'ls',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(updateToolAction(baseMessage, 'Write', 'tool-2', '{"path":"/tmp/a.md"}')).toMatchObject({
|
||||
actions: [{
|
||||
tool: 'Write',
|
||||
toolId: 'tool-2',
|
||||
label: 'Wrote file',
|
||||
status: 'pending',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(updateToolAction(baseMessage, 'Edit', 'tool-3', '{"path":"/tmp/a.md"}')).toMatchObject({
|
||||
actions: [{
|
||||
tool: 'Edit',
|
||||
toolId: 'tool-3',
|
||||
label: 'Edited file',
|
||||
status: 'pending',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing tool action without dropping the prior input', () => {
|
||||
const message: AiAgentMessage = {
|
||||
id: 'msg',
|
||||
userMessage: 'Question',
|
||||
actions: [{
|
||||
tool: 'Write',
|
||||
toolId: 'tool-1',
|
||||
label: 'Wrote file',
|
||||
status: 'pending',
|
||||
input: '{"path":"/tmp/original.md"}',
|
||||
}],
|
||||
}
|
||||
|
||||
expect(updateToolAction(message, 'Write', 'tool-1')).toEqual({
|
||||
...message,
|
||||
actions: [{
|
||||
tool: 'Write',
|
||||
toolId: 'tool-1',
|
||||
label: 'Wrote file',
|
||||
status: 'pending',
|
||||
input: '{"path":"/tmp/original.md"}',
|
||||
}],
|
||||
})
|
||||
})
|
||||
})
|
||||
242
src/lib/aiAgentSession.test.ts
Normal file
242
src/lib/aiAgentSession.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatus } from '../hooks/useAiAgent'
|
||||
import type { AiAgentMessage } from './aiAgentConversation'
|
||||
|
||||
const {
|
||||
buildAgentSystemPromptMock,
|
||||
createStreamCallbacksMock,
|
||||
formatMessageWithHistoryMock,
|
||||
nextMessageIdMock,
|
||||
streamAiAgentMock,
|
||||
trimHistoryMock,
|
||||
} = vi.hoisted(() => ({
|
||||
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
|
||||
createStreamCallbacksMock: vi.fn(() => ({ stream: 'callbacks' })),
|
||||
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
|
||||
nextMessageIdMock: vi.fn(),
|
||||
streamAiAgentMock: vi.fn(async () => {}),
|
||||
trimHistoryMock: vi.fn((history: unknown) => history),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-agent', () => ({
|
||||
buildAgentSystemPrompt: buildAgentSystemPromptMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
MAX_HISTORY_TOKENS: 100_000,
|
||||
formatMessageWithHistory: formatMessageWithHistoryMock,
|
||||
nextMessageId: nextMessageIdMock,
|
||||
trimHistory: trimHistoryMock,
|
||||
}))
|
||||
|
||||
vi.mock('./aiAgentStreamCallbacks', () => ({
|
||||
createStreamCallbacks: createStreamCallbacksMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/streamAiAgent', () => ({
|
||||
streamAiAgent: streamAiAgentMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
clearAgentConversation,
|
||||
sendAgentMessage,
|
||||
type AiAgentSessionRuntime,
|
||||
} from './aiAgentSession'
|
||||
|
||||
function createRuntime(
|
||||
initialMessages: AiAgentMessage[] = [],
|
||||
initialStatus: AgentStatus = 'idle',
|
||||
) {
|
||||
let messages = initialMessages
|
||||
let status = initialStatus
|
||||
|
||||
const messagesRef = { current: messages }
|
||||
const statusRef = { current: status }
|
||||
|
||||
const setMessages = vi.fn((next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
|
||||
messages = typeof next === 'function' ? next(messages) : next
|
||||
messagesRef.current = messages
|
||||
})
|
||||
const setStatus = vi.fn((next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
|
||||
status = typeof next === 'function' ? next(status) : next
|
||||
statusRef.current = status
|
||||
})
|
||||
|
||||
const runtime: AiAgentSessionRuntime = {
|
||||
setMessages,
|
||||
setStatus,
|
||||
abortRef: { current: { aborted: true } },
|
||||
responseAccRef: { current: 'stale response' },
|
||||
fileCallbacksRef: { current: { onVaultChanged: vi.fn() } },
|
||||
toolInputMapRef: { current: new Map([['stale-tool', { tool: 'Write', input: '{"path":"/stale.md"}' }]]) },
|
||||
messagesRef,
|
||||
statusRef,
|
||||
}
|
||||
|
||||
return {
|
||||
runtime,
|
||||
getMessages: () => messages,
|
||||
getStatus: () => status,
|
||||
}
|
||||
}
|
||||
|
||||
describe('aiAgentSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
|
||||
createStreamCallbacksMock.mockReturnValue({ stream: 'callbacks' })
|
||||
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
|
||||
trimHistoryMock.mockImplementation((history: unknown) => history)
|
||||
streamAiAgentMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
async function expectLocalResponse(options: {
|
||||
messageId: string
|
||||
context: { agent: string; ready: boolean; vaultPath: string }
|
||||
prompt: { text: string; references?: [] }
|
||||
response: string
|
||||
}) {
|
||||
nextMessageIdMock.mockReturnValue(options.messageId)
|
||||
const { runtime, getMessages } = createRuntime()
|
||||
|
||||
await sendAgentMessage({
|
||||
runtime,
|
||||
context: options.context,
|
||||
prompt: options.prompt,
|
||||
})
|
||||
|
||||
expect(getMessages()).toEqual([
|
||||
{
|
||||
userMessage: options.prompt.text,
|
||||
references: undefined,
|
||||
actions: [],
|
||||
response: options.response,
|
||||
id: options.messageId,
|
||||
},
|
||||
])
|
||||
expect(streamAiAgentMock).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
it('ignores blank prompts and busy runtimes', async () => {
|
||||
const idleRuntime = createRuntime()
|
||||
await sendAgentMessage({
|
||||
runtime: idleRuntime.runtime,
|
||||
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
|
||||
prompt: { text: ' ' },
|
||||
})
|
||||
|
||||
const busyRuntime = createRuntime([], 'thinking')
|
||||
await sendAgentMessage({
|
||||
runtime: busyRuntime.runtime,
|
||||
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
|
||||
prompt: { text: 'Question' },
|
||||
})
|
||||
|
||||
expect(idleRuntime.getMessages()).toEqual([])
|
||||
expect(busyRuntime.getMessages()).toEqual([])
|
||||
expect(streamAiAgentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends local fallback responses when the session cannot stream', async () => {
|
||||
const fallbackCases = [
|
||||
{
|
||||
messageId: 'msg-local',
|
||||
context: { agent: 'codex', ready: true, vaultPath: '' },
|
||||
prompt: { text: 'Open a note' },
|
||||
response: 'No vault loaded. Open a vault first.',
|
||||
},
|
||||
{
|
||||
messageId: 'msg-missing',
|
||||
context: { agent: 'codex', ready: false, vaultPath: '/vault' },
|
||||
prompt: { text: 'Open a note', references: [] },
|
||||
response:
|
||||
'Codex is not available on this machine. Install it or switch the default AI agent in Settings.',
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const fallbackCase of fallbackCases) {
|
||||
await expectLocalResponse(fallbackCase)
|
||||
}
|
||||
})
|
||||
|
||||
it('starts a streaming session with formatted history and fresh refs', async () => {
|
||||
nextMessageIdMock.mockReturnValue('msg-stream')
|
||||
const completedHistory: AiAgentMessage = {
|
||||
id: 'msg-1',
|
||||
userMessage: 'Previous question',
|
||||
actions: [],
|
||||
response: 'Previous answer',
|
||||
}
|
||||
const streamingHistory: AiAgentMessage = {
|
||||
id: 'msg-2',
|
||||
userMessage: 'Ignored streaming question',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
}
|
||||
const { runtime, getMessages, getStatus } = createRuntime([
|
||||
completedHistory,
|
||||
streamingHistory,
|
||||
])
|
||||
|
||||
await sendAgentMessage({
|
||||
runtime,
|
||||
context: {
|
||||
agent: 'codex',
|
||||
ready: true,
|
||||
vaultPath: '/vault',
|
||||
systemPromptOverride: 'OVERRIDE',
|
||||
},
|
||||
prompt: {
|
||||
text: ' Latest question ',
|
||||
references: [{ path: '/vault/ref.md', title: 'Ref' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.abortRef.current).toEqual({ aborted: false })
|
||||
expect(runtime.responseAccRef.current).toBe('')
|
||||
expect(runtime.toolInputMapRef.current.size).toBe(0)
|
||||
expect(getStatus()).toBe('thinking')
|
||||
expect(getMessages().at(-1)).toEqual({
|
||||
userMessage: 'Latest question',
|
||||
references: [{ path: '/vault/ref.md', title: 'Ref' }],
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
id: 'msg-stream',
|
||||
})
|
||||
expect(trimHistoryMock).toHaveBeenCalledWith([
|
||||
{ role: 'user', content: 'Previous question', id: 'msg-1' },
|
||||
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
|
||||
], 100_000)
|
||||
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
|
||||
{ role: 'user', content: 'Previous question', id: 'msg-1' },
|
||||
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
|
||||
], 'Latest question')
|
||||
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
messageId: 'msg-stream',
|
||||
vaultPath: '/vault',
|
||||
setMessages: runtime.setMessages,
|
||||
setStatus: runtime.setStatus,
|
||||
}))
|
||||
expect(streamAiAgentMock).toHaveBeenCalledWith({
|
||||
agent: 'codex',
|
||||
message: 'formatted:Latest question',
|
||||
systemPrompt: 'OVERRIDE',
|
||||
vaultPath: '/vault',
|
||||
callbacks: { stream: 'callbacks' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the conversation and resets runtime refs', () => {
|
||||
const { runtime } = createRuntime([
|
||||
{ id: 'msg-1', userMessage: 'Question', actions: [] },
|
||||
], 'done')
|
||||
|
||||
clearAgentConversation(runtime)
|
||||
|
||||
expect(runtime.abortRef.current.aborted).toBe(true)
|
||||
expect(runtime.responseAccRef.current).toBe('')
|
||||
expect(runtime.toolInputMapRef.current.size).toBe(0)
|
||||
expect(runtime.setMessages).toHaveBeenCalledWith([])
|
||||
expect(runtime.setStatus).toHaveBeenCalledWith('idle')
|
||||
})
|
||||
})
|
||||
195
src/lib/aiAgentStreamCallbacks.test.ts
Normal file
195
src/lib/aiAgentStreamCallbacks.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatus } from '../hooks/useAiAgent'
|
||||
import type { AiAgentMessage } from './aiAgentConversation'
|
||||
|
||||
const { detectFileOperationMock } = vi.hoisted(() => ({
|
||||
detectFileOperationMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
detectFileOperation: detectFileOperationMock,
|
||||
}))
|
||||
|
||||
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
|
||||
|
||||
function createMessageStore(initialMessages: AiAgentMessage[]) {
|
||||
let messages = initialMessages
|
||||
|
||||
return {
|
||||
getMessages: () => messages,
|
||||
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
|
||||
messages = typeof next === 'function' ? next(messages) : next
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createStatusStore(initialStatus: AgentStatus = 'idle') {
|
||||
let status = initialStatus
|
||||
|
||||
return {
|
||||
getStatus: () => status,
|
||||
setStatus: (next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
|
||||
status = typeof next === 'function' ? next(status) : next
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('aiAgentStreamCallbacks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('handles the happy-path lifecycle and refreshes the vault at the end', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
const status = createStatusStore()
|
||||
const fileCallbacks = { onVaultChanged: vi.fn() }
|
||||
const responseAccRef = { current: '' }
|
||||
const toolInputMapRef = { current: new Map<string, { tool: string; input?: string }>() }
|
||||
|
||||
const callbacks = createStreamCallbacks({
|
||||
messageId: 'msg-1',
|
||||
vaultPath: '/vault',
|
||||
setMessages: messages.setMessages,
|
||||
setStatus: status.setStatus,
|
||||
abortRef: { current: { aborted: false } },
|
||||
responseAccRef,
|
||||
toolInputMapRef,
|
||||
fileCallbacksRef: { current: fileCallbacks },
|
||||
})
|
||||
|
||||
callbacks.onThinking('step 1')
|
||||
callbacks.onText('Hello')
|
||||
callbacks.onToolStart('Write', 'tool-1', '{"path":"/vault/note.md"}')
|
||||
callbacks.onToolStart('Write', 'tool-1')
|
||||
callbacks.onToolDone('tool-1', 'saved')
|
||||
callbacks.onDone()
|
||||
|
||||
expect(status.getStatus()).toBe('done')
|
||||
expect(responseAccRef.current).toBe('Hello')
|
||||
expect(toolInputMapRef.current.get('tool-1')).toEqual({
|
||||
tool: 'Write',
|
||||
input: '{"path":"/vault/note.md"}',
|
||||
})
|
||||
expect(detectFileOperationMock).toHaveBeenCalledWith(
|
||||
'Write',
|
||||
'{"path":"/vault/note.md"}',
|
||||
'/vault',
|
||||
fileCallbacks,
|
||||
)
|
||||
expect(fileCallbacks.onVaultChanged).toHaveBeenCalledTimes(1)
|
||||
expect(messages.getMessages()).toEqual([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [{
|
||||
tool: 'Write',
|
||||
toolId: 'tool-1',
|
||||
label: 'Wrote file',
|
||||
status: 'done',
|
||||
input: '{"path":"/vault/note.md"}',
|
||||
output: 'saved',
|
||||
}],
|
||||
isStreaming: false,
|
||||
reasoning: 'step 1',
|
||||
reasoningDone: true,
|
||||
response: 'Hello',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('marks pending actions as failed when the stream errors', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [{
|
||||
tool: 'Bash',
|
||||
toolId: 'tool-1',
|
||||
label: 'Ran shell command',
|
||||
status: 'pending',
|
||||
}],
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
const status = createStatusStore('thinking')
|
||||
const responseAccRef = { current: 'Partial reply' }
|
||||
|
||||
const callbacks = createStreamCallbacks({
|
||||
messageId: 'msg-1',
|
||||
vaultPath: '/vault',
|
||||
setMessages: messages.setMessages,
|
||||
setStatus: status.setStatus,
|
||||
abortRef: { current: { aborted: false } },
|
||||
responseAccRef,
|
||||
toolInputMapRef: { current: new Map() },
|
||||
fileCallbacksRef: { current: undefined },
|
||||
})
|
||||
|
||||
callbacks.onError('boom')
|
||||
|
||||
expect(status.getStatus()).toBe('error')
|
||||
expect(messages.getMessages()).toEqual([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [{
|
||||
tool: 'Bash',
|
||||
toolId: 'tool-1',
|
||||
label: 'Ran shell command',
|
||||
status: 'error',
|
||||
}],
|
||||
isStreaming: false,
|
||||
reasoningDone: true,
|
||||
response: 'Partial reply\n\nError: boom',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores stream events after the request has been aborted', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
const status = createStatusStore('thinking')
|
||||
const fileCallbacks = { onVaultChanged: vi.fn() }
|
||||
|
||||
const callbacks = createStreamCallbacks({
|
||||
messageId: 'msg-1',
|
||||
vaultPath: '/vault',
|
||||
setMessages: messages.setMessages,
|
||||
setStatus: status.setStatus,
|
||||
abortRef: { current: { aborted: true } },
|
||||
responseAccRef: { current: '' },
|
||||
toolInputMapRef: { current: new Map() },
|
||||
fileCallbacksRef: { current: fileCallbacks },
|
||||
})
|
||||
|
||||
callbacks.onThinking('ignored')
|
||||
callbacks.onText('ignored')
|
||||
callbacks.onToolStart('Write', 'tool-1', '{"path":"/vault/note.md"}')
|
||||
callbacks.onToolDone('tool-1', 'saved')
|
||||
callbacks.onError('boom')
|
||||
callbacks.onDone()
|
||||
|
||||
expect(status.getStatus()).toBe('thinking')
|
||||
expect(messages.getMessages()[0]).toEqual({
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
})
|
||||
expect(fileCallbacks.onVaultChanged).not.toHaveBeenCalled()
|
||||
expect(detectFileOperationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,19 @@ import {
|
||||
sanitizeTelemetryEnvValue,
|
||||
} from './telemetryConfig'
|
||||
|
||||
function resolveConfig(overrides: {
|
||||
VITE_SENTRY_DSN?: string
|
||||
VITE_POSTHOG_KEY?: string
|
||||
VITE_POSTHOG_HOST?: string
|
||||
} = {}) {
|
||||
return resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('sanitizeTelemetryEnvValue', () => {
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
|
||||
@@ -22,50 +35,77 @@ describe('sanitizeTelemetryEnvValue', () => {
|
||||
})
|
||||
|
||||
describe('resolveFrontendTelemetryConfig', () => {
|
||||
it('keeps valid telemetry values after sanitizing them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
|
||||
VITE_POSTHOG_KEY: " 'phc_test_key' ",
|
||||
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
it.each([
|
||||
{
|
||||
name: 'keeps valid telemetry values after sanitizing them',
|
||||
overrides: {
|
||||
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
|
||||
VITE_POSTHOG_KEY: " 'phc_test_key' ",
|
||||
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
|
||||
},
|
||||
expected: {
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'adds https to scheme-less DSNs and PostHog hosts',
|
||||
overrides: {
|
||||
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
|
||||
},
|
||||
expected: {
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
},
|
||||
},
|
||||
])('$name', ({ overrides, expected }) => {
|
||||
expect(resolveConfig(overrides)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('uses the default PostHog host when one is not configured', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
}).posthogHost).toBe(defaultPostHogHost)
|
||||
})
|
||||
|
||||
it('adds https to scheme-less DSNs and PostHog hosts', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
expect(resolveConfig({ VITE_POSTHOG_HOST: undefined }).posthogHost).toBe(defaultPostHogHost)
|
||||
})
|
||||
|
||||
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'not a dsn',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
|
||||
}).sentryDsn).toBe('')
|
||||
expect(resolveConfig({ VITE_SENTRY_DSN: 'not a dsn' }).sentryDsn).toBe('')
|
||||
})
|
||||
|
||||
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'not a url',
|
||||
}).posthogHost).toBeNull()
|
||||
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
|
||||
})
|
||||
|
||||
it('drops placeholder telemetry hosts that would create broken startup requests', () => {
|
||||
expect(resolveConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@false/123456',
|
||||
VITE_POSTHOG_HOST: 'false',
|
||||
})).toEqual({
|
||||
sentryDsn: '',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('drops single-label telemetry hosts but keeps localhost for dev', () => {
|
||||
expect(resolveConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@le/123456',
|
||||
VITE_POSTHOG_HOST: 'https://le',
|
||||
})).toEqual({
|
||||
sentryDsn: '',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: null,
|
||||
})
|
||||
|
||||
expect(resolveConfig({
|
||||
VITE_SENTRY_DSN: 'http://public@localhost:9000/123456',
|
||||
VITE_POSTHOG_HOST: 'http://localhost:8010',
|
||||
})).toEqual({
|
||||
sentryDsn: 'http://public@localhost:9000/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'http://localhost:8010',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
|
||||
const DISALLOWED_TELEMETRY_HOSTS = new Set([
|
||||
'false',
|
||||
'true',
|
||||
'null',
|
||||
'undefined',
|
||||
'none',
|
||||
'disabled',
|
||||
])
|
||||
|
||||
type TelemetryEnv = {
|
||||
VITE_SENTRY_DSN?: string
|
||||
@@ -35,12 +43,36 @@ export function sanitizeTelemetryEnvValue(value: string | undefined): string {
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
&& isAllowedTelemetryHostname(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
const normalized = hostname.trim().replace(/\.$/, '').toLowerCase()
|
||||
if (normalized.startsWith('[') && normalized.endsWith(']')) {
|
||||
return normalized.slice(1, -1)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isIpAddress(hostname: string): boolean {
|
||||
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
|
||||
return hostname.split('.').every((segment) => Number(segment) <= 255)
|
||||
}
|
||||
|
||||
return hostname.includes(':') && /^[\da-f:]+$/i.test(hostname)
|
||||
}
|
||||
|
||||
function isAllowedTelemetryHostname(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname)
|
||||
if (!normalized || DISALLOWED_TELEMETRY_HOSTS.has(normalized)) return false
|
||||
if (normalized === 'localhost') return true
|
||||
return normalized.includes('.') || isIpAddress(normalized)
|
||||
}
|
||||
|
||||
function normalizeHttpLikeValue(value: string): string {
|
||||
if (!value) return ''
|
||||
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
|
||||
|
||||
199
src/mock-tauri/mock-handlers.coverage.test.ts
Normal file
199
src/mock-tauri/mock-handlers.coverage.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function loadHandlers() {
|
||||
vi.resetModules()
|
||||
return import('./mock-handlers')
|
||||
}
|
||||
|
||||
describe('mockHandlers coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renames a note, updates its frontmatter title, and rewrites backlinks', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const oldPath = `${vaultPath}/old-note.md`
|
||||
const referencePath = `${vaultPath}/reference.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: oldPath,
|
||||
content: '---\ntitle: Old Note\n---\n\n# Old Note',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: referencePath,
|
||||
content: 'See [[Old Note]] and [[old-note]].',
|
||||
})
|
||||
|
||||
const result = mockHandlers.rename_note({
|
||||
vault_path: vaultPath,
|
||||
old_path: oldPath,
|
||||
new_title: 'New Title',
|
||||
old_title: 'Old Note',
|
||||
})
|
||||
|
||||
const updatedContent = mockHandlers.get_all_content() as Record<string, string>
|
||||
|
||||
expect(result).toEqual({
|
||||
new_path: `${vaultPath}/new-title.md`,
|
||||
updated_files: 1,
|
||||
})
|
||||
expect(updatedContent[`${vaultPath}/new-title.md`]).toContain('title: New Title')
|
||||
expect(updatedContent[referencePath]).toBe('See [[new-title]] and [[new-title]].')
|
||||
})
|
||||
|
||||
it('treats an unchanged title as a no-op rename', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const notePath = `${vaultPath}/same-title.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: notePath,
|
||||
content: '---\ntitle: Same Title\n---\n',
|
||||
})
|
||||
|
||||
expect(mockHandlers.rename_note({
|
||||
vault_path: vaultPath,
|
||||
old_path: notePath,
|
||||
new_title: 'Same Title',
|
||||
old_title: 'Same Title',
|
||||
})).toEqual({
|
||||
new_path: notePath,
|
||||
updated_files: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates filename-only renames and blocks collisions', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const sourcePath = `${vaultPath}/draft.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: sourcePath,
|
||||
content: '# Draft',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: `${vaultPath}/duplicate.md`,
|
||||
content: '# Existing',
|
||||
})
|
||||
|
||||
expect(() => mockHandlers.rename_note_filename({
|
||||
vault_path: vaultPath,
|
||||
old_path: sourcePath,
|
||||
new_filename_stem: ' ',
|
||||
})).toThrow('Invalid filename')
|
||||
|
||||
expect(() => mockHandlers.rename_note_filename({
|
||||
vault_path: vaultPath,
|
||||
old_path: sourcePath,
|
||||
new_filename_stem: 'duplicate',
|
||||
})).toThrow('A note with that name already exists')
|
||||
})
|
||||
|
||||
it('tracks saved files, deduplicates modified-file listings, and clears them on commit', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: '/Users/luca/Laputa/26q1-laputa-app.md',
|
||||
content: '# Updated project note',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: '/Users/luca/Laputa/new-note.md',
|
||||
content: '# New note',
|
||||
})
|
||||
|
||||
const modifiedBeforeCommit = mockHandlers.get_modified_files()
|
||||
const basePathCount = modifiedBeforeCommit.filter((entry) => entry.path === '/Users/luca/Laputa/26q1-laputa-app.md').length
|
||||
|
||||
expect(basePathCount).toBe(1)
|
||||
expect(modifiedBeforeCommit.some((entry) => entry.path === '/Users/luca/Laputa/new-note.md')).toBe(true)
|
||||
|
||||
expect(mockHandlers.git_commit({ message: 'Save everything' })).toContain('6 files changed')
|
||||
expect(mockHandlers.get_modified_files()).toEqual([])
|
||||
})
|
||||
|
||||
it('searches mock content and slices pulse results to the requested limit', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const projectPath = '/Users/luca/Laputa/26q1-laputa-app.md'
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: projectPath,
|
||||
content: '# Project Plan\n\nStrategic coverage improvements',
|
||||
})
|
||||
|
||||
const search = mockHandlers.search_vault({ query: 'strategic', mode: 'content' })
|
||||
const pulse = mockHandlers.get_vault_pulse({ limit: 2 })
|
||||
|
||||
expect(search.query).toBe('strategic')
|
||||
expect(search.results).toEqual([
|
||||
expect.objectContaining({
|
||||
path: projectPath,
|
||||
title: 'Build Laputa App',
|
||||
}),
|
||||
])
|
||||
expect(pulse).toHaveLength(2)
|
||||
expect(pulse[0]?.shortHash).toBe('a1b2c3d')
|
||||
})
|
||||
|
||||
it('applies setting defaults and keeps saved vault lists isolated from caller mutations', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
mockHandlers.save_settings({
|
||||
settings: {
|
||||
auto_pull_interval_minutes: undefined,
|
||||
autogit_enabled: true,
|
||||
autogit_idle_threshold_seconds: undefined,
|
||||
autogit_inactive_threshold_seconds: undefined,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: false,
|
||||
analytics_enabled: true,
|
||||
anonymous_id: 'anon-1',
|
||||
release_channel: 'alpha',
|
||||
default_ai_agent: 'codex',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockHandlers.get_settings()).toEqual({
|
||||
auto_pull_interval_minutes: 5,
|
||||
autogit_enabled: true,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: false,
|
||||
analytics_enabled: true,
|
||||
anonymous_id: 'anon-1',
|
||||
release_channel: 'alpha',
|
||||
default_ai_agent: 'codex',
|
||||
})
|
||||
|
||||
const list = {
|
||||
vaults: [{ label: 'Work', path: '/work' }],
|
||||
active_vault: '/work',
|
||||
}
|
||||
mockHandlers.save_vault_list({ list })
|
||||
|
||||
const savedList = mockHandlers.load_vault_list()
|
||||
savedList.vaults.push({ label: 'Leak', path: '/leak' })
|
||||
|
||||
expect(mockHandlers.load_vault_list()).toEqual({
|
||||
vaults: [{ label: 'Work', path: '/work' }],
|
||||
active_vault: '/work',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds attachment paths for saved and copied images', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(12345)
|
||||
|
||||
expect(mockHandlers.save_image({
|
||||
vault_path: '/vault',
|
||||
filename: 'diagram.png',
|
||||
data: 'base64',
|
||||
})).toBe('/vault/attachments/12345-diagram.png')
|
||||
|
||||
expect(mockHandlers.copy_image_to_vault({
|
||||
vault_path: '/vault',
|
||||
source_path: '/tmp/screenshot.jpg',
|
||||
})).toBe('/vault/attachments/12345-screenshot.jpg')
|
||||
})
|
||||
})
|
||||
183
src/mock-tauri/mock-handlers.more.test.ts
Normal file
183
src/mock-tauri/mock-handlers.more.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function loadHandlers() {
|
||||
vi.resetModules()
|
||||
return import('./mock-handlers')
|
||||
}
|
||||
|
||||
describe('mockHandlers additional coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns entry fallbacks, file history, diffs, and empty search results for empty queries', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
expect(mockHandlers.reload_vault_entry({ path: '/missing.md' })).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/missing.md',
|
||||
title: 'Unknown',
|
||||
filename: 'unknown.md',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mockHandlers.get_file_history({ path: '/vault/notes/strategy.md' })).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
shortHash: 'a1b2c3d',
|
||||
message: 'Update strategy with latest changes',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
shortHash: 'm0n1o2p',
|
||||
message: 'Create strategy',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(mockHandlers.get_file_diff({ path: '/vault/old-draft.md' })).toContain('deleted file mode 100644')
|
||||
expect(mockHandlers.get_file_diff_at_commit({
|
||||
path: '/vault/notes/strategy.md',
|
||||
commitHash: 'abcdef1234567890',
|
||||
})).toContain('Updated paragraph at commit abcdef1.')
|
||||
|
||||
expect(mockHandlers.search_vault({ query: '', mode: 'title' })).toEqual({
|
||||
results: [],
|
||||
elapsed_ms: 0,
|
||||
query: '',
|
||||
mode: 'title',
|
||||
})
|
||||
})
|
||||
|
||||
it('renames a filename successfully and rewrites wikilinks that target the old path stem', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const sourcePath = `${vaultPath}/meeting-notes.md`
|
||||
const backlinkPath = `${vaultPath}/backlinks.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: sourcePath,
|
||||
content: '# Meeting Notes',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: backlinkPath,
|
||||
content: 'Links: [[meeting-notes]] and [[Meeting Notes|alias]].',
|
||||
})
|
||||
|
||||
expect(mockHandlers.rename_note_filename({
|
||||
vault_path: vaultPath,
|
||||
old_path: sourcePath,
|
||||
new_filename_stem: 'weekly-notes',
|
||||
})).toEqual({
|
||||
new_path: `${vaultPath}/weekly-notes.md`,
|
||||
updated_files: 1,
|
||||
})
|
||||
|
||||
const content = mockHandlers.get_all_content() as Record<string, string>
|
||||
expect(content[`${vaultPath}/weekly-notes.md`]).toBe('# Meeting Notes')
|
||||
expect(content[backlinkPath]).toBe('Links: [[weekly-notes]] and [[Meeting Notes|alias]].')
|
||||
})
|
||||
|
||||
it('tracks remote state through create, clone, and add-remote flows', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const emptyVaultPath = '/Users/mock/Documents/Brand New Vault'
|
||||
const clonedVaultPath = '/Users/mock/Documents/Cloned Vault'
|
||||
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
|
||||
expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
})
|
||||
|
||||
expect(mockHandlers.git_add_remote({
|
||||
request: { vault_path: emptyVaultPath, remoteUrl: 'https://example.test/repo.git' },
|
||||
})).toEqual({
|
||||
status: 'connected',
|
||||
message: 'Remote connected. This vault now tracks origin/main.',
|
||||
})
|
||||
expect(mockHandlers.git_remote_status({ vault_path: emptyVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
|
||||
expect(mockHandlers.create_getting_started_vault({ targetPath: clonedVaultPath })).toBe(clonedVaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
})
|
||||
|
||||
expect(mockHandlers.clone_repo({
|
||||
url: 'https://example.test/repo.git',
|
||||
local_path: clonedVaultPath,
|
||||
})).toBe(`Cloned to ${clonedVaultPath}`)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('persists last-vault state, reports vault existence, and restores AI guidance state', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/demo-vault-v2')
|
||||
expect(mockHandlers.set_last_vault_path({ path: '/Users/mock/Documents/Work' })).toBeNull()
|
||||
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/Documents/Work')
|
||||
|
||||
expect(mockHandlers.check_vault_exists({ path: '/tmp/demo-vault-v2-copy' })).toBe(true)
|
||||
expect(mockHandlers.check_vault_exists({ path: '/tmp/random-vault' })).toBe(false)
|
||||
|
||||
expect(mockHandlers.get_vault_ai_guidance_status()).toEqual({
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
can_restore: false,
|
||||
})
|
||||
expect(mockHandlers.restore_vault_ai_guidance()).toEqual({
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
can_restore: false,
|
||||
})
|
||||
expect(mockHandlers.repair_vault()).toBe('Vault repaired')
|
||||
})
|
||||
|
||||
it('surfaces the simple command handlers for git, conflicts, trash, and telemetry', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
expect(mockHandlers.git_pull()).toEqual({
|
||||
status: 'up_to_date',
|
||||
message: 'Already up to date',
|
||||
updatedFiles: [],
|
||||
conflictFiles: [],
|
||||
})
|
||||
expect(mockHandlers.git_push()).toEqual({
|
||||
status: 'ok',
|
||||
message: 'Pushed to remote',
|
||||
})
|
||||
expect(mockHandlers.get_conflict_files()).toEqual([])
|
||||
expect(mockHandlers.get_conflict_mode()).toBe('none')
|
||||
expect(mockHandlers.purge_trash()).toEqual([])
|
||||
expect(mockHandlers.empty_trash()).toEqual([])
|
||||
expect(mockHandlers.delete_note({ path: '/vault/trash/me.md' })).toBe('/vault/trash/me.md')
|
||||
expect(mockHandlers.batch_delete_notes({ paths: ['/a.md', '/b.md'] })).toEqual(['/a.md', '/b.md'])
|
||||
expect(mockHandlers.batch_archive_notes({ paths: ['/a.md', '/b.md', '/c.md'] })).toBe(3)
|
||||
expect(mockHandlers.batch_trash_notes({ paths: ['/a.md', '/b.md'] })).toBe(2)
|
||||
expect(mockHandlers.migrate_is_a_to_type()).toBe(0)
|
||||
expect(mockHandlers.register_mcp_tools()).toBe('registered')
|
||||
expect(mockHandlers.check_mcp_status()).toBe('installed')
|
||||
expect(mockHandlers.reinit_telemetry()).toBeNull()
|
||||
expect(mockHandlers.stream_claude_chat()).toBe('mock-session')
|
||||
expect(mockHandlers.stream_claude_agent()).toBeNull()
|
||||
expect(mockHandlers.stream_ai_agent()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -70,6 +70,12 @@ describe('parseFrontmatter', () => {
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves single wikilinks as scalar strings instead of inline arrays', () => {
|
||||
const fm = parseFrontmatter('---\nOwner: [[person/alice]]\nBelongs to: [[project/alpha]]\n---\nBody')
|
||||
expect(fm['Owner']).toBe('[[person/alice]]')
|
||||
expect(fm['Belongs to']).toBe('[[project/alpha]]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectFrontmatterState', () => {
|
||||
|
||||
@@ -16,6 +16,10 @@ function isBlockScalar(value: string): boolean {
|
||||
return value === '' || value === '|' || value === '>'
|
||||
}
|
||||
|
||||
function isInlineArrayLiteral(value: string): boolean {
|
||||
return value.startsWith('[') && value.endsWith(']') && !value.startsWith('[[')
|
||||
}
|
||||
|
||||
function parseInlineArray(value: string): FrontmatterValue {
|
||||
const items = value.slice(1, -1).split(',').map(s => unquote(s.trim()))
|
||||
return collapseList(items)
|
||||
@@ -44,41 +48,71 @@ export function detectFrontmatterState(content: string | null): FrontmatterState
|
||||
return hasValidLine ? 'valid' : 'invalid'
|
||||
}
|
||||
|
||||
function extractFrontmatterBody(content: string | null): string | null {
|
||||
if (!content) return null
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function parseListItem(line: string): string | null {
|
||||
const match = line.match(/^ {2}- (.*)$/)
|
||||
return match ? unquote(match[1]) : null
|
||||
}
|
||||
|
||||
function parseKeyValueLine(line: string): { key: string, value: string } | null {
|
||||
const match = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
key: match[1].trim(),
|
||||
value: match[2].trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(value: string): FrontmatterValue | undefined {
|
||||
if (isBlockScalar(value)) return undefined
|
||||
if (isInlineArrayLiteral(value)) return parseInlineArray(value)
|
||||
return parseScalar(value)
|
||||
}
|
||||
|
||||
function flushList(
|
||||
result: ParsedFrontmatter,
|
||||
currentKey: string | null,
|
||||
currentList: string[],
|
||||
): string[] {
|
||||
if (currentKey && currentList.length > 0) {
|
||||
result[currentKey] = collapseList(currentList)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Parse YAML frontmatter from content */
|
||||
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
||||
if (!content) return {}
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
if (!match) return {}
|
||||
const frontmatterBody = extractFrontmatterBody(content)
|
||||
if (frontmatterBody === null) return {}
|
||||
|
||||
const result: ParsedFrontmatter = {}
|
||||
let currentKey: string | null = null
|
||||
let currentList: string[] = []
|
||||
let inList = false
|
||||
|
||||
for (const line of match[1].split('\n')) {
|
||||
const listMatch = line.match(/^ {2}- (.*)$/)
|
||||
if (listMatch && currentKey) {
|
||||
inList = true
|
||||
currentList.push(unquote(listMatch[1]))
|
||||
for (const line of frontmatterBody.split('\n')) {
|
||||
const listItem = parseListItem(line)
|
||||
if (listItem !== null && currentKey) {
|
||||
currentList.push(listItem)
|
||||
continue
|
||||
}
|
||||
|
||||
if (inList && currentKey) {
|
||||
result[currentKey] = collapseList(currentList)
|
||||
currentList = []
|
||||
inList = false
|
||||
currentList = flushList(result, currentKey, currentList)
|
||||
|
||||
const keyValue = parseKeyValueLine(line)
|
||||
if (!keyValue) continue
|
||||
currentKey = keyValue.key
|
||||
|
||||
const parsedValue = parseFrontmatterValue(keyValue.value)
|
||||
if (parsedValue !== undefined) {
|
||||
result[currentKey] = parsedValue
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
|
||||
if (!kvMatch) continue
|
||||
currentKey = kvMatch[1].trim()
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
if (isBlockScalar(value)) continue
|
||||
if (value.startsWith('[') && value.endsWith(']')) { result[currentKey] = parseInlineArray(value); continue }
|
||||
result[currentKey] = parseScalar(value)
|
||||
}
|
||||
|
||||
if (inList && currentKey) result[currentKey] = collapseList(currentList)
|
||||
flushList(result, currentKey, currentList)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ export function resolveInverseRelationshipLabel(
|
||||
}
|
||||
|
||||
export function orderInverseRelationshipLabels(labels: Iterable<string>): string[] {
|
||||
const customLabels = [...labels]
|
||||
const presentLabels = [...labels]
|
||||
const preferredLabels = PREFERRED_INVERSE_RELATIONSHIP_LABELS.filter((label) => presentLabels.includes(label))
|
||||
const customLabels = presentLabels
|
||||
.filter((label) => !PREFERRED_INVERSE_RELATIONSHIP_LABELS.includes(label as typeof PREFERRED_INVERSE_RELATIONSHIP_LABELS[number]))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
|
||||
return [...PREFERRED_INVERSE_RELATIONSHIP_LABELS, ...customLabels]
|
||||
return [...preferredLabels, ...customLabels]
|
||||
}
|
||||
|
||||
243
src/utils/noteListHelpers.extra.test.ts
Normal file
243
src/utils/noteListHelpers.extra.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
|
||||
import { allSelection, makeEntry } from '../test-utils/noteListTestUtils'
|
||||
import {
|
||||
clearListSortFromLocalStorage,
|
||||
countInboxByPeriod,
|
||||
extractSortableProperties,
|
||||
filterEntries,
|
||||
filterInboxEntries,
|
||||
formatSearchSubtitle,
|
||||
formatSubtitle,
|
||||
getSortComparator,
|
||||
getSortOptionLabel,
|
||||
loadSortPreferences,
|
||||
parseSortConfig,
|
||||
relativeDate,
|
||||
saveSortPreferences,
|
||||
serializeSortConfig,
|
||||
} from './noteListHelpers'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
describe('noteListHelpers extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-21T12:00:00Z'))
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('formats relative dates across future, recent, and older timestamps', () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
|
||||
expect(relativeDate(nowSeconds + 86400)).toBe('Apr 22')
|
||||
expect(relativeDate(nowSeconds - 30)).toBe('just now')
|
||||
expect(relativeDate(nowSeconds - 5 * 60)).toBe('5m ago')
|
||||
expect(relativeDate(nowSeconds - 2 * 3600)).toBe('2h ago')
|
||||
expect(relativeDate(nowSeconds - 3 * 86400)).toBe('3d ago')
|
||||
expect(relativeDate(nowSeconds - 10 * 86400)).toBe('Apr 11')
|
||||
})
|
||||
|
||||
it('builds note subtitles for empty, linked, and edited notes', () => {
|
||||
const modifiedEntry = makeEntry({
|
||||
title: 'Project',
|
||||
modifiedAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 86400 * 2,
|
||||
wordCount: 1200,
|
||||
outgoingLinks: ['alpha', 'beta'],
|
||||
})
|
||||
const emptyEntry = makeEntry({
|
||||
title: 'Empty',
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
wordCount: 0,
|
||||
outgoingLinks: [],
|
||||
})
|
||||
|
||||
expect(formatSubtitle(modifiedEntry)).toBe('1h ago · 1,200 words · 2 links')
|
||||
expect(formatSubtitle(emptyEntry)).toBe('Empty')
|
||||
expect(formatSearchSubtitle(modifiedEntry)).toBe('1h ago · Created 2d ago · 1,200 words · 2 links')
|
||||
})
|
||||
|
||||
it('extracts sortable properties and labels custom property sort keys', () => {
|
||||
const entries = [
|
||||
makeEntry({ properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
makeEntry({ properties: { Estimate: 3, Priority: 'Low' } }),
|
||||
]
|
||||
|
||||
expect(extractSortableProperties(entries)).toEqual(['Estimate', 'Owner', 'Priority'])
|
||||
expect(getSortOptionLabel('property:Priority')).toBe('Priority')
|
||||
expect(getSortOptionLabel('title')).toBe('Title')
|
||||
})
|
||||
|
||||
it('sorts entries by built-in and custom property comparators', () => {
|
||||
const entries = [
|
||||
makeEntry({
|
||||
title: 'Gamma',
|
||||
createdAt: 10,
|
||||
modifiedAt: 30,
|
||||
status: 'Done',
|
||||
properties: { Score: 5, Start: '2026-04-18', Enabled: true },
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Alpha',
|
||||
createdAt: 20,
|
||||
modifiedAt: 20,
|
||||
status: 'Active',
|
||||
properties: { Score: 2, Start: '2026-04-15', Enabled: false },
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Beta',
|
||||
createdAt: 15,
|
||||
modifiedAt: 25,
|
||||
status: null,
|
||||
properties: { Score: 8, Start: 'not-a-date', Enabled: true },
|
||||
}),
|
||||
]
|
||||
|
||||
expect([...entries].sort(getSortComparator('title', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Beta', 'Gamma'])
|
||||
expect([...entries].sort(getSortComparator('created', 'desc')).map((entry) => entry.title)).toEqual(['Alpha', 'Beta', 'Gamma'])
|
||||
expect([...entries].sort(getSortComparator('status', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
|
||||
expect([...entries].sort(getSortComparator('property:Score', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
|
||||
expect([...entries].sort(getSortComparator('property:Start', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
|
||||
expect([...entries].sort(getSortComparator('property:Enabled', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
|
||||
})
|
||||
|
||||
it('serializes, parses, loads, and saves sort preferences with migration support', () => {
|
||||
const serialized = serializeSortConfig({ option: 'property:Priority', direction: 'desc' })
|
||||
expect(serialized).toBe('property:Priority:desc')
|
||||
expect(parseSortConfig(serialized)).toEqual({ option: 'property:Priority', direction: 'desc' })
|
||||
expect(parseSortConfig('broken')).toBeNull()
|
||||
expect(parseSortConfig('title:sideways')).toBeNull()
|
||||
|
||||
localStorage.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify({
|
||||
'__list__': 'title',
|
||||
'type:Project': { option: 'created', direction: 'asc' },
|
||||
}))
|
||||
|
||||
expect(loadSortPreferences()).toEqual({
|
||||
'__list__': { option: 'title', direction: 'asc' },
|
||||
'type:Project': { option: 'created', direction: 'asc' },
|
||||
})
|
||||
|
||||
saveSortPreferences({
|
||||
'__list__': { option: 'modified', direction: 'desc' },
|
||||
})
|
||||
|
||||
expect(localStorage.getItem(APP_STORAGE_KEYS.sortPreferences)).toBe(JSON.stringify({
|
||||
'__list__': { option: 'modified', direction: 'desc' },
|
||||
}))
|
||||
expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)).toBeNull()
|
||||
|
||||
clearListSortFromLocalStorage()
|
||||
expect(localStorage.getItem(APP_STORAGE_KEYS.sortPreferences)).toBeNull()
|
||||
expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)).toBeNull()
|
||||
})
|
||||
|
||||
it('filters view, folder, favorites, and pulse selections', () => {
|
||||
const entries = [
|
||||
makeEntry({
|
||||
path: '/vault/notes/alpha.md',
|
||||
title: 'Alpha',
|
||||
fileKind: 'markdown',
|
||||
favorite: true,
|
||||
}),
|
||||
makeEntry({
|
||||
path: '/vault/projects/beta.md',
|
||||
title: 'Beta',
|
||||
fileKind: 'markdown',
|
||||
}),
|
||||
makeEntry({
|
||||
path: '/vault/attachments/diagram.png',
|
||||
title: 'Diagram',
|
||||
fileKind: 'binary',
|
||||
}),
|
||||
]
|
||||
const views = [{
|
||||
filename: 'work.view',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: {
|
||||
all: [{ field: 'title', op: 'contains', value: 'Alpha' }],
|
||||
},
|
||||
},
|
||||
}]
|
||||
|
||||
expect(filterEntries(entries, { kind: 'view', filename: 'work.view' }, undefined, views).map((entry) => entry.title)).toEqual(['Alpha'])
|
||||
expect(filterEntries(entries, { kind: 'folder', path: 'projects' }).map((entry) => entry.title)).toEqual(['Beta'])
|
||||
expect(filterEntries(entries, { kind: 'filter', filter: 'favorites' }).map((entry) => entry.title)).toEqual(['Alpha'])
|
||||
expect(filterEntries(entries, { kind: 'filter', filter: 'pulse' })).toEqual([])
|
||||
expect(filterEntries(entries, allSelection).map((entry) => entry.title)).toEqual(['Alpha', 'Beta'])
|
||||
})
|
||||
|
||||
it('filters inbox entries by period and counts them', () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const entries = [
|
||||
makeEntry({
|
||||
title: 'This Week',
|
||||
organized: false,
|
||||
archived: false,
|
||||
isA: 'Note',
|
||||
createdAt: nowSeconds - 2 * 86400,
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'This Month',
|
||||
organized: false,
|
||||
archived: false,
|
||||
isA: 'Note',
|
||||
createdAt: nowSeconds - 20 * 86400,
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'This Quarter',
|
||||
organized: false,
|
||||
archived: false,
|
||||
isA: 'Note',
|
||||
createdAt: nowSeconds - 80 * 86400,
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Organized',
|
||||
organized: true,
|
||||
archived: false,
|
||||
isA: 'Note',
|
||||
createdAt: nowSeconds - 2 * 86400,
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Type document',
|
||||
organized: false,
|
||||
archived: false,
|
||||
isA: 'Type',
|
||||
createdAt: nowSeconds - 2 * 86400,
|
||||
}),
|
||||
]
|
||||
|
||||
expect(filterInboxEntries(entries, 'week').map((entry) => entry.title)).toEqual(['This Week'])
|
||||
expect(filterInboxEntries(entries, 'month').map((entry) => entry.title)).toEqual(['This Week', 'This Month'])
|
||||
expect(filterInboxEntries(entries, 'quarter').map((entry) => entry.title)).toEqual(['This Week', 'This Month', 'This Quarter'])
|
||||
expect(countInboxByPeriod(entries)).toEqual({
|
||||
week: 1,
|
||||
month: 2,
|
||||
quarter: 3,
|
||||
all: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
34
src/utils/noteListSearchEvents.ts
Normal file
34
src/utils/noteListSearchEvents.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const NOTE_LIST_SEARCH_AVAILABILITY_EVENT = 'laputa:note-list-search-availability'
|
||||
export const NOTE_LIST_SEARCH_TOGGLE_EVENT = 'laputa:toggle-note-list-search'
|
||||
|
||||
interface NoteListSearchAvailabilityDetail {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function isAvailabilityDetail(detail: unknown): detail is NoteListSearchAvailabilityDetail {
|
||||
return typeof detail === 'object'
|
||||
&& detail !== null
|
||||
&& 'enabled' in detail
|
||||
&& typeof (detail as { enabled?: unknown }).enabled === 'boolean'
|
||||
}
|
||||
|
||||
export function dispatchNoteListSearchAvailability(enabled: boolean) {
|
||||
window.dispatchEvent(new CustomEvent<NoteListSearchAvailabilityDetail>(
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
{ detail: { enabled } },
|
||||
))
|
||||
}
|
||||
|
||||
export function readNoteListSearchAvailability(event: Event): boolean | null {
|
||||
if (!(event instanceof CustomEvent) || !isAvailabilityDetail(event.detail)) return null
|
||||
return event.detail.enabled
|
||||
}
|
||||
|
||||
export function dispatchNoteListSearchToggle() {
|
||||
window.dispatchEvent(new Event(NOTE_LIST_SEARCH_TOGGLE_EVENT))
|
||||
}
|
||||
|
||||
export function addNoteListSearchToggleListener(listener: () => void): () => void {
|
||||
window.addEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener)
|
||||
}
|
||||
63
src/utils/noteOpenPerformance.extra.test.ts
Normal file
63
src/utils/noteOpenPerformance.extra.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
beginNoteOpenTrace,
|
||||
failNoteOpenTrace,
|
||||
finishNoteOpenTrace,
|
||||
logKeyboardNavigationTrace,
|
||||
markNoteOpenTrace,
|
||||
} from './noteOpenPerformance'
|
||||
|
||||
const VITEST_WORKER_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, '__vitest_worker__')
|
||||
|
||||
describe('noteOpenPerformance additional coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (VITEST_WORKER_DESCRIPTOR) {
|
||||
Reflect.deleteProperty(globalThis, '__vitest_worker__')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (VITEST_WORKER_DESCRIPTOR) {
|
||||
Object.defineProperty(globalThis, '__vitest_worker__', VITEST_WORKER_DESCRIPTOR)
|
||||
}
|
||||
})
|
||||
|
||||
it('logs n/a durations and cache misses when optional marks are absent', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
vi.spyOn(performance, 'now')
|
||||
.mockReturnValueOnce(10)
|
||||
.mockReturnValueOnce(35)
|
||||
|
||||
beginNoteOpenTrace('/vault/missing-stages.md', 'quick-open')
|
||||
finishNoteOpenTrace('/vault/missing-stages.md')
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
'[perf] noteOpen path=/vault/missing-stages.md source=quick-open total=25.0ms beforeNavigate=n/a contentLoad=n/a editorSwap=25.0ms cache=miss',
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores trace updates when running under the vitest runtime flag', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
Object.defineProperty(globalThis, '__vitest_worker__', {
|
||||
configurable: true,
|
||||
value: 'worker-1',
|
||||
})
|
||||
|
||||
beginNoteOpenTrace('/vault/ignored.md', 'sidebar')
|
||||
markNoteOpenTrace('/vault/ignored.md', 'cacheReady')
|
||||
failNoteOpenTrace('/vault/ignored.md', 'ignored')
|
||||
finishNoteOpenTrace('/vault/ignored.md')
|
||||
logKeyboardNavigationTrace('down', 999, 12)
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not log quiet keyboard traces when both thresholds stay below the cutoff', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
|
||||
logKeyboardNavigationTrace('down', 499, 3.9)
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
84
src/utils/noteOpenPerformance.test.ts
Normal file
84
src/utils/noteOpenPerformance.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
beginNoteOpenTrace,
|
||||
failNoteOpenTrace,
|
||||
finishNoteOpenTrace,
|
||||
logKeyboardNavigationTrace,
|
||||
markNoteOpenTrace,
|
||||
} from './noteOpenPerformance'
|
||||
|
||||
const VITEST_WORKER_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, '__vitest_worker__')
|
||||
|
||||
describe('noteOpenPerformance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (VITEST_WORKER_DESCRIPTOR) {
|
||||
Reflect.deleteProperty(globalThis, '__vitest_worker__')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (VITEST_WORKER_DESCRIPTOR) {
|
||||
Object.defineProperty(globalThis, '__vitest_worker__', VITEST_WORKER_DESCRIPTOR)
|
||||
}
|
||||
})
|
||||
|
||||
it('logs a completed note-open trace with detailed stage timing', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
const nowSpy = vi.spyOn(performance, 'now')
|
||||
nowSpy
|
||||
.mockReturnValueOnce(100)
|
||||
.mockReturnValueOnce(120)
|
||||
.mockReturnValueOnce(150)
|
||||
.mockReturnValueOnce(170)
|
||||
.mockReturnValueOnce(200)
|
||||
.mockReturnValueOnce(245)
|
||||
.mockReturnValueOnce(290)
|
||||
|
||||
beginNoteOpenTrace('/vault/note.md', 'sidebar')
|
||||
markNoteOpenTrace('/vault/note.md', 'beforeNavigateStart')
|
||||
markNoteOpenTrace('/vault/note.md', 'beforeNavigateEnd')
|
||||
markNoteOpenTrace('/vault/note.md', 'cacheReady')
|
||||
markNoteOpenTrace('/vault/note.md', 'contentLoadStart')
|
||||
markNoteOpenTrace('/vault/note.md', 'contentLoadEnd')
|
||||
finishNoteOpenTrace('/vault/note.md')
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
'[perf] noteOpen path=/vault/note.md source=sidebar total=190.0ms beforeNavigate=30.0ms contentLoad=45.0ms editorSwap=45.0ms cache=hit',
|
||||
)
|
||||
})
|
||||
|
||||
it('logs when an in-flight note open is superseded by another one', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
const nowSpy = vi.spyOn(performance, 'now')
|
||||
nowSpy
|
||||
.mockReturnValueOnce(10)
|
||||
.mockReturnValueOnce(35)
|
||||
.mockReturnValueOnce(55)
|
||||
|
||||
beginNoteOpenTrace('/vault/alpha.md', 'sidebar')
|
||||
beginNoteOpenTrace('/vault/beta.md', 'search')
|
||||
failNoteOpenTrace('/vault/beta.md', 'cleanup')
|
||||
|
||||
expect(debugSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'[perf] noteOpen cancel path=/vault/alpha.md source=sidebar total=25.0ms reason=superseded',
|
||||
)
|
||||
expect(debugSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'[perf] noteOpen cancel path=/vault/beta.md source=search total=20.0ms reason=cleanup',
|
||||
)
|
||||
})
|
||||
|
||||
it('only logs keyboard traces when the list is large or slow enough', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
|
||||
logKeyboardNavigationTrace('down', 100, 3)
|
||||
logKeyboardNavigationTrace('up', 600, 5)
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledTimes(1)
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
'[perf] noteListKeyboard direction=up items=600 move=5.0ms',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,35 @@ describe('buildReleaseHistoryPage', () => {
|
||||
expect(html).toContain('<p>First paragraph<br>with a line break.</p><p>Second paragraph</p>')
|
||||
})
|
||||
|
||||
it('sorts releases within each channel by published date descending even when the payload order is wrong', () => {
|
||||
const html = buildReleaseHistoryPage([
|
||||
{
|
||||
body: 'Older alpha release',
|
||||
name: 'Tolaria Alpha 2026.4.20.9',
|
||||
prerelease: true,
|
||||
published_at: '2026-04-20T09:44:02Z',
|
||||
tag_name: 'alpha-v2026.4.20-alpha.9',
|
||||
},
|
||||
{
|
||||
body: 'Newest alpha release',
|
||||
name: 'Tolaria Alpha 2026.4.20.12',
|
||||
prerelease: true,
|
||||
published_at: '2026-04-20T16:53:41Z',
|
||||
tag_name: 'alpha-v2026.4.20-alpha.12',
|
||||
},
|
||||
{
|
||||
body: 'Middle alpha release',
|
||||
name: 'Tolaria Alpha 2026.4.20.10',
|
||||
prerelease: true,
|
||||
published_at: '2026-04-20T10:32:01Z',
|
||||
tag_name: 'alpha-v2026.4.20-alpha.10',
|
||||
},
|
||||
])
|
||||
|
||||
expect(html.indexOf('Tolaria Alpha 2026.4.20.12')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.10'))
|
||||
expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9'))
|
||||
})
|
||||
|
||||
it('filters draft releases and shows an empty state for channels without published builds', () => {
|
||||
const html = buildReleaseHistoryPage([
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ type ReleaseEntry = {
|
||||
githubUrl: string | null
|
||||
notesHtml: string
|
||||
publishedLabel: string
|
||||
publishedTimestamp: number
|
||||
tagName: string
|
||||
title: string
|
||||
}
|
||||
@@ -419,6 +420,15 @@ function formatPublishedLabel(value: unknown): string {
|
||||
})
|
||||
}
|
||||
|
||||
function parsePublishedTimestamp(value: unknown): number {
|
||||
const text = normalizeText(value)
|
||||
if (text === null) return Number.NEGATIVE_INFINITY
|
||||
|
||||
const publishedAt = new Date(text)
|
||||
const timestamp = publishedAt.getTime()
|
||||
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp
|
||||
}
|
||||
|
||||
function isDownloadableAsset(name: string): boolean {
|
||||
return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip')
|
||||
}
|
||||
@@ -471,6 +481,7 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel,
|
||||
githubUrl: normalizeUrl(release.html_url),
|
||||
notesHtml: resolveReleaseNotesHtml(release.body_html, release.body),
|
||||
publishedLabel: formatPublishedLabel(release.published_at),
|
||||
publishedTimestamp: parsePublishedTimestamp(release.published_at),
|
||||
tagName,
|
||||
title,
|
||||
}]
|
||||
@@ -490,6 +501,10 @@ function collectReleaseSections(payload: unknown): ReleaseSections {
|
||||
sections[channel].push(release)
|
||||
}
|
||||
|
||||
for (const channel of ['stable', 'alpha'] as const) {
|
||||
sections[channel].sort((left, right) => right.publishedTimestamp - left.publishedTimestamp)
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SectionGroup } from '../components/SidebarParts'
|
||||
import { resolveIcon } from './iconRegistry'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import { isLegacyJournalingType } from './legacyTypes'
|
||||
import { canonicalizeTypeName } from './vaultTypes'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, StackSimple,
|
||||
@@ -29,17 +30,44 @@ const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type,
|
||||
|
||||
const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
|
||||
const isActive = (e: VaultEntry) => !e.archived
|
||||
const isSupportedSectionType = (type: string) => !isLegacyJournalingType(type)
|
||||
|
||||
function shouldCollectActiveType(entry: VaultEntry): boolean {
|
||||
if (!isActive(entry) || !isMarkdown(entry)) return false
|
||||
if (!entry.isA) return false
|
||||
return isSupportedSectionType(entry.isA)
|
||||
return Boolean(entry.isA)
|
||||
}
|
||||
|
||||
function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
|
||||
if (name !== entry.title || !isActive(entry)) return false
|
||||
return isSupportedSectionType(name)
|
||||
return name === entry.title && isActive(entry)
|
||||
}
|
||||
|
||||
function resolveTypeEntry(type: string, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
|
||||
return typeEntryMap[type] ?? typeEntryMap[type.toLowerCase()]
|
||||
}
|
||||
|
||||
function hasExplicitTypeDefinition(type: string, typeEntryMap: Record<string, VaultEntry>): boolean {
|
||||
const typeEntry = resolveTypeEntry(type, typeEntryMap)
|
||||
return Boolean(typeEntry && typeEntry.title.trim().toLowerCase() === type.trim().toLowerCase() && isActive(typeEntry))
|
||||
}
|
||||
|
||||
function shouldIncludeSectionType(type: string, typeEntryMap: Record<string, VaultEntry>): boolean {
|
||||
if (!isLegacyJournalingType(type)) return true
|
||||
return hasExplicitTypeDefinition(type, typeEntryMap)
|
||||
}
|
||||
|
||||
function canonicalizeSectionType(type: string, typeEntryMap: Record<string, VaultEntry>): string | null {
|
||||
const trimmedType = type.trim()
|
||||
if (!trimmedType) return null
|
||||
return resolveTypeEntry(trimmedType, typeEntryMap)?.title ?? canonicalizeTypeName(trimmedType)
|
||||
}
|
||||
|
||||
function addSectionType(typeMap: Map<string, string>, rawType: string, typeEntryMap: Record<string, VaultEntry>): void {
|
||||
const canonicalType = canonicalizeSectionType(rawType, typeEntryMap)
|
||||
if (!canonicalType) return
|
||||
|
||||
const typeKey = canonicalType.toLowerCase()
|
||||
if (!typeMap.has(typeKey)) {
|
||||
typeMap.set(typeKey, canonicalType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect unique explicit isA values from active (non-archived) markdown entries. */
|
||||
@@ -71,12 +99,17 @@ export function buildSectionGroup(type: string, typeEntryMap: Record<string, Vau
|
||||
|
||||
/** Build sections dynamically from vault entries and defined types — types with 0 notes still appear */
|
||||
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
const activeTypes = new Map<string, string>()
|
||||
for (const type of collectActiveTypes(entries)) {
|
||||
addSectionType(activeTypes, type, typeEntryMap)
|
||||
}
|
||||
for (const [name, entry] of Object.entries(typeEntryMap)) {
|
||||
if (!shouldIncludeTypeDefinition(name, entry)) continue
|
||||
activeTypes.add(name)
|
||||
addSectionType(activeTypes, name, typeEntryMap)
|
||||
}
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
return Array.from(activeTypes.values())
|
||||
.filter((type) => shouldIncludeSectionType(type, typeEntryMap))
|
||||
.map((type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
|
||||
156
src/utils/streamAiAgent.test.ts
Normal file
156
src/utils/streamAiAgent.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
getAiAgentDefinitionMock,
|
||||
invokeMock,
|
||||
isTauriState,
|
||||
listenMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getAiAgentDefinitionMock: vi.fn((agent: string) => ({
|
||||
label: agent === 'codex' ? 'Codex' : 'Claude Code',
|
||||
})),
|
||||
invokeMock: vi.fn(),
|
||||
isTauriState: { value: false },
|
||||
listenMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriState.value,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/aiAgents', () => ({
|
||||
getAiAgentDefinition: getAiAgentDefinitionMock,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: invokeMock,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: listenMock,
|
||||
}))
|
||||
|
||||
import { streamAiAgent } from './streamAiAgent'
|
||||
|
||||
describe('streamAiAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriState.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses the mock response when Tauri is unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const callbacks = {
|
||||
onText: vi.fn(),
|
||||
onThinking: vi.fn(),
|
||||
onToolStart: vi.fn(),
|
||||
onToolDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
}
|
||||
|
||||
const promise = streamAiAgent({
|
||||
agent: 'codex',
|
||||
message: '<conversation_history>\n[user]: first\n\n[user]: latest\n</conversation_history>',
|
||||
vaultPath: '/vault',
|
||||
callbacks,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await promise
|
||||
|
||||
expect(callbacks.onText).toHaveBeenCalledWith(
|
||||
'[mock-codex turns=2] You asked: "latest" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].',
|
||||
)
|
||||
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
|
||||
expect(listenMock).not.toHaveBeenCalled()
|
||||
expect(invokeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards streamed Tauri events and invokes the backend request', async () => {
|
||||
isTauriState.value = true
|
||||
const unlistenMock = vi.fn()
|
||||
let eventHandler: ((event: { payload: unknown }) => void) | undefined
|
||||
|
||||
listenMock.mockImplementation(async (_eventName: string, handler: typeof eventHandler) => {
|
||||
eventHandler = handler
|
||||
return unlistenMock
|
||||
})
|
||||
invokeMock.mockImplementation(async () => {
|
||||
eventHandler?.({ payload: { kind: 'Init', session_id: 'session-1' } })
|
||||
eventHandler?.({ payload: { kind: 'ThinkingDelta', text: 'thinking...' } })
|
||||
eventHandler?.({ payload: { kind: 'TextDelta', text: 'answer' } })
|
||||
eventHandler?.({ payload: { kind: 'ToolStart', tool_name: 'Write', tool_id: 'tool-1', input: '{"path":"/vault/note.md"}' } })
|
||||
eventHandler?.({ payload: { kind: 'ToolDone', tool_id: 'tool-1', output: 'saved' } })
|
||||
eventHandler?.({ payload: { kind: 'Done' } })
|
||||
return 'session-1'
|
||||
})
|
||||
|
||||
const callbacks = {
|
||||
onText: vi.fn(),
|
||||
onThinking: vi.fn(),
|
||||
onToolStart: vi.fn(),
|
||||
onToolDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
}
|
||||
|
||||
const promise = streamAiAgent({
|
||||
agent: 'claude_code',
|
||||
message: 'Explain this',
|
||||
systemPrompt: 'SYSTEM',
|
||||
vaultPath: '/vault',
|
||||
callbacks,
|
||||
})
|
||||
|
||||
await promise
|
||||
|
||||
expect(listenMock).toHaveBeenCalledWith('ai-agent-stream', expect.any(Function))
|
||||
expect(invokeMock).toHaveBeenCalledWith('stream_ai_agent', {
|
||||
request: {
|
||||
agent: 'claude_code',
|
||||
message: 'Explain this',
|
||||
system_prompt: 'SYSTEM',
|
||||
vault_path: '/vault',
|
||||
},
|
||||
})
|
||||
expect(callbacks.onThinking).toHaveBeenCalledWith('thinking...')
|
||||
expect(callbacks.onText).toHaveBeenCalledWith('answer')
|
||||
expect(callbacks.onToolStart).toHaveBeenCalledWith('Write', 'tool-1', '{"path":"/vault/note.md"}')
|
||||
expect(callbacks.onToolDone).toHaveBeenCalledWith('tool-1', 'saved')
|
||||
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
|
||||
expect(unlistenMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces backend invocation failures and still closes the stream', async () => {
|
||||
isTauriState.value = true
|
||||
const unlistenMock = vi.fn()
|
||||
|
||||
listenMock.mockResolvedValue(unlistenMock)
|
||||
invokeMock.mockRejectedValue(new Error('backend boom'))
|
||||
|
||||
const callbacks = {
|
||||
onText: vi.fn(),
|
||||
onThinking: vi.fn(),
|
||||
onToolStart: vi.fn(),
|
||||
onToolDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
}
|
||||
|
||||
await streamAiAgent({
|
||||
agent: 'codex',
|
||||
message: 'Explain this',
|
||||
vaultPath: '/vault',
|
||||
callbacks,
|
||||
})
|
||||
|
||||
expect(callbacks.onError).toHaveBeenCalledWith('backend boom')
|
||||
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
|
||||
expect(unlistenMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -45,9 +45,16 @@ function collectHiddenTypeKeys(entries: VaultEntry[]): Set<string> {
|
||||
return hiddenTypeKeys
|
||||
}
|
||||
|
||||
function shouldIncludeCommandPaletteType(type: string, hiddenTypeKeys: Set<string>): boolean {
|
||||
function hasExplicitTypeDefinition(entries: VaultEntry[], type: string): boolean {
|
||||
const typeKey = type.trim().toLowerCase()
|
||||
return entries.some((entry) => entry.isA === 'Type' && !entry.archived && entry.title.trim().toLowerCase() === typeKey)
|
||||
}
|
||||
|
||||
function shouldIncludeCommandPaletteType(type: string, hiddenTypeKeys: Set<string>, entries: VaultEntry[]): boolean {
|
||||
const typeKey = type.toLowerCase()
|
||||
return !hiddenTypeKeys.has(typeKey) && !isLegacyJournalingType(type)
|
||||
if (hiddenTypeKeys.has(typeKey)) return false
|
||||
if (!isLegacyJournalingType(type)) return true
|
||||
return hasExplicitTypeDefinition(entries, type)
|
||||
}
|
||||
|
||||
export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
@@ -60,5 +67,5 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
const hiddenTypeKeys = collectHiddenTypeKeys(entries)
|
||||
const sourceTypes = typeMap.size === 0 ? DEFAULT_TYPES : Array.from(typeMap.values()).sort()
|
||||
|
||||
return sourceTypes.filter((type) => shouldIncludeCommandPaletteType(type, hiddenTypeKeys))
|
||||
return sourceTypes.filter((type) => shouldIncludeCommandPaletteType(type, hiddenTypeKeys, entries))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function seedLegacyJournalVault(vaultPath: string): void {
|
||||
function seedExplicitJournalTypeVault(vaultPath: string): void {
|
||||
fs.writeFileSync(path.join(vaultPath, 'journal.md'), `---
|
||||
type: Type
|
||||
order: 12
|
||||
@@ -30,25 +30,25 @@ type: Journal
|
||||
`)
|
||||
}
|
||||
|
||||
test.describe('command palette hides the legacy Journal type', () => {
|
||||
test.describe('explicit Journal type stays visible across navigation surfaces', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
seedLegacyJournalVault(tempVaultDir)
|
||||
seedExplicitJournalTypeVault(tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('legacy Journal does not appear in the sidebar or command palette', async ({ page }) => {
|
||||
test('explicit Journal type appears in the sidebar and command palette', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
|
||||
await expect(page.locator('nav').getByText('Journals', { exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('nav').getByText('Journals', { exact: true })).toBeVisible()
|
||||
|
||||
await openCommandPalette(page)
|
||||
await page.locator('input[placeholder="Type a command..."]').fill('journal')
|
||||
|
||||
await expect(page.getByText('No matching commands', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('div.mx-1.flex.cursor-pointer')).toHaveCount(0)
|
||||
await expect(page.getByText('List Journals', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('New Journal', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user