Compare commits

...

27 Commits

Author SHA1 Message Date
lucaronin
1b3a444368 fix: show welcome on fresh install without missing vault 2026-04-21 23:34:07 +02:00
lucaronin
c4da7f1f2f fix: narrow raw mode sync override 2026-04-21 19:04:02 +02:00
lucaronin
c5fe291a9a fix: sync inspector edits into raw editor 2026-04-21 19:04:02 +02:00
lucaronin
59268acd04 fix: guard empty vault path during startup 2026-04-21 19:04:01 +02:00
lucaronin
3e4e2ebdc6 refactor: simplify breadcrumb and note-list search ui 2026-04-21 19:03:11 +02:00
Luca Rossi
05375258a2 Rename 'Documentation' section to 'Tech Docs' 2026-04-21 18:06:30 +02:00
Luca Rossi
0c0354bfb8 Updated README badges 2026-04-21 18:05:54 +02:00
lucaronin
6023e95d5b fix: serialize vitest coverage files 2026-04-21 17:38:44 +02:00
lucaronin
88d5694353 fix: retry flaky vitest coverage teardown 2026-04-21 17:35:00 +02:00
lucaronin
db6b38843b fix: keep vitest coverage shards off workspace 2026-04-21 17:21:35 +02:00
lucaronin
ad9901bbf5 fix: stabilize coverage suite reruns 2026-04-21 17:08:43 +02:00
lucaronin
b785c53ef9 test: increase strategic coverage 2026-04-21 16:54:52 +02:00
lucaronin
8ae1ade220 test: cover live sidebar type picker sync 2026-04-21 15:16:39 +02:00
lucaronin
35b035643e fix: adapt save reload callback 2026-04-21 15:09:27 +02:00
lucaronin
d3d663af0b fix: reload vault entries on tauri startup 2026-04-21 15:02:42 +02:00
lucaronin
8f624350d8 test: cover journal vault scan 2026-04-21 12:37:46 +02:00
lucaronin
0e54173834 test: cover explicit journal visibility 2026-04-21 12:27:06 +02:00
lucaronin
a6f1524a78 ci: reject placeholder telemetry secrets 2026-04-21 11:06:16 +02:00
lucaronin
4cd5baeb55 fix: sync sidebar types with live vault state 2026-04-21 10:45:39 +02:00
lucaronin
fb1265e088 test: stabilize note list search timing 2026-04-21 10:14:18 +02:00
lucaronin
ca89662231 fix: sync raw editor frontmatter state 2026-04-21 10:06:54 +02:00
lucaronin
97f285e3cb ci: upload coverage to codecov 2026-04-21 10:06:54 +02:00
Luca Rossi
4bd4ef43dc Added release badge in README 2026-04-21 09:43:40 +02:00
Luca Rossi
be29880a3e Added walkthroughs to the README 2026-04-21 09:22:51 +02:00
lucaronin
7be5519f61 fix: sort alpha releases chronologically 2026-04-21 09:10:16 +02:00
lucaronin
8cefbe949a fix: refine derived inverse relationship labels 2026-04-21 09:08:25 +02:00
lucaronin
dd0a55b8e8 docs: add ADR for external vault refresh after pulls and agent edits (guard — from commit 0020ade6) 2026-04-21 08:03:02 +02:00
81 changed files with 8858 additions and 467 deletions

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,21 @@ jobs:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$")
def parse_alpha_tag(tag: str) -> tuple[str, int] | None:
match = alpha_pattern.fullmatch(tag)
if not match:
return None
calendar_version, sequence = match.groups()
return calendar_version, int(sequence)
def alpha_version(calendar_version: str, sequence: int) -> str:
return f"{calendar_version}-alpha.{sequence}"
def alpha_tag(calendar_version: str, sequence: int) -> str:
return f"alpha-v{calendar_version}-alpha.{sequence:04d}"
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
@@ -49,7 +64,8 @@ jobs:
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
parsed = parse_alpha_tag(tag)
version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v")
else:
today = datetime.now(timezone.utc).date()
stable_date = None
@@ -71,8 +87,8 @@ jobs:
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
version = alpha_version(calendar_version, sequence)
tag = alpha_tag(calendar_version, sequence)
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
@@ -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

View File

@@ -1,4 +1,4 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Build](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865)
# 💧 Tolaria
@@ -12,6 +12,13 @@ Personally, I use it to **run my life** (hey 👋 [Luca here](http://x.com/lucar
<img width="1000" height="656" alt="1776506856823-CleanShot_2026-04-18_at_12 06 57_2x" src="https://github.com/user-attachments/assets/8aeafb0a-b236-43c2-a083-ec111f903c38" />
## Walkthroughs
You can find some Loom walkthroughs below — they are short and to the point:
- [How I Organize My Own Tolaria Workspace](https://www.loom.com/share/bb3aaffa238b4be0bd62e4464bca2528)
- [My Inbox Workflow](https://www.loom.com/share/dffda263317b4fa8b47b59cdf9330571)
- [How I Save Web Resources to Tolaria](https://www.loom.com/share/8a3c1776f801402ebbf4d7b0f31e9882)
## Principles
- 📑 **Files-first** — Your notes are plain markdown files. They're portable, work with any editor, and require no export step. Your data belongs to you, not to any app.
@@ -54,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

View File

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

View File

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

View File

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

View File

@@ -126,3 +126,4 @@ 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 |

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -1,13 +1,16 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -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', () => {

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
@@ -57,21 +57,32 @@ describe('NoteList virtualized datasets', () => {
})
it('filters large datasets by search query', async () => {
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' }),
]
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' } })
await waitFor(() => {
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
})
await act(async () => {
vi.advanceTimersByTime(200)
})
vi.useRealTimers()
await waitFor(() => {
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
})
} finally {
vi.useRealTimers()
}
})
it('sorts large datasets correctly', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)
})
})

View File

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

View File

@@ -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()
})
})

View File

@@ -55,23 +55,23 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
</div>
{searchVisible && (
<div className="border-b border-border px-3 py-2">
<div className="flex items-center gap-3">
<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 text-[13px]"
className="h-8 pr-8 text-[13px]"
/>
<div className="min-w-[92px] text-[12px] text-muted-foreground" aria-live="polite">
{isSearching && (
<span className="flex items-center gap-1" data-testid="note-list-search-loading">
<Loader2 size={12} className="animate-spin" />
Searching...
</span>
)}
</div>
{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>
)}

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

View 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')
})
})

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

View 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()
})
})

View 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()
})
})

View File

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

View 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()
})
})

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

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

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

View 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')
})
})

View File

@@ -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')
})
})

View File

@@ -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', () => {

View File

@@ -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 }],

View File

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

View File

@@ -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')
})
})

View File

@@ -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?.()

View File

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

View File

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

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

View 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()
})
})

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

View 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([])
})
})

View File

@@ -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')
})

View File

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

View 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"}',
}],
})
})
})

View 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')
})
})

View 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()
})
})

View 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')
})
})

View 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()
})
})

View File

@@ -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', () => {

View File

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

View File

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

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

View 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()
})
})

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

View File

@@ -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([
{

View File

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

View File

@@ -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[] {

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

View File

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

View File

@@ -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()
})
})

View File

@@ -0,0 +1,117 @@
import { test, expect, type Page } from '@playwright/test'
import path from 'path'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
const PROJECT_NOTE_PATH = path.join('project', 'alpha-project.md')
const PROJECT_NOTE_CONTENT = `---
Is A: Project
Status: Done
Owner:
- "[[Note B]]"
Related to:
- "[[Note B]]"
- "[[Note C]]"
---
# Alpha Project
This is a test project that references other notes.
## Notes
See [[Note B]] for details and [[Note C]] for additional context.
`
let tempVaultDir: string
async function openAlphaProject(page: Page, notePath: string): Promise<void> {
const row = page.locator(`[data-note-path="${notePath}"]`)
await expect(row).toBeVisible({ timeout: 5_000 })
await row.click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function showStatusChipInInbox(page: Page): Promise<void> {
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('laputa:open-note-list-properties', {
detail: { scope: 'inbox' },
}))
})
await expect(page.getByTestId('list-properties-popover')).toBeVisible({ timeout: 5_000 })
await page.getByRole('checkbox', { name: 'status' }).click()
await page.keyboard.press('Escape')
}
async function openRawEditor(page: Page): Promise<void> {
await page.keyboard.press('Control+Backslash')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
}
async function replaceRawEditorContent(page: Page, nextContent: string): Promise<void> {
await page.evaluate((content) => {
const host = document.querySelector('.cm-content')
if (!host) {
throw new Error('CodeMirror content element is missing')
}
type CodeMirrorHost = Element & {
cmTile?: {
view?: {
state: { doc: { length: number } }
dispatch(transaction: { changes: { from: number; to: number; insert: string } }): void
}
}
}
const view = (host as CodeMirrorHost).cmTile?.view
if (!view) {
throw new Error('CodeMirror view is missing')
}
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: content,
},
})
}, nextContent)
}
test.describe('Raw editor frontmatter propagation', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('raw frontmatter edits immediately update inspector relationships and note-list chips @smoke', async ({ page }) => {
const notePath = path.join(tempVaultDir, PROJECT_NOTE_PATH)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await showStatusChipInInbox(page)
const noteRow = page.locator(`[data-note-path="${notePath}"]`)
await expect(noteRow.getByTestId('property-chip-status-0')).toHaveText('• Active')
await openAlphaProject(page, notePath)
await openRawEditor(page)
await replaceRawEditorContent(page, PROJECT_NOTE_CONTENT)
await page.waitForTimeout(800)
await page.keyboard.press('Control+Shift+i')
await expect(
page.locator('[data-testid="editable-property"]').filter({ hasText: 'Owner' }),
).toHaveCount(0)
await expect(
page.getByTestId('relationships-panel-grid').getByText('Owner', { exact: true }),
).toBeVisible()
await expect(noteRow.getByTestId('property-chip-status-0')).toHaveText('• Done')
})
})

View File

@@ -0,0 +1,87 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { openCommandPalette } from './helpers'
let tempVaultDir: string
function seedJournalInstanceWithoutType(vaultPath: string): void {
fs.writeFileSync(path.join(vaultPath, 'daily-log.md'), `---
title: Daily Log
type: Journal
---
# Daily Log
`)
}
async function openSidebarVisibilityPopover(page: Page): Promise<void> {
const customizeButton = page.getByRole('button', { name: 'Customize sections' })
await customizeButton.focus()
await expect(customizeButton).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByText('Show in sidebar', { exact: true })).toBeVisible()
}
async function closeSidebarVisibilityPopover(page: Page): Promise<void> {
const customizeButton = page.getByRole('button', { name: 'Customize sections' })
await customizeButton.focus()
await expect(customizeButton).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByText('Show in sidebar', { exact: true })).toHaveCount(0)
}
async function createTypeFromKeyboard(page: Page, typeName: string): Promise<void> {
await openCommandPalette(page)
await page.locator('input[placeholder="Type a command..."]').fill('new type')
await page.keyboard.press('Enter')
const typeInput = page.getByPlaceholder('e.g. Recipe, Book, Habit...')
await expect(page.getByText('Create New Type', { exact: true })).toBeVisible()
await expect(typeInput).toBeFocused()
await page.keyboard.type(typeName)
await page.keyboard.press('Enter')
await expect(page.getByText('Create New Type', { exact: true })).toHaveCount(0)
}
test.describe('Sidebar type picker stays in sync with live vault types', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
seedJournalInstanceWithoutType(tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('creating a missing Journal type updates the sidebar visibility picker immediately', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await expect(page.locator('nav').getByText('Journals', { exact: true })).toHaveCount(0)
await openSidebarVisibilityPopover(page)
await expect(page.getByRole('button', { name: 'Toggle Journals' })).toHaveCount(0)
await closeSidebarVisibilityPopover(page)
await createTypeFromKeyboard(page, 'Journal')
await expect(page.locator('nav').getByText('Journals', { exact: true })).toBeVisible()
await openSidebarVisibilityPopover(page)
const journalToggle = page.getByRole('button', { name: 'Toggle Journals' })
await expect(journalToggle).toBeVisible()
await journalToggle.focus()
await expect(journalToggle).toBeFocused()
await page.keyboard.press('Space')
await closeSidebarVisibilityPopover(page)
await expect(page.locator('nav').getByText('Journals', { exact: true })).toHaveCount(0)
})
})

View File

@@ -526,7 +526,7 @@ export default defineConfig({
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reporter: ['text', 'json', 'html', 'lcov'],
// Keep coverage temp files off the mounted workspace to avoid flaky
// read-after-write races when Vitest re-reads its own coverage shards.
reportsDirectory: vitestCoverageDirectory,