Compare commits
49 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3a444368 | ||
|
|
c4da7f1f2f | ||
|
|
c5fe291a9a | ||
|
|
59268acd04 | ||
|
|
3e4e2ebdc6 | ||
|
|
05375258a2 | ||
|
|
0c0354bfb8 | ||
|
|
6023e95d5b | ||
|
|
88d5694353 | ||
|
|
db6b38843b | ||
|
|
ad9901bbf5 | ||
|
|
b785c53ef9 | ||
|
|
8ae1ade220 | ||
|
|
35b035643e | ||
|
|
d3d663af0b | ||
|
|
8f624350d8 | ||
|
|
0e54173834 | ||
|
|
a6f1524a78 | ||
|
|
4cd5baeb55 | ||
|
|
fb1265e088 | ||
|
|
ca89662231 | ||
|
|
97f285e3cb | ||
|
|
4bd4ef43dc | ||
|
|
be29880a3e | ||
|
|
7be5519f61 | ||
|
|
8cefbe949a | ||
|
|
dd0a55b8e8 | ||
|
|
610276b1bc | ||
|
|
e6c968e37e | ||
|
|
367a66f32a | ||
|
|
6a4046915c | ||
|
|
ec8b637c84 | ||
|
|
3ee42fb856 | ||
|
|
eacf05a9f8 | ||
|
|
a199fad803 | ||
|
|
e956c806e5 | ||
|
|
7a6e3863ad | ||
|
|
e0e2dc4063 | ||
|
|
0020ade6d7 | ||
|
|
503e482c7d | ||
|
|
0d0e6af4c9 | ||
|
|
dbaf361878 | ||
|
|
d3741c82f5 | ||
|
|
b90d2f8612 | ||
|
|
9536efee0e | ||
|
|
c86a6ab9c5 | ||
|
|
1d7c027700 | ||
|
|
74e50c64bb | ||
|
|
8b97ef711f |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.89
|
||||
AVERAGE_THRESHOLD=9.75
|
||||
HOTSPOT_THRESHOLD=9.9
|
||||
AVERAGE_THRESHOLD=9.77
|
||||
|
||||
11
.github/workflows/README.md
vendored
11
.github/workflows/README.md
vendored
@@ -10,6 +10,7 @@ Il workflow `ci.yml` esegue i seguenti check automatici:
|
||||
|
||||
### 2. Test Coverage
|
||||
- Frontend: vitest con coverage reporting
|
||||
- Upload automatico su Codecov dai report LCOV frontend + Rust
|
||||
- Threshold configurabile in `vitest.config.ts`
|
||||
|
||||
### 3. Code Health (CodeScene)
|
||||
@@ -40,6 +41,11 @@ CODESCENE_PROJECT_ID=<your-project-id>
|
||||
Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token).
|
||||
Il project ID lo trovi nella dashboard CodeScene.
|
||||
|
||||
### Codecov Setup
|
||||
- Installa/attiva il repo in Codecov una volta sola tramite GitHub App / import del repository.
|
||||
- Nessun `CODECOV_TOKEN` richiesto in GitHub Actions: `ci.yml` usa OIDC (`id-token: write` + `use_oidc: true`).
|
||||
- Il workflow carica `coverage/lcov.info` (Vitest) e `coverage/rust.lcov` (cargo-llvm-cov).
|
||||
|
||||
### Telemetry Secrets For Release Builds
|
||||
Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`:
|
||||
|
||||
@@ -97,8 +103,11 @@ codescene delta-analysis --base-revision origin/main
|
||||
|
||||
## Workflow Triggers
|
||||
|
||||
- **Push**: su `main` e branch `experiment/*`
|
||||
- **Push**: su `main`
|
||||
- **Pull Request**: verso `main`
|
||||
- **Manuale**: `workflow_dispatch`
|
||||
|
||||
Nota: l'upload a Codecov gira su push a `main` e sulle PR dello stesso repository. Le PR da fork saltano l'upload per evitare problemi di permessi OIDC.
|
||||
|
||||
## Status Checks
|
||||
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -3,6 +3,13 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
@@ -78,10 +85,23 @@ jobs:
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--lcov \
|
||||
--output-path coverage/rust.lcov \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
files: ./coverage/lcov.info,./coverage/rust.lcov
|
||||
verbose: true
|
||||
# OIDC avoids long-lived CODECOV_TOKEN secrets.
|
||||
|
||||
# ── 3. Code Health (CodeScene — Hotspot + Average Code Health gates) ──
|
||||
# Enforces minimum floors on BOTH hotspot and average code health.
|
||||
# Thresholds come from .codescene-thresholds so CI and local hooks match.
|
||||
|
||||
44
.github/workflows/release-stable.yml
vendored
44
.github/workflows/release-stable.yml
vendored
@@ -136,9 +136,22 @@ jobs:
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DISALLOWED_PLACEHOLDERS = {
|
||||
"",
|
||||
"-",
|
||||
"_",
|
||||
"false",
|
||||
"true",
|
||||
"null",
|
||||
"undefined",
|
||||
"none",
|
||||
"disabled",
|
||||
}
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
@@ -150,9 +163,28 @@ jobs:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def normalize_hostname(hostname: str) -> str:
|
||||
normalized = hostname.strip().rstrip('.').lower()
|
||||
if normalized.startswith('[') and normalized.endswith(']'):
|
||||
normalized = normalized[1:-1]
|
||||
return normalized
|
||||
|
||||
def is_ip_address(hostname: str) -> bool:
|
||||
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
|
||||
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
|
||||
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
|
||||
|
||||
def is_allowed_hostname(hostname: str) -> bool:
|
||||
normalized = normalize_hostname(hostname)
|
||||
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
|
||||
return False
|
||||
if normalized == 'localhost':
|
||||
return True
|
||||
return '.' in normalized or is_ip_address(normalized)
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
@@ -167,13 +199,13 @@ jobs:
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
if value.lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append(f"{name} must be set to a real value, not a placeholder")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL")
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
|
||||
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
@@ -42,6 +42,21 @@ jobs:
|
||||
output = subprocess.check_output(command, text=True).strip()
|
||||
return [line for line in output.splitlines() if line]
|
||||
|
||||
alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$")
|
||||
|
||||
def parse_alpha_tag(tag: str) -> tuple[str, int] | None:
|
||||
match = alpha_pattern.fullmatch(tag)
|
||||
if not match:
|
||||
return None
|
||||
calendar_version, sequence = match.groups()
|
||||
return calendar_version, int(sequence)
|
||||
|
||||
def alpha_version(calendar_version: str, sequence: int) -> str:
|
||||
return f"{calendar_version}-alpha.{sequence}"
|
||||
|
||||
def alpha_tag(calendar_version: str, sequence: int) -> str:
|
||||
return f"alpha-v{calendar_version}-alpha.{sequence:04d}"
|
||||
|
||||
existing_tags = [
|
||||
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
|
||||
if tag.startswith("alpha-v")
|
||||
@@ -49,7 +64,8 @@ jobs:
|
||||
|
||||
if existing_tags:
|
||||
tag = existing_tags[0]
|
||||
version = tag.removeprefix("alpha-v")
|
||||
parsed = parse_alpha_tag(tag)
|
||||
version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v")
|
||||
else:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
stable_date = None
|
||||
@@ -71,8 +87,8 @@ jobs:
|
||||
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
|
||||
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
|
||||
|
||||
version = f"{calendar_version}-alpha.{sequence}"
|
||||
tag = f"alpha-v{version}"
|
||||
version = alpha_version(calendar_version, sequence)
|
||||
tag = alpha_tag(calendar_version, sequence)
|
||||
|
||||
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
|
||||
display_version = (
|
||||
@@ -179,9 +195,22 @@ jobs:
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DISALLOWED_PLACEHOLDERS = {
|
||||
"",
|
||||
"-",
|
||||
"_",
|
||||
"false",
|
||||
"true",
|
||||
"null",
|
||||
"undefined",
|
||||
"none",
|
||||
"disabled",
|
||||
}
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
@@ -193,9 +222,28 @@ jobs:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def normalize_hostname(hostname: str) -> str:
|
||||
normalized = hostname.strip().rstrip('.').lower()
|
||||
if normalized.startswith('[') and normalized.endswith(']'):
|
||||
normalized = normalized[1:-1]
|
||||
return normalized
|
||||
|
||||
def is_ip_address(hostname: str) -> bool:
|
||||
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
|
||||
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
|
||||
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
|
||||
|
||||
def is_allowed_hostname(hostname: str) -> bool:
|
||||
normalized = normalize_hostname(hostname)
|
||||
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
|
||||
return False
|
||||
if normalized == 'localhost':
|
||||
return True
|
||||
return '.' in normalized or is_ip_address(normalized)
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
@@ -210,13 +258,13 @@ jobs:
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
if value.lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append(f"{name} must be set to a real value, not a placeholder")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL")
|
||||
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
|
||||
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
@@ -275,7 +323,27 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "")
|
||||
PREV_TAG=$(python3 <<'PY'
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
current_tag = '${{ needs.version.outputs.tag }}'
|
||||
pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$')
|
||||
|
||||
output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip()
|
||||
tags = [line for line in output.splitlines() if line and line != current_tag]
|
||||
|
||||
parsed_tags = []
|
||||
for tag in tags:
|
||||
match = pattern.fullmatch(tag)
|
||||
if not match:
|
||||
continue
|
||||
year, month, day, sequence = map(int, match.groups())
|
||||
parsed_tags.append(((year, month, day, sequence), tag))
|
||||
|
||||
print(max(parsed_tags)[1] if parsed_tags else '')
|
||||
PY
|
||||
)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
|
||||
11
README.md
11
README.md
@@ -1,4 +1,4 @@
|
||||
[](https://codescene.io/projects/76865) [](https://codescene.io/projects/76865)
|
||||
 [](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [](https://codecov.io/gh/refactoringhq/tolaria) [](https://codescene.io/projects/76865)
|
||||
|
||||
# 💧 Tolaria
|
||||
|
||||
@@ -12,6 +12,13 @@ Personally, I use it to **run my life** (hey 👋 [Luca here](http://x.com/lucar
|
||||
|
||||
<img width="1000" height="656" alt="1776506856823-CleanShot_2026-04-18_at_12 06 57_2x" src="https://github.com/user-attachments/assets/8aeafb0a-b236-43c2-a083-ec111f903c38" />
|
||||
|
||||
## Walkthroughs
|
||||
|
||||
You can find some Loom walkthroughs below — they are short and to the point:
|
||||
- [How I Organize My Own Tolaria Workspace](https://www.loom.com/share/bb3aaffa238b4be0bd62e4464bca2528)
|
||||
- [My Inbox Workflow](https://www.loom.com/share/dffda263317b4fa8b47b59cdf9330571)
|
||||
- [How I Save Web Resources to Tolaria](https://www.loom.com/share/8a3c1776f801402ebbf4d7b0f31e9882)
|
||||
|
||||
## Principles
|
||||
|
||||
- 📑 **Files-first** — Your notes are plain markdown files. They're portable, work with any editor, and require no export step. Your data belongs to you, not to any app.
|
||||
@@ -54,7 +61,7 @@ Open `http://localhost:5173` for the browser-based mock mode, or run the native
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## Tech Docs
|
||||
|
||||
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
|
||||
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
|
||||
|
||||
@@ -273,7 +273,7 @@ type SidebarSelection =
|
||||
- The selected `entry` is the neighborhood source note.
|
||||
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
|
||||
- Outgoing relationship groups render first using the note's `relationships` map.
|
||||
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
|
||||
- Inverse groups (`Children`, `Events`, `Referenced by`) and `Backlinks` render after the outgoing groups.
|
||||
- Empty groups stay visible with count `0`.
|
||||
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
|
||||
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
|
||||
@@ -395,6 +395,8 @@ interface PulseCommit {
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk after a successful pull update so the editor and note list stay aligned
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
@@ -508,6 +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
|
||||
|
||||
@@ -667,6 +670,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -199,7 +199,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
@@ -531,7 +531,11 @@ flowchart TD
|
||||
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
|
||||
PULL --> PC{Result?}
|
||||
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
|
||||
PC -->|Fast-forward| RV["reload vault"]
|
||||
PC -->|Fast-forward| RV["reload vault + folders/views"]
|
||||
RV --> TAB{"clean active tab?"}
|
||||
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
|
||||
TAB -->|No| DONE["idle"]
|
||||
RT --> DONE
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
|
||||
@@ -778,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:
|
||||
@@ -785,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
|
||||
@@ -815,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.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0070"
|
||||
title: "Starter vaults are local-first with explicit remote connection"
|
||||
status: active
|
||||
date: 2026-04-19
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error.
|
||||
|
||||
That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started.
|
||||
|
||||
## Decision
|
||||
|
||||
**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette.
|
||||
|
||||
**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step.
|
||||
- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state.
|
||||
- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later.
|
||||
- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows.
|
||||
- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote.
|
||||
- The starter repo remains a distribution source only, not an ongoing sync destination.
|
||||
- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step.
|
||||
48
docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md
Normal file
48
docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0071"
|
||||
title: "External vault updates reload derived state and reopen the clean active note"
|
||||
status: active
|
||||
date: 2026-04-21
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0002 makes the filesystem the source of truth, and ADR-0043 keeps locally edited frontmatter reactive inside the running UI. But external vault mutations still had a gap. A `git pull` or an AI agent edit could change notes on disk while the app kept showing stale note-list state, stale derived relationships, or an editor surface that still rendered the pre-refresh BlockNote document.
|
||||
|
||||
The fix needed to satisfy a few constraints at once:
|
||||
|
||||
- refresh all vault-derived UI, not just the main note list
|
||||
- preserve unsaved local edits instead of clobbering them with disk state
|
||||
- reopen the active note from disk when it is safe, even if another file changed, because backlinks, inverse relationships, and other derived surfaces can depend on the whole vault
|
||||
- handle the native editor case where an in-place file update requires a full tab reopen to show the fresh document reliably
|
||||
|
||||
## Decision
|
||||
|
||||
**All external vault mutations now reconcile through one shared refresh path that reloads vault-derived state and then conditionally reopens the active note from disk.**
|
||||
|
||||
Tolaria now routes post-pull refreshes and AI-agent file modifications through the same `refreshPulledVaultState()` helper.
|
||||
|
||||
That shared path does the following:
|
||||
|
||||
1. Reload `vault.entries`, folders, and saved views together.
|
||||
2. If there is no active note, stop after the reload.
|
||||
3. If the active note has unsaved local edits, keep the current editor buffer and do not replace it from disk.
|
||||
4. Otherwise, find the refreshed `VaultEntry` for the active note and replace the active tab with freshly loaded disk content.
|
||||
5. If the active file itself changed in place during the external update, close the tab before reopening it so BlockNote fully remounts onto the new document.
|
||||
6. If the active file no longer exists after the reload, close the open tab state instead of leaving a stale editor behind.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Shared external-refresh reconciler** (chosen): one policy for pulls and agent edits, consistent vault-derived UI, and explicit protection for unsaved local edits. Cons: more coupling between sync flows and tab management.
|
||||
- **Patch only the changed surfaces ad hoc**: smaller individual fixes, but high risk of drift between pull handling, agent handling, and future external-write paths.
|
||||
- **Always force a full app-level reload**: simplest correctness story, but too disruptive and more likely to throw away user context unnecessarily.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Any workflow that mutates the vault externally, such as git pulls or agent writes, should go through the shared refresh reconciler rather than reloading a single surface in isolation.
|
||||
- Clean active notes now converge back to on-disk truth automatically after external updates.
|
||||
- Unsaved local edits remain protected from external refreshes, even when the rest of the vault reloads.
|
||||
- Folder, saved-view, backlink, and inverse-relationship surfaces stay aligned with the refreshed vault, not just the editor tab.
|
||||
- Tolaria now treats "refresh after external mutation" as a first-class synchronization concern rather than a per-feature fix.
|
||||
- Re-evaluate if the editor gains a reliable in-place document reset API, because that could remove the need for the close-and-reopen step when the active file itself changed.
|
||||
@@ -125,3 +125,5 @@ proposed → active → superseded
|
||||
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
|
||||
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
|
||||
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
|
||||
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
|
||||
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | active |
|
||||
|
||||
@@ -1,47 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, rename, rm } from 'node:fs/promises'
|
||||
import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const finalCoverageDir = resolve(rootDir, 'coverage')
|
||||
const coverageRunRoot = resolve(rootDir, '.tmp', 'vitest-coverage-runs')
|
||||
const runId = `${Date.now()}-${process.pid}`
|
||||
const runCoverageDir = resolve(coverageRunRoot, runId)
|
||||
const coverageRunRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-runs')
|
||||
const forwardedArgs = process.argv.slice(2)
|
||||
|
||||
await mkdir(runCoverageDir, { recursive: true })
|
||||
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--fileParallelism' || arg === '--no-file-parallelism'
|
||||
)
|
||||
const maxAttempts = 2
|
||||
|
||||
const packageManagerExec = process.env.npm_execpath
|
||||
const command = packageManagerExec ? process.execPath : 'pnpm'
|
||||
const commandArgs = packageManagerExec
|
||||
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage', `--coverage.reportsDirectory=${runCoverageDir}`, ...forwardedArgs]
|
||||
: ['exec', 'vitest', 'run', '--coverage', `--coverage.reportsDirectory=${runCoverageDir}`, ...forwardedArgs]
|
||||
const baseCommandArgs = packageManagerExec
|
||||
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
|
||||
: ['exec', 'vitest', 'run', '--coverage']
|
||||
|
||||
const exitCode = await new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('error', rejectExit)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolveExit(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
if (exitCode === 0) {
|
||||
await rm(finalCoverageDir, { recursive: true, force: true })
|
||||
await rename(runCoverageDir, finalCoverageDir)
|
||||
process.exit(0)
|
||||
function isKnownVitestInternalStateFlake(output) {
|
||||
return output.includes('Vitest failed to access its internal state.')
|
||||
&& /Test Files\s+\d+\s+passed\s+\(\d+\)/.test(output)
|
||||
&& /Tests\s+\d+\s+passed\s+\(\d+\)/.test(output)
|
||||
}
|
||||
|
||||
console.error(`Vitest coverage artifacts preserved at ${runCoverageDir}`)
|
||||
process.exit(exitCode)
|
||||
function appendCapturedOutput(output, chunk) {
|
||||
const nextOutput = output + chunk
|
||||
return nextOutput.length > 200_000 ? nextOutput.slice(-200_000) : nextOutput
|
||||
}
|
||||
|
||||
async function runCoverageAttempt(attempt) {
|
||||
const runId = `${Date.now()}-${process.pid}-${attempt}`
|
||||
const runCoverageDir = resolve(coverageRunRoot, runId)
|
||||
const runCoverageTempDir = resolve(runCoverageDir, '.tmp')
|
||||
|
||||
await mkdir(runCoverageDir, { recursive: true })
|
||||
// Vitest writes per-worker coverage shards under reportsDirectory/.tmp.
|
||||
await mkdir(runCoverageTempDir, { recursive: true })
|
||||
|
||||
const commandArgs = [
|
||||
...baseCommandArgs,
|
||||
// Vitest 4.0.18 occasionally crashes during coverage worker teardown
|
||||
// after all files pass, so serialize file execution unless a caller
|
||||
// explicitly opts into a different file-parallelism mode.
|
||||
...(hasFileParallelismOverride ? [] : ['--no-file-parallelism']),
|
||||
`--coverage.reportsDirectory=${runCoverageDir}`,
|
||||
...forwardedArgs,
|
||||
]
|
||||
let output = ''
|
||||
|
||||
const exitCode = await new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
VITEST_COVERAGE_DIR: runCoverageDir,
|
||||
},
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const handleOutput = (stream, target) => {
|
||||
if (!stream) return
|
||||
stream.setEncoding('utf8')
|
||||
stream.on('data', (chunk) => {
|
||||
target.write(chunk)
|
||||
output = appendCapturedOutput(output, chunk)
|
||||
})
|
||||
}
|
||||
|
||||
handleOutput(child.stdout, process.stdout)
|
||||
handleOutput(child.stderr, process.stderr)
|
||||
|
||||
child.on('error', rejectExit)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolveExit(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
output,
|
||||
runCoverageDir,
|
||||
}
|
||||
}
|
||||
|
||||
let finalRun = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const run = await runCoverageAttempt(attempt)
|
||||
finalRun = run
|
||||
|
||||
if (run.exitCode === 0) {
|
||||
await rm(finalCoverageDir, { recursive: true, force: true })
|
||||
await cp(run.runCoverageDir, finalCoverageDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
})
|
||||
await rm(run.runCoverageDir, { recursive: true, force: true })
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Retry once when Vitest itself flakes after a fully passing suite.
|
||||
if (attempt < maxAttempts && isKnownVitestInternalStateFlake(run.output)) {
|
||||
console.error(`Vitest hit a known internal-state teardown flake on attempt ${attempt}; retrying once...`)
|
||||
await rm(run.runCoverageDir, { recursive: true, force: true })
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
console.error(`Vitest coverage artifacts preserved at ${finalRun.runCoverageDir}`)
|
||||
process.exit(finalRun.exitCode)
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct MenuStateUpdate {
|
||||
has_conflicts: Option<bool>,
|
||||
has_restorable_deleted_note: Option<bool>,
|
||||
has_no_remote: Option<bool>,
|
||||
note_list_search_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -77,6 +78,9 @@ pub fn update_menu_state(
|
||||
if let Some(v) = state.has_no_remote {
|
||||
menu::set_git_no_remote_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = state.note_list_search_enabled {
|
||||
menu::set_note_list_search_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
|
||||
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
|
||||
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
|
||||
|
||||
@@ -62,6 +63,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
FILE_QUICK_OPEN_ALIAS,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_EDITOR_ONLY,
|
||||
@@ -110,6 +112,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
];
|
||||
|
||||
/// IDs of menu items that depend on the note list being the active surface.
|
||||
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
|
||||
|
||||
/// IDs of menu items that depend on a deleted-note preview being active.
|
||||
const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED];
|
||||
|
||||
@@ -185,6 +190,11 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.id(EDIT_FIND_IN_VAULT)
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
.build(app)?;
|
||||
let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search")
|
||||
.id(EDIT_TOGGLE_NOTE_LIST_SEARCH)
|
||||
.accelerator("CmdOrCtrl+F")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
|
||||
.id(EDIT_TOGGLE_DIFF)
|
||||
.build(app)?;
|
||||
@@ -200,6 +210,7 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.select_all()
|
||||
.separator()
|
||||
.item(&find_in_vault)
|
||||
.item(&toggle_note_list_search)
|
||||
.item(&toggle_diff)
|
||||
.build()?)
|
||||
}
|
||||
@@ -452,6 +463,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on the note list being the active surface.
|
||||
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on having uncommitted changes.
|
||||
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
|
||||
@@ -486,6 +502,7 @@ mod tests {
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_EDITOR_ONLY,
|
||||
@@ -532,6 +549,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_list_search_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in NOTE_LIST_SEARCH_DEPENDENT_IDS {
|
||||
assert!(
|
||||
CUSTOM_IDS.contains(id),
|
||||
"note-list-search-dependent ID {id} not in CUSTOM_IDS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in GIT_COMMIT_DEPENDENT_IDS {
|
||||
|
||||
@@ -13,9 +13,45 @@ pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
.ok_or_else(|| "Could not determine Documents directory".to_string())
|
||||
}
|
||||
|
||||
const GETTING_STARTED_REQUIRED_CONFIG_FILES: [&str; 2] = ["type.md", "note.md"];
|
||||
const GETTING_STARTED_TEMPLATE_MARKERS: [&str; 2] = ["welcome.md", "views/active-projects.yml"];
|
||||
|
||||
/// Check whether a vault path exists on disk.
|
||||
pub fn vault_exists(path: &str) -> bool {
|
||||
Path::new(path).is_dir()
|
||||
let default_path = default_vault_path().ok();
|
||||
vault_exists_with_default_path(Path::new(path), default_path.as_deref())
|
||||
}
|
||||
|
||||
fn vault_exists_with_default_path(path: &Path, default_path: Option<&Path>) -> bool {
|
||||
if !path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !is_canonical_getting_started_path(path, default_path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
canonical_getting_started_vault_exists(path)
|
||||
}
|
||||
|
||||
fn is_canonical_getting_started_path(path: &Path, default_path: Option<&Path>) -> bool {
|
||||
default_path.is_some_and(|candidate| candidate == path)
|
||||
}
|
||||
|
||||
fn canonical_getting_started_vault_exists(path: &Path) -> bool {
|
||||
has_getting_started_config_files(path) && has_getting_started_template_marker(path)
|
||||
}
|
||||
|
||||
fn has_getting_started_config_files(path: &Path) -> bool {
|
||||
GETTING_STARTED_REQUIRED_CONFIG_FILES
|
||||
.iter()
|
||||
.all(|file| path.join(file).is_file())
|
||||
}
|
||||
|
||||
fn has_getting_started_template_marker(path: &Path) -> bool {
|
||||
GETTING_STARTED_TEMPLATE_MARKERS
|
||||
.iter()
|
||||
.any(|file| path.join(file).is_file())
|
||||
}
|
||||
|
||||
/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults
|
||||
@@ -640,6 +676,13 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_tolaria_config_files(path: &Path) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
fs::write(path.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
fs::write(path.join("type.md"), "# Type\n").unwrap();
|
||||
fs::write(path.join("note.md"), "# Note\n").unwrap();
|
||||
}
|
||||
|
||||
fn assert_getting_started_vault_replaces_template(agents_content: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
@@ -669,6 +712,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_getting_started_path_rejects_plain_tolaria_folder() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let default_path = dir.path().join("Getting Started");
|
||||
|
||||
write_tolaria_config_files(&default_path);
|
||||
|
||||
assert!(!vault_exists_with_default_path(
|
||||
default_path.as_path(),
|
||||
Some(default_path.as_path())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_canonical_vault_path_stays_permissive() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let default_path = dir.path().join("Getting Started");
|
||||
let other_vault_path = dir.path().join("Existing Vault");
|
||||
|
||||
fs::create_dir_all(&other_vault_path).unwrap();
|
||||
|
||||
assert!(vault_exists_with_default_path(
|
||||
other_vault_path.as_path(),
|
||||
Some(default_path.as_path())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_clones_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -692,6 +762,22 @@ mod tests {
|
||||
assert!(dest.join("note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_getting_started_path_accepts_cloned_starter_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let default_path = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(default_path.as_path(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert!(vault_exists_with_default_path(
|
||||
default_path.as_path(),
|
||||
Some(default_path.as_path())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_rejects_nonempty_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -28,6 +28,8 @@ mod complex_frontmatter;
|
||||
mod display_metadata;
|
||||
#[path = "mod_tests/folder_and_file_kind.rs"]
|
||||
mod folder_and_file_kind;
|
||||
#[path = "mod_tests/journal_type_visibility.rs"]
|
||||
mod journal_type_visibility;
|
||||
#[path = "mod_tests/real_vault_consistency.rs"]
|
||||
mod real_vault_consistency;
|
||||
#[path = "mod_tests/relationships.rs"]
|
||||
|
||||
38
src-tauri/src/vault/mod_tests/journal_type_visibility.rs
Normal file
38
src-tauri/src/vault/mod_tests/journal_type_visibility.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_preserves_explicit_journal_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"journal.md",
|
||||
"---\ntype: Type\nvisible: true\n---\n# Journal\n",
|
||||
);
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"2026-03-11.md",
|
||||
"---\ntitle: March 11\ntype: Journal\n---\n# March 11\n",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
let journal_type = entries
|
||||
.iter()
|
||||
.find(|entry| entry.filename == "journal.md")
|
||||
.expect("expected the explicit Journal type file to be scanned");
|
||||
assert_eq!(journal_type.title, "Journal");
|
||||
assert_eq!(journal_type.is_a.as_deref(), Some("Type"));
|
||||
assert_eq!(journal_type.visible, Some(true));
|
||||
|
||||
let journal_note = entries
|
||||
.iter()
|
||||
.find(|entry| entry.filename == "2026-03-11.md")
|
||||
.expect("expected the Journal note to be scanned");
|
||||
assert_eq!(journal_note.is_a.as_deref(), Some("Journal"));
|
||||
assert_eq!(
|
||||
journal_note.relationships.get("Type"),
|
||||
Some(&vec!["[[journal]]".to_string()]),
|
||||
);
|
||||
}
|
||||
@@ -495,6 +495,24 @@ describe('App', () => {
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder')
|
||||
})
|
||||
|
||||
it('shows welcome instead of vault-missing when the missing path was not a persisted active vault', async () => {
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Vault not found')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
})
|
||||
|
||||
it('renders sidebar with correct default selection (All Notes)', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
@@ -570,9 +588,10 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
|
||||
const workVaultPath = '/Users/mock/Documents/Work'
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
|
||||
active_vault: '/Users/mock/Documents/Getting Started',
|
||||
vaults: [{ label: 'Work Vault', path: workVaultPath }],
|
||||
active_vault: workVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
const disabledWorkflowConfig = JSON.stringify({
|
||||
@@ -584,14 +603,7 @@ describe('App', () => {
|
||||
property_display_modes: null,
|
||||
inbox: { noteListProperties: null, explicitOrganization: false },
|
||||
})
|
||||
const vaultPaths = [
|
||||
'/Users/mock/Documents/Getting Started',
|
||||
'/Users/mock/demo-vault-v2',
|
||||
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
|
||||
]
|
||||
for (const path of vaultPaths) {
|
||||
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
|
||||
}
|
||||
localStorage.setItem(`laputa:vault-config:${workVaultPath}`, disabledWorkflowConfig)
|
||||
|
||||
render(<App />)
|
||||
|
||||
|
||||
70
src/App.tsx
70
src/App.tsx
@@ -74,6 +74,7 @@ import type { NoteListItem } from './utils/ai-context'
|
||||
import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
@@ -404,17 +405,6 @@ function App() {
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
||||
|
||||
// Detect external file renames on window focus
|
||||
@@ -456,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,
|
||||
@@ -487,8 +476,41 @@ function App() {
|
||||
const {
|
||||
handleSelectNote,
|
||||
handleReplaceActiveTab,
|
||||
closeAllTabs,
|
||||
openTabWithContent,
|
||||
} = notes
|
||||
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
|
||||
await refreshPulledVaultState({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
reloadFolders: vault.reloadFolders,
|
||||
reloadVault: vault.reloadVault,
|
||||
reloadViews: vault.reloadViews,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
}, [
|
||||
closeAllTabs,
|
||||
handleReplaceActiveTab,
|
||||
notes.activeTabPath,
|
||||
resolvedPath,
|
||||
vault.reloadFolders,
|
||||
vault.reloadVault,
|
||||
vault.reloadViews,
|
||||
vault.unsavedPaths,
|
||||
])
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: handlePulledVaultUpdate,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const pulseCommitDiffRequestIdRef = useRef(0)
|
||||
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
|
||||
|
||||
@@ -592,8 +614,14 @@ function App() {
|
||||
}, [handleEnterNeighborhood, handleReplaceActiveTab])
|
||||
|
||||
const vaultBridge = useVaultBridge({
|
||||
entriesByPath, resolvedPath,
|
||||
entriesByPath,
|
||||
resolvedPath,
|
||||
reloadVault: vault.reloadVault,
|
||||
reloadFolders: vault.reloadFolders,
|
||||
reloadViews: vault.reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
})
|
||||
@@ -613,7 +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,
|
||||
|
||||
10
src/assets/tolaria-icon.svg
Normal file
10
src/assets/tolaria-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_111_151)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_151">
|
||||
<rect width="266" height="363" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 889 B |
@@ -77,6 +77,12 @@ describe('AiPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a new AI chat when the header action is clicked', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
fireEvent.click(screen.getByTitle('New AI chat'))
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders empty state without context', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
|
||||
@@ -153,6 +159,20 @@ describe('AiPanel', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('focuses the panel shell when reopening with existing messages', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockMessages = [{
|
||||
userMessage: 'Remember this',
|
||||
actions: [],
|
||||
response: 'Still here.',
|
||||
id: 'msg-3',
|
||||
}]
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(document.activeElement).toBe(screen.getByTestId('ai-panel'))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed while panel has focus', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
|
||||
import { type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
@@ -32,29 +32,31 @@ interface AiPanelProps {
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
export function AiPanel({
|
||||
interface AiPanelViewProps {
|
||||
controller: AiPanelController
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
}
|
||||
|
||||
export function AiPanelView({
|
||||
controller,
|
||||
onClose,
|
||||
onOpenNote,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: AiPanelProps) {
|
||||
}: AiPanelViewProps) {
|
||||
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
|
||||
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
|
||||
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
@@ -64,24 +66,17 @@ export function AiPanel({
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
handleNewChat,
|
||||
} = controller
|
||||
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
hasMessages: agent.messages.length > 0,
|
||||
isActive,
|
||||
onClose,
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -99,7 +94,13 @@ export function AiPanel({
|
||||
data-testid="ai-panel"
|
||||
data-ai-active={isActive || undefined}
|
||||
>
|
||||
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
agentReady={defaultAiAgentReady}
|
||||
legacyCopy={useLegacyAiExperience}
|
||||
onClose={onClose}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
{activeEntry && (
|
||||
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
|
||||
)}
|
||||
@@ -128,3 +129,48 @@ export function AiPanel({
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({
|
||||
onClose,
|
||||
onOpenNote,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: AiPanelProps) {
|
||||
const controller = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
|
||||
return (
|
||||
<AiPanelView
|
||||
controller={controller}
|
||||
onClose={onClose}
|
||||
onOpenNote={onOpenNote}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
activeEntry={activeEntry}
|
||||
entries={entries}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface AiPanelHeaderProps {
|
||||
agentReady: boolean
|
||||
legacyCopy: boolean
|
||||
onClose: () => void
|
||||
onClear: () => void
|
||||
onNewChat: () => void
|
||||
}
|
||||
|
||||
interface AiPanelContextBarProps {
|
||||
@@ -111,7 +111,7 @@ export function AiPanelHeader({
|
||||
agentReady,
|
||||
legacyCopy,
|
||||
onClose,
|
||||
onClear,
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -132,8 +132,9 @@ export function AiPanelHeader({
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear}
|
||||
title="New conversation"
|
||||
onClick={onNewChat}
|
||||
aria-label="New AI chat"
|
||||
title="New AI chat"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
@@ -245,6 +245,12 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
expect(actions).toBeInTheDocument()
|
||||
expect(actions).toHaveClass('ml-auto')
|
||||
})
|
||||
|
||||
it('does not render the unused backlinks or more-actions placeholders', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.queryByRole('button', { name: 'Backlinks are coming soon' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'More note actions are coming soon' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
|
||||
@@ -8,10 +8,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
GitBranch,
|
||||
Code,
|
||||
CursorText,
|
||||
Sparkle,
|
||||
SlidersHorizontal,
|
||||
DotsThree,
|
||||
Trash,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
@@ -214,14 +212,6 @@ function DiffAction({
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderAction({ copy, children }: { copy: ActionTooltipCopy; children: ReactNode }) {
|
||||
return (
|
||||
<IconActionButton copy={copy} style={DISABLED_ICON_STYLE}>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
|
||||
@@ -481,16 +471,10 @@ function BreadcrumbActions({
|
||||
onToggleDiff={onToggleDiff}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<PlaceholderAction copy={{ label: 'Backlinks are coming soon' }}>
|
||||
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
|
||||
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
|
||||
<PlaceholderAction copy={{ label: 'More note actions are coming soon' }}>
|
||||
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
69
src/components/ConflictResolverModal.extra.test.tsx
Normal file
69
src/components/ConflictResolverModal.extra.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConflictResolverModal } from './ConflictResolverModal'
|
||||
import type { ConflictFileState } from '../hooks/useConflictResolver'
|
||||
|
||||
function renderModal(fileStates: ConflictFileState[], overrides: Record<string, unknown> = {}) {
|
||||
const props = {
|
||||
open: true,
|
||||
fileStates,
|
||||
allResolved: true,
|
||||
committing: false,
|
||||
error: null,
|
||||
onResolveFile: vi.fn(),
|
||||
onOpenInEditor: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
render(<ConflictResolverModal {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('ConflictResolverModal extra coverage', () => {
|
||||
it('supports keyboard navigation and actions across the focused file', () => {
|
||||
const props = renderModal([
|
||||
{ file: 'notes/project.md', resolution: null, resolving: false },
|
||||
{ file: 'notes/plan.md', resolution: null, resolving: false },
|
||||
])
|
||||
|
||||
const list = screen.getByTestId('conflict-file-list')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(list, { key: 'T' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'theirs')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(list, { key: 'k' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'ours')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'o' })
|
||||
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'Enter' })
|
||||
expect(props.onCommit).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(list, { key: 'Escape' })
|
||||
expect(props.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores binary open shortcuts, resolving rows, and modifier-assisted actions', () => {
|
||||
const props = renderModal([
|
||||
{ file: 'images/photo.png', resolution: null, resolving: false },
|
||||
{ file: 'notes/plan.md', resolution: null, resolving: true },
|
||||
], { allResolved: false })
|
||||
|
||||
const list = screen.getByTestId('conflict-file-list')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'o' })
|
||||
expect(props.onOpenInEditor).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.focus(screen.getByTestId('conflict-file-notes/plan.md'))
|
||||
fireEvent.keyDown(list, { key: 'K' })
|
||||
fireEvent.keyDown(list, { key: 'T', ctrlKey: true })
|
||||
|
||||
expect(props.onResolveFile).not.toHaveBeenCalled()
|
||||
expect(props.onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal file
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConflictFileState } from '../hooks/useConflictResolver'
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
open ? (
|
||||
<div data-testid="dialog-root">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>close</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
DialogContent: ({
|
||||
children,
|
||||
onKeyDown,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
}) => (
|
||||
<div role="dialog" tabIndex={0} onKeyDown={onKeyDown}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||
}))
|
||||
|
||||
import { ConflictResolverModal } from './ConflictResolverModal'
|
||||
|
||||
function makeFileStates(overrides?: Partial<ConflictFileState>[]): ConflictFileState[] {
|
||||
const defaults: ConflictFileState[] = [
|
||||
{ file: 'notes/project.md', resolution: null, resolving: false },
|
||||
{ file: 'notes/plan.md', resolution: null, resolving: false },
|
||||
]
|
||||
if (!overrides) return defaults
|
||||
return defaults.map((state, index) => ({ ...state, ...(overrides[index] ?? {}) }))
|
||||
}
|
||||
|
||||
function renderModal(overrides: Partial<React.ComponentProps<typeof ConflictResolverModal>> = {}) {
|
||||
const props: React.ComponentProps<typeof ConflictResolverModal> = {
|
||||
open: true,
|
||||
fileStates: makeFileStates(),
|
||||
allResolved: false,
|
||||
committing: false,
|
||||
error: null,
|
||||
onResolveFile: vi.fn(),
|
||||
onOpenInEditor: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
render(<ConflictResolverModal {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('ConflictResolverModal keyboard behavior', () => {
|
||||
it('navigates rows with the keyboard and routes shortcuts to the focused file', () => {
|
||||
const props = renderModal()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(dialog, { key: 'k' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'ours')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(dialog, { key: 'T' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'theirs')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'o' })
|
||||
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
|
||||
})
|
||||
|
||||
it('commits on Enter when all files are resolved and closes through Escape and open-change callbacks', () => {
|
||||
const props = renderModal({ allResolved: true })
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Enter' })
|
||||
expect(props.onCommit).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dialog-close'))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores shortcut variants that should be blocked', () => {
|
||||
const props = renderModal({
|
||||
fileStates: [{ file: 'images/photo.png', resolution: null, resolving: true }],
|
||||
})
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'k' })
|
||||
fireEvent.keyDown(dialog, { key: 'o' })
|
||||
fireEvent.keyDown(dialog, { key: 't', metaKey: true })
|
||||
|
||||
expect(props.onResolveFile).not.toHaveBeenCalled()
|
||||
expect(props.onOpenInEditor).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -398,6 +398,66 @@ describe('Editor', () => {
|
||||
|
||||
resetVaultConfigStore()
|
||||
})
|
||||
|
||||
it('updates the open raw editor when tab content changes externally', async () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
editor_mode: null,
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
inbox: null,
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
|
||||
const rawToggleRef = { current: (() => {}) as () => void }
|
||||
const initialContent = '---\nowner: [[Alice]]\nstatus: Active\n---\n\n# Test Project\n\nBody.\n'
|
||||
const updatedContent = '---\nowner: [[Bob]]\nstatus: Active\n---\n\n# Test Project\n\nBody.\n'
|
||||
const initialTab = { entry: mockEntry, content: initialContent }
|
||||
const updatedTab = { entry: mockEntry, content: updatedContent }
|
||||
|
||||
const { rerender } = render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[initialTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
entries={[mockEntry]}
|
||||
rawToggleRef={rawToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(typeof rawToggleRef.current).toBe('function')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await rawToggleRef.current()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('owner: [[Alice]]')
|
||||
})
|
||||
|
||||
rerender(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[updatedTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
entries={[mockEntry]}
|
||||
rawToggleRef={rawToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('owner: [[Bob]]')
|
||||
})
|
||||
|
||||
resetVaultConfigStore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPendingRawExitContent', () => {
|
||||
@@ -528,6 +588,37 @@ describe('click empty editor space', () => {
|
||||
container.removeChild(editableDiv)
|
||||
})
|
||||
|
||||
it('restores the cursor to the H1 when clicking the title block', async () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
mockEditor.document = [
|
||||
{ id: 'title', type: 'heading', content: [{ type: 'text', text: 'Alpha Project', styles: {} }], props: { level: 1 }, children: [] },
|
||||
{ id: 'body', type: 'paragraph', content: [], props: {}, children: [] },
|
||||
]
|
||||
|
||||
render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
||||
)
|
||||
|
||||
const container = document.querySelector('.editor__blocknote-container')!
|
||||
const editableDiv = document.createElement('div')
|
||||
editableDiv.setAttribute('contenteditable', 'true')
|
||||
const heading = document.createElement('h1')
|
||||
heading.textContent = 'Alpha Project'
|
||||
heading.setAttribute('data-content-type', 'heading')
|
||||
heading.setAttribute('data-level', '1')
|
||||
editableDiv.appendChild(heading)
|
||||
container.appendChild(editableDiv)
|
||||
|
||||
fireEvent.click(heading)
|
||||
await act(() => Promise.resolve())
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('title', 'end')
|
||||
expect(mockEditor.focus).toHaveBeenCalled()
|
||||
|
||||
container.removeChild(editableDiv)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('archived note behavior', () => {
|
||||
@@ -598,6 +689,12 @@ describe('wikilink autocomplete', () => {
|
||||
expect(mockFilterSuggestionItems).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes BlockNote trigger-prefixed wikilink queries before filtering', async () => {
|
||||
renderWithEntries()
|
||||
const items = await capturedGetItems!('[[Al')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('limits results to MAX_RESULTS (20)', async () => {
|
||||
// Create many entries that will all match
|
||||
const manyEntries = Array.from({ length: 50 }, (_, i) => ({
|
||||
@@ -633,7 +730,7 @@ describe('wikilink autocomplete', () => {
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test' } },
|
||||
' ',
|
||||
])
|
||||
], { updateSelection: true })
|
||||
})
|
||||
|
||||
it('deduplicates entries with the same path', async () => {
|
||||
@@ -790,7 +887,7 @@ describe('person @mention autocomplete', () => {
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
|
||||
' ',
|
||||
])
|
||||
], { updateSelection: true })
|
||||
})
|
||||
|
||||
it('shows Person type badge on results', async () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import { AiPanelView } from './AiPanel'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
|
||||
interface EditorRightPanelProps {
|
||||
showAIChat?: boolean
|
||||
@@ -43,26 +46,45 @@ export function EditorRightPanel({
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
const aiPanelController = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
activeEntry: inspectorEntry,
|
||||
activeNoteContent: inspectorContent,
|
||||
entries,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
const { handleNewChat } = aiPanelController
|
||||
|
||||
useEffect(() => {
|
||||
const handleRequestedNewChat = () => {
|
||||
handleNewChat()
|
||||
}
|
||||
|
||||
window.addEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
|
||||
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
|
||||
}, [handleNewChat])
|
||||
|
||||
if (showAIChat) {
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 flex flex-col min-h-0"
|
||||
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
|
||||
>
|
||||
<AiPanel
|
||||
<AiPanelView
|
||||
controller={aiPanelController}
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
activeNoteContent={inspectorContent}
|
||||
entries={entries}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -451,8 +451,8 @@ Status: Active
|
||||
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides referenced-by section when no entries reference the current note', () => {
|
||||
@@ -559,7 +559,7 @@ Status: Active
|
||||
/>
|
||||
)
|
||||
// noteA shows in Referenced By (via Belongs to)
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { TooltipProvider } from './ui/tooltip'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
@@ -265,17 +268,29 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
describe('relation editing', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const renderEditableRelationships = (
|
||||
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
|
||||
) => renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
...overrides,
|
||||
})
|
||||
const openInlineAdd = (value?: string) => {
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
if (value !== undefined) {
|
||||
fireEvent.change(input, { target: { value } })
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows remove buttons on relation refs when editing is enabled', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
renderEditableRelationships()
|
||||
expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -285,91 +300,77 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
})
|
||||
|
||||
it('calls onDeleteProperty when removing the last ref in a group', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
renderEditableRelationships()
|
||||
fireEvent.click(screen.getByTestId('remove-relation-ref'))
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to')
|
||||
})
|
||||
|
||||
it('calls onUpdateProperty with remaining refs when removing one of many', () => {
|
||||
renderRelationshipsPanel({
|
||||
it.each([
|
||||
{
|
||||
name: 'calls onUpdateProperty with remaining refs when removing one of many',
|
||||
frontmatter: { 'Related to': ['[[project/my-project]]', '[[topic/ai]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
const removeButtons = screen.getAllByTestId('remove-relation-ref')
|
||||
fireEvent.click(removeButtons[0]) // Remove first ref
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
|
||||
})
|
||||
|
||||
it('calls onUpdateProperty with string when two refs become one', () => {
|
||||
renderRelationshipsPanel({
|
||||
expectedKey: 'Related to',
|
||||
expectedValue: '[[topic/ai]]',
|
||||
},
|
||||
{
|
||||
name: 'calls onUpdateProperty with string when two refs become one',
|
||||
frontmatter: { Has: ['[[project/my-project]]', '[[topic/ai]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
expectedKey: 'Has',
|
||||
expectedValue: '[[topic/ai]]',
|
||||
},
|
||||
])('$name', ({ frontmatter, expectedKey, expectedValue }) => {
|
||||
renderEditableRelationships({ frontmatter })
|
||||
const removeButtons = screen.getAllByTestId('remove-relation-ref')
|
||||
fireEvent.click(removeButtons[0])
|
||||
// Should pass a single string, not an array of one
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Has', '[[topic/ai]]')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith(expectedKey, expectedValue)
|
||||
})
|
||||
|
||||
it('shows inline add button for each relationship group when editing is enabled', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
renderEditableRelationships()
|
||||
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens inline add input when add button clicked', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
renderEditableRelationships()
|
||||
openInlineAdd()
|
||||
expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a note to an existing relationship via inline add', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd('AI')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
})
|
||||
|
||||
it('clicks a search result to add the relationship and close the inline editor', () => {
|
||||
renderEditableRelationships()
|
||||
openInlineAdd('AI')
|
||||
fireEvent.click(screen.getByText('AI'))
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not add duplicate refs', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[topic/ai]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
|
||||
const input = openInlineAdd('AI')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores empty inline-add Enter presses', () => {
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes inline add on Escape', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
|
||||
@@ -380,6 +381,21 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
|
||||
const renderInlineCreatePanel = (
|
||||
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
|
||||
) => renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
...overrides,
|
||||
})
|
||||
const openInlineCreate = (value: string) => {
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value } })
|
||||
return input
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -387,43 +403,22 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
})
|
||||
|
||||
it('shows "Create & open" option when typed title does not match any note', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('Brand New Note')
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when typed title matches an existing note', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('AI')
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('Brand New Note')
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
@@ -433,15 +428,8 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
|
||||
it('does not add wikilink when note creation fails', async () => {
|
||||
onCreateAndOpenNote.mockResolvedValue(false)
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Failing Note' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('Failing Note')
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
|
||||
// Give async handler time to resolve
|
||||
@@ -453,29 +441,16 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
})
|
||||
|
||||
it('shows both existing matches and "Create & open" for partial matches', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
// "My" partially matches "My Project" but is not an exact match
|
||||
fireEvent.change(input, { target: { value: 'My' } })
|
||||
renderInlineCreatePanel()
|
||||
// "My" partially matches "My Project" but is not an exact match.
|
||||
openInlineCreate('My')
|
||||
// Should show search results AND create option
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
renderInlineCreatePanel({ onCreateAndOpenNote: undefined })
|
||||
openInlineCreate('Brand New Note')
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -498,6 +473,40 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits from the relationship-name input on Enter and cancels the form from the note input on Escape', () => {
|
||||
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Relationship name'), { key: 'Enter' })
|
||||
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[topic/ai]]')
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Note title'), { key: 'Escape' })
|
||||
expect(screen.queryByPlaceholderText('Relationship name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the target dropdown after blur once the grace timeout expires', () => {
|
||||
vi.useFakeTimers()
|
||||
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
|
||||
const noteInput = screen.getByPlaceholderText('Note title')
|
||||
fireEvent.focus(noteInput)
|
||||
fireEvent.change(noteInput, { target: { value: 'New Person' } })
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
fireEvent.blur(noteInput)
|
||||
vi.advanceTimersByTime(150)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('creates note and adds relationship via form', async () => {
|
||||
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
@@ -594,11 +603,15 @@ describe('ReferencedByPanel', () => {
|
||||
]
|
||||
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByTestId('derived-relationships-separator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Write Essays')).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Derived relationships')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Read-only groups sourced from other notes.')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking a referenced-by entry', () => {
|
||||
@@ -618,6 +631,17 @@ describe('ReferencedByPanel', () => {
|
||||
expect(screen.getByTitle('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds an inverse-relationship tooltip to each derived relationship label', async () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/a.md', title: 'Child Note' }), viaKey: 'Belongs to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
|
||||
|
||||
fireEvent.focus(screen.getByText('Children'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('GitHistoryPanel', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
@@ -56,20 +56,33 @@ describe('NoteList virtualized datasets', () => {
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters large datasets by search query', () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
|
||||
makeIndexedEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
it('filters large datasets by search query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 298 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
|
||||
makeIndexedEntry(299, { title: 'Beta Strategy' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
||||
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('sorts large datasets correctly', () => {
|
||||
|
||||
@@ -84,10 +84,16 @@ function renderManagedViewNoteList({
|
||||
}
|
||||
}
|
||||
|
||||
function searchNoteList(query: string) {
|
||||
async function searchNoteList(query: string) {
|
||||
const searchInput = screen.queryByPlaceholderText('Search notes...')
|
||||
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
function renderBookNoteList({
|
||||
@@ -117,11 +123,11 @@ function renderBookNoteList({
|
||||
})
|
||||
}
|
||||
|
||||
function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
searchNoteList(matchingQuery)
|
||||
async function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
await searchNoteList(matchingQuery)
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
|
||||
searchNoteList(hiddenQuery)
|
||||
await searchNoteList(hiddenQuery)
|
||||
expect(screen.queryByText(title)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
}
|
||||
@@ -181,7 +187,7 @@ describe('NoteList rendering', () => {
|
||||
it('shows referenced-by groups for topic entities', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced By')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the search input from the header action', () => {
|
||||
@@ -191,14 +197,14 @@ describe('NoteList rendering', () => {
|
||||
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by a case-insensitive search query', () => {
|
||||
it('filters by a case-insensitive search query', async () => {
|
||||
renderNoteList()
|
||||
searchNoteList('facebook')
|
||||
await searchNoteList('facebook')
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by snippet text when the title does not match', () => {
|
||||
it('filters by snippet text when the title does not match', async () => {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
||||
@@ -206,13 +212,13 @@ describe('NoteList rendering', () => {
|
||||
],
|
||||
})
|
||||
|
||||
searchNoteList('nebula-only')
|
||||
await searchNoteList('nebula-only')
|
||||
|
||||
expect(screen.getByText('Beta Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by visible property values and ignores hidden properties', () => {
|
||||
it('filters by visible property values and ignores hidden properties', async () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Property Search Note',
|
||||
@@ -221,10 +227,10 @@ describe('NoteList rendering', () => {
|
||||
allNotesNoteListProperties: null,
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
await expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
})
|
||||
|
||||
it('uses the active all-notes columns when filtering by visible property values', () => {
|
||||
it('uses the active all-notes columns when filtering by visible property values', async () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Override Search Note',
|
||||
@@ -233,7 +239,7 @@ describe('NoteList rendering', () => {
|
||||
allNotesNoteListProperties: ['Owner'],
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
await expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
})
|
||||
|
||||
it('sorts entries by last modified descending by default', () => {
|
||||
@@ -289,6 +295,35 @@ describe('NoteList rendering', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the note-list search input full width and shows only an inline spinner while loading', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Strategy' }),
|
||||
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note' }),
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'strategy' } })
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
expect(searchInput).toHaveClass('pr-8')
|
||||
expect(searchInput.parentElement).toHaveClass('relative', 'flex-1')
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Searching...')).not.toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(180)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows backlinks from outgoing links in entity view', () => {
|
||||
const entriesWithBacklink = mockEntries.map((entry) =>
|
||||
entry.path === mockEntries[2].path ? { ...entry, outgoingLinks: ['Build Laputa App'] } : entry,
|
||||
@@ -318,11 +353,11 @@ describe('NoteList rendering', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps existing neighborhood groups visible at zero after search filters them out', () => {
|
||||
it('keeps existing neighborhood groups visible at zero after search filters them out', async () => {
|
||||
const parent = makeEntry({
|
||||
path: '/vault/parent.md',
|
||||
filename: 'parent.md',
|
||||
@@ -344,11 +379,11 @@ describe('NoteList rendering', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
|
||||
|
||||
searchNoteList('missing-neighborhood-match')
|
||||
await searchNoteList('missing-neighborhood-match')
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -375,7 +410,7 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Related to')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced By')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
|
||||
})
|
||||
|
||||
|
||||
325
src/components/PropertyValueCells.extra.test.tsx
Normal file
325
src/components/PropertyValueCells.extra.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
|
||||
|
||||
const { createValueButtonMock } = vi.hoisted(() => ({
|
||||
createValueButtonMock:
|
||||
(testId: string, nextValue: (value: string) => string) =>
|
||||
({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid={testId} onClick={() => onSave(nextValue(value))}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./EditableValue', () => ({
|
||||
EditableValue: createValueButtonMock('editable-value', (value) => `${value}-saved`),
|
||||
TagPillList: ({
|
||||
items,
|
||||
label,
|
||||
onSave,
|
||||
}: {
|
||||
items: string[]
|
||||
label: string
|
||||
onSave: (items: string[]) => void
|
||||
}) => (
|
||||
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'omega'])}>
|
||||
{label}:{items.join(',')}
|
||||
</button>
|
||||
),
|
||||
UrlValue: createValueButtonMock('url-value', (value) => `${value}/updated`),
|
||||
}))
|
||||
|
||||
vi.mock('./StatusDropdown', () => ({
|
||||
StatusDropdown: ({
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
onSave: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<button data-testid="status-save" onClick={() => onSave('Done')}>save</button>
|
||||
<button data-testid="status-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./TagsDropdown', () => ({
|
||||
TagsDropdown: ({
|
||||
onToggle,
|
||||
onClose,
|
||||
}: {
|
||||
onToggle: (tag: string) => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="tags-dropdown">
|
||||
<button data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
|
||||
<button data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button>
|
||||
<button data-testid="tags-close" onClick={onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./ColorInput', () => ({
|
||||
ColorEditableValue: createValueButtonMock('color-value', (value) => value.toUpperCase()),
|
||||
}))
|
||||
|
||||
vi.mock('./IconEditableValue', () => ({
|
||||
IconEditableValue: createValueButtonMock('icon-value', (value) => `${value}-icon`),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/calendar', () => ({
|
||||
Calendar: ({ onSelect }: { onSelect: (value?: Date) => void }) => (
|
||||
<div>
|
||||
<button data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
|
||||
pick
|
||||
</button>
|
||||
<button data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
|
||||
empty
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({
|
||||
children,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
<button data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
|
||||
</div>
|
||||
),
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function makeRect(right: number, bottom: number): DOMRect {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 24,
|
||||
height: 24,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right,
|
||||
bottom,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
describe('PropertyValueCells extra', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes relationship property keys and positions the display-mode menu within the viewport', () => {
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue(makeRect(100, 20))
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DisplayModeSelector
|
||||
propKey=" Belongs-To "
|
||||
currentMode="text"
|
||||
autoMode="text"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
|
||||
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-menu').style.left).toBe('8px')
|
||||
expect(screen.getByTestId('display-mode-menu').style.top).toBe('24px')
|
||||
|
||||
const backdrop = Array.from(document.body.querySelectorAll('div')).find(
|
||||
(node) => node.className === 'fixed inset-0 z-[12000]',
|
||||
)
|
||||
fireEvent.click(backdrop as HTMLDivElement)
|
||||
expect(screen.queryByTestId('display-mode-menu')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-date'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(' Belongs-To ', 'date')
|
||||
rectSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('closes empty date pickers without saving and ignores undefined selections', () => {
|
||||
const onSave = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Due"
|
||||
value=""
|
||||
displayMode="date"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker-empty'))
|
||||
fireEvent.click(screen.getByTestId('popover-close'))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('renders number displays, restores invalid input on escape, and falls back to editable text', () => {
|
||||
const onSave = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('number-display'))
|
||||
expect(onStartEdit).toHaveBeenCalledWith('Count')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'oops' } })
|
||||
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Escape' })
|
||||
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Title"
|
||||
value="Plain text"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('editable-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('Title', 'Plain text-saved')
|
||||
})
|
||||
|
||||
it('handles string booleans, tag toggles, and tag removals', () => {
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Published"
|
||||
value="false"
|
||||
displayMode="boolean"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(onUpdate).toHaveBeenCalledWith('Published', true)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Archived"
|
||||
value={0}
|
||||
displayMode="boolean"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Tags"
|
||||
value={['alpha']}
|
||||
displayMode="tags"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={['alpha', 'beta']}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('tags-toggle-alpha'))
|
||||
fireEvent.click(screen.getByTitle('Remove alpha'))
|
||||
fireEvent.click(screen.getByTestId('tags-add-button'))
|
||||
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
|
||||
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
|
||||
expect(onStartEdit).toHaveBeenCalledWith('Tags')
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Category"
|
||||
value="solo"
|
||||
displayMode="tags"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={['solo', 'beta']}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
|
||||
|
||||
expect(onSaveList).toHaveBeenCalledWith('Category', ['solo', 'beta'])
|
||||
})
|
||||
})
|
||||
390
src/components/PropertyValueCells.test.tsx
Normal file
390
src/components/PropertyValueCells.test.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() => {
|
||||
const renderMockActionMenu = (
|
||||
containerTestId: string,
|
||||
actions: Array<{ testId: string; label: string; onClick: () => void }>,
|
||||
) => (
|
||||
<div data-testid={containerTestId}>
|
||||
{actions.map(({ testId, label, onClick }) => (
|
||||
<button key={testId} data-testid={testId} onClick={onClick}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const createDropdownModule = (
|
||||
exportName: string,
|
||||
containerTestId: string,
|
||||
createActions: (callbacks: Record<string, (...args: never[]) => void>) => Array<{
|
||||
testId: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}>,
|
||||
) => ({
|
||||
[exportName]: (callbacks: Record<string, (...args: never[]) => void>) =>
|
||||
renderMockActionMenu(containerTestId, createActions(callbacks)),
|
||||
})
|
||||
|
||||
const createPrimarySecondaryActions = (options: {
|
||||
primary: { testId: string; label: string; onClick: () => void }
|
||||
secondary: { testId: string; label: string; onClick: () => void }
|
||||
}) => [options.primary, options.secondary]
|
||||
|
||||
return { createDropdownModule, createPrimarySecondaryActions }
|
||||
})
|
||||
|
||||
vi.mock('./EditableValue', () => ({
|
||||
EditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="editable-value" onClick={() => onSave(`${value}-saved`)}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
TagPillList: ({
|
||||
items,
|
||||
label,
|
||||
onSave,
|
||||
}: {
|
||||
items: string[]
|
||||
label: string
|
||||
onSave: (items: string[]) => void
|
||||
}) => (
|
||||
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'gamma'])}>
|
||||
{label}:{items.join(',')}
|
||||
</button>
|
||||
),
|
||||
UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="url-value" onClick={() => onSave('https://saved.example')}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./StatusDropdown', () =>
|
||||
createDropdownModule('StatusDropdown', 'status-dropdown', ({ onSave, onCancel }) =>
|
||||
createPrimarySecondaryActions({
|
||||
primary: { testId: 'status-save', label: 'save', onClick: () => onSave('Done') },
|
||||
secondary: { testId: 'status-cancel', label: 'cancel', onClick: onCancel },
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('./TagsDropdown', () =>
|
||||
createDropdownModule('TagsDropdown', 'tags-dropdown', ({ onToggle, onClose }) =>
|
||||
createPrimarySecondaryActions({
|
||||
primary: { testId: 'tags-toggle', label: 'toggle', onClick: () => onToggle('beta') },
|
||||
secondary: { testId: 'tags-close', label: 'close', onClick: onClose },
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('./ColorInput', () => ({
|
||||
ColorEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="color-value" onClick={() => onSave('#00ff00')}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./IconEditableValue', () => ({
|
||||
IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="icon-value" onClick={() => onSave('sparkles')}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/calendar', () => ({
|
||||
Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => (
|
||||
<button
|
||||
data-testid="date-picker-calendar"
|
||||
onClick={() => onSelect(new Date(2026, 3, 22))}
|
||||
>
|
||||
pick
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||
}))
|
||||
|
||||
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
|
||||
|
||||
describe('PropertyValueCells', () => {
|
||||
it('shows the relationship icon and resets to auto mode when the auto option is selected', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DisplayModeSelector
|
||||
propKey="Related to"
|
||||
currentMode="text"
|
||||
autoMode="number"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-number'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('Related to', null)
|
||||
})
|
||||
|
||||
it('selects explicit display modes from the menu', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DisplayModeSelector
|
||||
propKey="Status"
|
||||
currentMode="status"
|
||||
autoMode="text"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-date'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('Status', 'date')
|
||||
})
|
||||
|
||||
it('handles status and tags editing interactions', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Status"
|
||||
value="Doing"
|
||||
displayMode="status"
|
||||
isEditing={true}
|
||||
vaultStatuses={['Doing', 'Done']}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-save'))
|
||||
fireEvent.click(screen.getByTestId('status-cancel'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('Status', 'Done')
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Tags"
|
||||
value={['alpha']}
|
||||
displayMode="tags"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={['alpha', 'beta']}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Remove alpha'))
|
||||
fireEvent.click(screen.getByTestId('tags-add-button'))
|
||||
fireEvent.click(screen.getByTestId('tags-toggle'))
|
||||
fireEvent.click(screen.getByTestId('tags-close'))
|
||||
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
|
||||
expect(onStartEdit).toHaveBeenCalledWith('Tags')
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('covers number and date editing branches', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '' } })
|
||||
fireEvent.blur(screen.getByTestId('number-input'))
|
||||
expect(onSave).toHaveBeenCalledWith('Count', '')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '19' } })
|
||||
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('Count', '19')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'nope' } })
|
||||
fireEvent.blur(screen.getByTestId('number-input'))
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Due"
|
||||
value="2026-04-20"
|
||||
displayMode="date"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker-calendar'))
|
||||
expect(onSave).toHaveBeenCalledWith('Due', '2026-04-22')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Due"
|
||||
value="2026-04-20"
|
||||
displayMode="date"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker-clear'))
|
||||
expect(onSave).toHaveBeenCalledWith('Due', '')
|
||||
})
|
||||
|
||||
it('auto-detects scalar display modes and delegates array values correctly', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Flag"
|
||||
value={true}
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onUpdate).toHaveBeenCalledWith('Flag', false)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Website"
|
||||
value="https://example.com"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('url-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('Website', 'https://saved.example')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Accent"
|
||||
value="#ff0000"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('color-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('Accent', '#00ff00')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="_icon"
|
||||
value="spark"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('icon-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('_icon', 'sparkles')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Labels"
|
||||
value={['alpha', 'beta']}
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('tag-pill-list'))
|
||||
expect(onSaveList).toHaveBeenCalledWith('Labels', ['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
})
|
||||
315
src/components/RawEditorView.behavior.test.tsx
Normal file
315
src/components/RawEditorView.behavior.test.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MutableRefObject } from 'react'
|
||||
|
||||
const {
|
||||
buildRawEditorAutocompleteStateMock,
|
||||
buildRawEditorBaseItemsMock,
|
||||
buildTypeEntryMapMock,
|
||||
detectYamlErrorMock,
|
||||
extractWikilinkQueryMock,
|
||||
getRawEditorDropdownPositionMock,
|
||||
noteSearchListState,
|
||||
replaceActiveWikilinkQueryMock,
|
||||
trackEventMock,
|
||||
useCodeMirrorMock,
|
||||
viewRefState,
|
||||
} = vi.hoisted(() => ({
|
||||
buildRawEditorAutocompleteStateMock: vi.fn(),
|
||||
buildRawEditorBaseItemsMock: vi.fn(),
|
||||
buildTypeEntryMapMock: vi.fn(),
|
||||
detectYamlErrorMock: vi.fn(),
|
||||
extractWikilinkQueryMock: vi.fn(),
|
||||
getRawEditorDropdownPositionMock: vi.fn(),
|
||||
noteSearchListState: { lastProps: null as null | Record<string, unknown> },
|
||||
replaceActiveWikilinkQueryMock: vi.fn(),
|
||||
trackEventMock: vi.fn(),
|
||||
useCodeMirrorMock: vi.fn(),
|
||||
viewRefState: { current: null as null | Record<string, unknown> },
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useCodeMirror', () => ({
|
||||
useCodeMirror: useCodeMirrorMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/rawEditorUtils', () => ({
|
||||
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
|
||||
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
|
||||
detectYamlError: detectYamlErrorMock,
|
||||
extractWikilinkQuery: extractWikilinkQueryMock,
|
||||
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
|
||||
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/typeColors', () => ({
|
||||
buildTypeEntryMap: buildTypeEntryMapMock,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
vi.mock('./NoteSearchList', () => ({
|
||||
NoteSearchList: (props: {
|
||||
items: Array<{ title: string; onItemClick: () => void }>
|
||||
onItemClick: (item: { title: string; onItemClick: () => void }) => void
|
||||
onItemHover: (index: number) => void
|
||||
selectedIndex: number
|
||||
}) => {
|
||||
noteSearchListState.lastProps = props
|
||||
return (
|
||||
<div data-testid="note-search-list">
|
||||
{props.items.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
data-testid={`note-search-item-${index}`}
|
||||
onMouseEnter={() => props.onItemHover(index)}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
<div data-testid="note-search-selected-index">{props.selectedIndex}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
path,
|
||||
filename: `${title}.md`,
|
||||
title,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockView(docText = '[[Target') {
|
||||
return {
|
||||
state: {
|
||||
doc: { toString: () => docText },
|
||||
selection: { main: { head: docText.length } },
|
||||
},
|
||||
dispatch: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('RawEditorView behavior coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
noteSearchListState.lastProps = null
|
||||
viewRefState.current = createMockView()
|
||||
useCodeMirrorMock.mockImplementation((_containerRef: unknown, _content: string, callbacks: unknown) => {
|
||||
useCodeMirrorMock.mock.calls[useCodeMirrorMock.mock.calls.length - 1]![2] = callbacks
|
||||
return viewRefState
|
||||
})
|
||||
buildRawEditorBaseItemsMock.mockReturnValue([{ title: 'Base item' }])
|
||||
buildTypeEntryMapMock.mockReturnValue({ Note: { title: 'Note' } })
|
||||
detectYamlErrorMock.mockImplementation((doc: string) => (
|
||||
doc.includes('broken') ? 'Broken YAML' : null
|
||||
))
|
||||
extractWikilinkQueryMock.mockReturnValue(null)
|
||||
getRawEditorDropdownPositionMock.mockReturnValue({ top: 12, left: 34 })
|
||||
replaceActiveWikilinkQueryMock.mockReturnValue({
|
||||
text: '[[Inserted]]',
|
||||
cursor: 12,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('debounces content changes, exposes the latest content ref, flushes saves, and cleans up on unmount', () => {
|
||||
const onContentChange = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const latestContentRef = { current: null } as MutableRefObject<string | null>
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<RawEditorView
|
||||
content="---\ntitle: Start\n---"
|
||||
path="/vault/a.md"
|
||||
entries={[entry('Alpha')]}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
latestContentRef={latestContentRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
|
||||
onDocChange: (doc: string) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
act(() => {
|
||||
callbacks.onDocChange('broken content')
|
||||
})
|
||||
|
||||
expect(latestContentRef.current).toBe('broken content')
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Broken YAML')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', 'broken content')
|
||||
|
||||
rerender(
|
||||
<RawEditorView
|
||||
content="fixed"
|
||||
path="/vault/b.md"
|
||||
entries={[entry('Alpha')]}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
latestContentRef={latestContentRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
callbacks.onDocChange('pending change')
|
||||
callbacks.onSave()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'pending change')
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
callbacks.onDocChange('flush on unmount')
|
||||
})
|
||||
unmount()
|
||||
|
||||
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'flush on unmount')
|
||||
})
|
||||
|
||||
it('opens the autocomplete dropdown, updates selection, inserts wikilinks, and tracks the insert event', () => {
|
||||
extractWikilinkQueryMock.mockReturnValue('alp')
|
||||
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
|
||||
items: [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
|
||||
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
}))
|
||||
const onContentChange = vi.fn()
|
||||
const mockView = createMockView('[[alp')
|
||||
viewRefState.current = mockView
|
||||
|
||||
render(
|
||||
<RawEditorView
|
||||
content="[[alp"
|
||||
path="/vault/a.md"
|
||||
entries={[entry('Alpha'), entry('Beta')]}
|
||||
onContentChange={onContentChange}
|
||||
onSave={vi.fn()}
|
||||
vaultPath="/vault"
|
||||
/>,
|
||||
)
|
||||
|
||||
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
|
||||
onCursorActivity: (view: unknown) => void
|
||||
onEscape: () => boolean
|
||||
}
|
||||
|
||||
act(() => {
|
||||
callbacks.onCursorActivity(mockView)
|
||||
})
|
||||
|
||||
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
query: 'alp',
|
||||
vaultPath: '/vault',
|
||||
}))
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowDown' })
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowUp' })
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId('note-search-item-1'))
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'Enter' })
|
||||
|
||||
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('[[alp', '[[alp'.length, 'Beta')
|
||||
expect(mockView.dispatch).toHaveBeenCalledWith({
|
||||
changes: { from: 0, to: 5, insert: '[[Inserted]]' },
|
||||
selection: { anchor: 12 },
|
||||
})
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', '[[Inserted]]')
|
||||
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
|
||||
expect(mockView.focus).toHaveBeenCalledTimes(1)
|
||||
expect(callbacks.onEscape()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears autocomplete when the query is too short and reports escape handling while open', () => {
|
||||
extractWikilinkQueryMock
|
||||
.mockReturnValueOnce('a')
|
||||
.mockReturnValueOnce('alpha')
|
||||
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
|
||||
items: [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
}))
|
||||
const mockView = createMockView('[[alpha')
|
||||
viewRefState.current = mockView
|
||||
|
||||
render(
|
||||
<RawEditorView
|
||||
content="[[alpha"
|
||||
path="/vault/a.md"
|
||||
entries={[entry('Alpha')]}
|
||||
onContentChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
let callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
|
||||
onCursorActivity: (view: unknown) => void
|
||||
onEscape: () => boolean
|
||||
}
|
||||
|
||||
act(() => {
|
||||
callbacks.onCursorActivity(mockView)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
|
||||
|
||||
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
|
||||
|
||||
act(() => {
|
||||
callbacks.onCursorActivity(mockView)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
|
||||
expect(callbacks.onEscape()).toBe(true)
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.coverage.test.tsx
Normal file
276
src/components/RawEditorView.coverage.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
trackEventMock,
|
||||
buildTypeEntryMapMock,
|
||||
buildRawEditorBaseItemsMock,
|
||||
detectYamlErrorMock,
|
||||
extractWikilinkQueryMock,
|
||||
buildRawEditorAutocompleteStateMock,
|
||||
getRawEditorDropdownPositionMock,
|
||||
replaceActiveWikilinkQueryMock,
|
||||
useCodeMirrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
buildTypeEntryMapMock: vi.fn(() => new Map()),
|
||||
buildRawEditorBaseItemsMock: vi.fn(() => []),
|
||||
detectYamlErrorMock: vi.fn(() => null),
|
||||
extractWikilinkQueryMock: vi.fn(() => null),
|
||||
buildRawEditorAutocompleteStateMock: vi.fn(),
|
||||
getRawEditorDropdownPositionMock: vi.fn(() => ({ top: 48, left: 96 })),
|
||||
replaceActiveWikilinkQueryMock: vi.fn(),
|
||||
useCodeMirrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
type CodeMirrorCallbacks = {
|
||||
onCursorActivity: (view: unknown) => void
|
||||
onDocChange: (doc: string) => void
|
||||
onEscape: () => boolean
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
let latestCallbacks: CodeMirrorCallbacks | null = null
|
||||
let latestViewRef: MutableRefObject<{
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
focus: ReturnType<typeof vi.fn>
|
||||
state: {
|
||||
doc: { toString: () => string }
|
||||
selection: { main: { head: number } }
|
||||
}
|
||||
} | null>
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/typeColors', () => ({
|
||||
buildTypeEntryMap: buildTypeEntryMapMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/rawEditorUtils', () => ({
|
||||
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
|
||||
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
|
||||
detectYamlError: detectYamlErrorMock,
|
||||
extractWikilinkQuery: extractWikilinkQueryMock,
|
||||
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
|
||||
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useCodeMirror', () => ({
|
||||
useCodeMirror: useCodeMirrorMock,
|
||||
}))
|
||||
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
|
||||
vi.mock('./NoteSearchList', () => ({
|
||||
NoteSearchList: ({
|
||||
items,
|
||||
selectedIndex,
|
||||
onItemClick,
|
||||
onItemHover,
|
||||
}: {
|
||||
items: Array<{ title: string }>
|
||||
selectedIndex: number
|
||||
onItemClick: (item: { title: string }) => void
|
||||
onItemHover: (index: number) => void
|
||||
}) => (
|
||||
<div data-testid="raw-editor-note-search-list">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
data-testid={`autocomplete-item-${index}`}
|
||||
data-selected={index === selectedIndex}
|
||||
onMouseEnter={() => onItemHover(index)}
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function createEntry(title: string) {
|
||||
return {
|
||||
path: `/vault/${title.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
filename: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
title,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
content: '# Raw note',
|
||||
path: '/vault/raw-note.md',
|
||||
entries: [createEntry('Alpha'), createEntry('Beta')],
|
||||
onContentChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
vaultPath: '/vault',
|
||||
}
|
||||
|
||||
function renderView(overrides: Partial<typeof defaultProps> = {}) {
|
||||
const props = { ...defaultProps, ...overrides }
|
||||
render(<RawEditorView {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('RawEditorView additional coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
|
||||
latestViewRef = {
|
||||
current: {
|
||||
dispatch: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
state: {
|
||||
doc: { toString: () => 'Before [[Al' },
|
||||
selection: { main: { head: 11 } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }) => ({
|
||||
selectedIndex: 0,
|
||||
items: [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
|
||||
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
|
||||
],
|
||||
}))
|
||||
replaceActiveWikilinkQueryMock.mockReturnValue({
|
||||
text: 'Before [[Alpha]]',
|
||||
cursor: 15,
|
||||
})
|
||||
useCodeMirrorMock.mockImplementation((_container, _content, callbacks: CodeMirrorCallbacks) => {
|
||||
latestCallbacks = callbacks
|
||||
return latestViewRef
|
||||
})
|
||||
})
|
||||
|
||||
it('debounces content updates, exposes latest content, flushes on save, and flushes pending edits on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
const latestContentRef = { current: null as string | null }
|
||||
const onContentChange = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const { unmount } = render(
|
||||
<RawEditorView
|
||||
{...defaultProps}
|
||||
latestContentRef={latestContentRef}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(latestContentRef.current).toBe('# Raw note')
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onDocChange('draft 1')
|
||||
latestCallbacks?.onDocChange('draft 2')
|
||||
})
|
||||
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
expect(latestContentRef.current).toBe('draft 2')
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTimeAsync(500)
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 2')
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onDocChange('draft 3')
|
||||
latestCallbacks?.onSave()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 3')
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onDocChange('draft 4')
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 4')
|
||||
})
|
||||
|
||||
it('renders YAML errors from the parser result', () => {
|
||||
detectYamlErrorMock.mockReturnValue('Missing closing delimiter')
|
||||
|
||||
renderView()
|
||||
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Missing closing delimiter')
|
||||
})
|
||||
|
||||
it('opens autocomplete from cursor activity, navigates items, inserts a wikilink, and closes on escape', async () => {
|
||||
extractWikilinkQueryMock.mockReturnValue('Al')
|
||||
const onContentChange = vi.fn()
|
||||
renderView({ onContentChange })
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
|
||||
})
|
||||
|
||||
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
|
||||
const presentation = screen.getByRole('presentation')
|
||||
fireEvent.keyDown(presentation, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(presentation, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(presentation, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('Before [[Al', 11, 'Alpha')
|
||||
})
|
||||
|
||||
expect(latestViewRef.current?.dispatch).toHaveBeenCalledWith({
|
||||
changes: { from: 0, to: 'Before [[Al'.length, insert: 'Before [[Alpha]]' },
|
||||
selection: { anchor: 15 },
|
||||
})
|
||||
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'Before [[Alpha]]')
|
||||
expect(latestViewRef.current?.focus).toHaveBeenCalledTimes(1)
|
||||
|
||||
extractWikilinkQueryMock.mockReturnValue(null)
|
||||
act(() => {
|
||||
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
|
||||
})
|
||||
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
|
||||
|
||||
extractWikilinkQueryMock.mockReturnValue('Al')
|
||||
act(() => {
|
||||
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
|
||||
})
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
|
||||
let escaped = false
|
||||
act(() => {
|
||||
escaped = latestCallbacks?.onEscape() ?? false
|
||||
})
|
||||
expect(escaped).toBe(true)
|
||||
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -113,6 +113,16 @@ describe('buildDynamicSections', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Daily Log', isA: 'Journal' },
|
||||
]
|
||||
|
||||
const sections = buildDynamicSections(entries, {})
|
||||
|
||||
expect(sections.map((section) => section.type)).not.toContain('Journal')
|
||||
})
|
||||
|
||||
it('includes Journal when a real Journal type definition exists', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Daily Log', isA: 'journal' },
|
||||
]
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Journal: { ...baseEntry, title: 'Journal', isA: 'Type' },
|
||||
journal: { ...baseEntry, title: 'Journal', isA: 'Type' },
|
||||
@@ -120,7 +130,7 @@ describe('buildDynamicSections', () => {
|
||||
|
||||
const sections = buildDynamicSections(entries, typeEntryMap)
|
||||
|
||||
expect(sections.map((section) => section.type)).not.toContain('Journal')
|
||||
expect(sections.filter((section) => section.type === 'Journal')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -517,11 +517,17 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
describe('type visibility via visible property', () => {
|
||||
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
|
||||
path: `/vault/${title.toLowerCase()}.md`,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Type',
|
||||
const makeSidebarEntry = (overrides: {
|
||||
path: string
|
||||
filename: string
|
||||
title: string
|
||||
isA: string
|
||||
visible?: boolean | null
|
||||
}): VaultEntry => ({
|
||||
path: overrides.path,
|
||||
filename: overrides.filename,
|
||||
title: overrides.title,
|
||||
isA: overrides.isA,
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
@@ -542,11 +548,19 @@ describe('Sidebar', () => {
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible,
|
||||
visible: overrides.visible ?? null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
})
|
||||
|
||||
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => makeSidebarEntry({
|
||||
path: `/vault/${title.toLowerCase()}.md`,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Type',
|
||||
visible,
|
||||
})
|
||||
|
||||
it('hides a section when its Type entry has visible: false', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
@@ -620,6 +634,34 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the sidebar type picker when Journal becomes a real type while the app stays open', () => {
|
||||
const journalEntries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeSidebarEntry({
|
||||
path: '/vault/april-21.md',
|
||||
filename: 'april-21.md',
|
||||
title: 'April 21',
|
||||
isA: 'journal',
|
||||
}),
|
||||
]
|
||||
const { rerender } = render(<Sidebar entries={journalEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Customize sections'))
|
||||
expect(screen.queryByLabelText('Toggle Journals')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Sidebar
|
||||
entries={[...journalEntries, makeTypeEntry('Journal', null)]}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByLabelText('Toggle Journals')).toBeInTheDocument()
|
||||
|
||||
rerender(<Sidebar entries={journalEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByLabelText('Toggle Journals')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves custom section colors in the customize popover', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
|
||||
304
src/components/SingleEditorView.test.tsx
Normal file
304
src/components/SingleEditorView.test.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
capturedToolbarProps: null as null | Record<string, unknown>,
|
||||
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
|
||||
capturedImageDropArgs: null as null | Record<string, unknown>,
|
||||
hoverGuardMock: vi.fn(),
|
||||
imageDropState: { isDragOver: false },
|
||||
linkActivationMock: vi.fn(),
|
||||
wikilinkEntriesRef: { current: [] as VaultEntry[] },
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
},
|
||||
BlockNoteViewRaw: (props: {
|
||||
children?: ReactNode
|
||||
editable?: boolean
|
||||
className?: string
|
||||
formattingToolbar?: boolean
|
||||
slashMenu?: boolean
|
||||
sideMenu?: boolean
|
||||
}) => {
|
||||
const {
|
||||
children,
|
||||
editable,
|
||||
className,
|
||||
formattingToolbar,
|
||||
slashMenu,
|
||||
sideMenu,
|
||||
...restProps
|
||||
} = props
|
||||
void formattingToolbar
|
||||
void slashMenu
|
||||
void sideMenu
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="blocknote-view"
|
||||
data-editable={editable !== false ? 'true' : 'false'}
|
||||
className={className}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SideMenuController: () => <div data-testid="side-menu-controller" />,
|
||||
SuggestionMenuController: (props: Record<string, unknown>) => {
|
||||
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
|
||||
return <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
|
||||
},
|
||||
useCreateBlockNote: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
components: {},
|
||||
}))
|
||||
|
||||
vi.mock('@mantine/core', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
MantineContext: React.createContext(null),
|
||||
MantineProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks/useTheme', () => ({
|
||||
useEditorTheme: () => ({ cssVars: { '--editor-accent': '#abc' } }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useImageDrop', () => ({
|
||||
useImageDrop: (args: Record<string, unknown>) => {
|
||||
state.capturedImageDropArgs = args
|
||||
return state.imageDropState
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils/typeColors', () => ({
|
||||
buildTypeEntryMap: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/wikilinkSuggestions', () => ({
|
||||
MIN_QUERY_LENGTH: 2,
|
||||
deduplicateByPath: <T,>(items: T[]) => items,
|
||||
preFilterWikilinks: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../utils/personMentionSuggestions', () => ({
|
||||
PERSON_MENTION_MIN_QUERY: 1,
|
||||
filterPersonMentions: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../utils/suggestionEnrichment', () => ({
|
||||
attachClickHandlers: <T,>(items: T[]) => items,
|
||||
enrichSuggestionItems: <T,>(items: T[]) => items,
|
||||
}))
|
||||
|
||||
vi.mock('./WikilinkSuggestionMenu', () => ({
|
||||
WikilinkSuggestionMenu: () => <div data-testid="wikilink-suggestion-menu" />,
|
||||
}))
|
||||
|
||||
vi.mock('./editorSchema', () => ({
|
||||
_wikilinkEntriesRef: state.wikilinkEntriesRef,
|
||||
}))
|
||||
|
||||
vi.mock('./blockNoteSideMenuHoverGuard', () => ({
|
||||
useBlockNoteSideMenuHoverGuard: (containerRef: unknown) => state.hoverGuardMock(containerRef),
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormattingConfig', () => ({
|
||||
getTolariaSlashMenuItems: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormatting', () => ({
|
||||
TolariaFormattingToolbar: () => <div data-testid="tolaria-formatting-toolbar" />,
|
||||
TolariaFormattingToolbarController: (props: Record<string, unknown>) => {
|
||||
state.capturedToolbarProps = props
|
||||
return <div data-testid="tolaria-formatting-toolbar-controller" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaBlockNoteSideMenu', () => ({
|
||||
TolariaSideMenu: () => <div data-testid="tolaria-side-menu" />,
|
||||
}))
|
||||
|
||||
vi.mock('./useEditorLinkActivation', () => ({
|
||||
useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown) => (
|
||||
state.linkActivationMock(containerRef, onNavigateWikilink)
|
||||
),
|
||||
}))
|
||||
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/project/alpha.md',
|
||||
filename: 'alpha.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 10,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] }
|
||||
return {
|
||||
document: [
|
||||
{ id: 'heading-block', type: 'heading', content: [], children: [] },
|
||||
cursorBlock,
|
||||
],
|
||||
tryParseMarkdownToBlocks: vi.fn(async () => [
|
||||
{ type: 'table', content: { type: 'tableContent' } },
|
||||
]),
|
||||
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
focus: vi.fn(),
|
||||
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
|
||||
insertBlocks: vi.fn(),
|
||||
insertInlineContent: vi.fn(),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('SingleEditorView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.capturedToolbarProps = null
|
||||
state.capturedSuggestionProps = {}
|
||||
state.capturedImageDropArgs = null
|
||||
state.imageDropState.isDragOver = false
|
||||
state.wikilinkEntriesRef.current = []
|
||||
delete window.__laputaTest
|
||||
})
|
||||
|
||||
it('registers the seeded BlockNote test bridge, applies column widths, and cleans it up on unmount', async () => {
|
||||
const editor = createEditor()
|
||||
const entries = [makeEntry()]
|
||||
const { unmount } = render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={entries}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(state.wikilinkEntriesRef.current).toEqual(entries)
|
||||
expect(typeof window.__laputaTest?.seedBlockNoteTable).toBe('function')
|
||||
|
||||
await act(async () => {
|
||||
await window.__laputaTest?.seedBlockNoteTable?.([120, null, 80])
|
||||
})
|
||||
|
||||
expect(editor.blocksToHTMLLossy).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: 'table',
|
||||
content: expect.objectContaining({
|
||||
type: 'tableContent',
|
||||
columnWidths: [120, null, 80],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ type: 'paragraph' }),
|
||||
])
|
||||
expect(editor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<table>seeded</table>')
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(window.__laputaTest?.seedBlockNoteTable).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the drag overlay and inserts dropped images after the active cursor block', () => {
|
||||
state.imageDropState.isDragOver = true
|
||||
const editor = createEditor()
|
||||
|
||||
render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
vaultPath="/vault"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Drop image here')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
(state.capturedImageDropArgs?.onImageUrl as (url: string) => void)('https://example.com/image.png')
|
||||
})
|
||||
|
||||
expect(editor.insertBlocks).toHaveBeenCalledWith(
|
||||
[{ type: 'image', props: { url: 'https://example.com/image.png' } }],
|
||||
expect.objectContaining({ id: 'cursor-block' }),
|
||||
'after',
|
||||
)
|
||||
})
|
||||
|
||||
it('wires the toolbar mouse guard and suggestion item click handlers', () => {
|
||||
const editor = createEditor()
|
||||
render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(state.hoverGuardMock).toHaveBeenCalledOnce()
|
||||
expect(state.linkActivationMock).toHaveBeenCalledOnce()
|
||||
|
||||
const onMouseDownCapture = (
|
||||
(state.capturedToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } })
|
||||
).elementProps.onMouseDownCapture
|
||||
const menuTrigger = document.createElement('button')
|
||||
menuTrigger.setAttribute('aria-haspopup', 'menu')
|
||||
const menuPreventDefault = vi.fn()
|
||||
onMouseDownCapture({ target: menuTrigger, preventDefault: menuPreventDefault })
|
||||
expect(menuPreventDefault).not.toHaveBeenCalled()
|
||||
|
||||
const normalTarget = document.createElement('div')
|
||||
const normalPreventDefault = vi.fn()
|
||||
onMouseDownCapture({ target: normalTarget, preventDefault: normalPreventDefault })
|
||||
expect(normalPreventDefault).toHaveBeenCalledOnce()
|
||||
|
||||
const onWikiItemClick = vi.fn()
|
||||
const onMentionItemClick = vi.fn()
|
||||
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onWikiItemClick })
|
||||
;(state.capturedSuggestionProps['@'].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onMentionItemClick })
|
||||
|
||||
expect(onWikiItemClick).toHaveBeenCalledOnce()
|
||||
expect(onMentionItemClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -128,6 +128,128 @@ function shouldIgnoreContainerClick(target: HTMLElement) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSuggestionQuery(query: string, triggerCharacter: string): string {
|
||||
return query.startsWith(triggerCharacter)
|
||||
? query.slice(triggerCharacter.length)
|
||||
: query
|
||||
}
|
||||
|
||||
function isSelectionInsideElement(element: HTMLElement): boolean {
|
||||
const selection = window.getSelection()
|
||||
const anchorNode = selection?.anchorNode ?? null
|
||||
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
|
||||
return Boolean(anchorElement && element.contains(anchorElement))
|
||||
}
|
||||
|
||||
function queueTitleHeadingCursorRepair(
|
||||
target: HTMLElement,
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
): boolean {
|
||||
const titleHeading = target.closest<HTMLElement>(
|
||||
'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
|
||||
)
|
||||
if (!titleHeading) return false
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (isSelectionInsideElement(titleHeading)) return
|
||||
|
||||
const firstBlock = editor.document[0]
|
||||
if (firstBlock?.type !== 'heading') return
|
||||
|
||||
editor.setTextCursorPosition(firstBlock.id, 'end')
|
||||
editor.focus()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function useEditorContainerClickHandler(options: {
|
||||
editable: boolean
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
}) {
|
||||
const { editable, editor } = options
|
||||
|
||||
return useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (queueTitleHeadingCursorRepair(target, editor)) return
|
||||
if (shouldIgnoreContainerClick(target)) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
}
|
||||
|
||||
function buildBaseSuggestionItems(entries: VaultEntry[]) {
|
||||
return deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
})))
|
||||
}
|
||||
|
||||
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
return useCallback((target: string) => {
|
||||
editor.insertInlineContent([
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
], { updateSelection: true })
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
}
|
||||
|
||||
function useSuggestionMenuItems(options: {
|
||||
baseItems: ReturnType<typeof buildBaseSuggestionItems>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
insertWikilink: (target: string) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
vaultPath?: string
|
||||
}) {
|
||||
const {
|
||||
baseItems,
|
||||
editor,
|
||||
insertWikilink,
|
||||
typeEntryMap,
|
||||
vaultPath,
|
||||
} = options
|
||||
|
||||
const buildItems = useCallback((query: string, triggerCharacter: '[[' | '@') => {
|
||||
const normalizedQuery = normalizeSuggestionQuery(query, triggerCharacter)
|
||||
const minLength = triggerCharacter === '[[' ? MIN_QUERY_LENGTH : PERSON_MENTION_MIN_QUERY
|
||||
if (normalizedQuery.length < minLength) return null
|
||||
|
||||
const candidates = triggerCharacter === '[['
|
||||
? preFilterWikilinks(baseItems, normalizedQuery)
|
||||
: filterPersonMentions(baseItems, normalizedQuery)
|
||||
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
|
||||
buildItems(query, '[[') ?? []
|
||||
), [buildItems])
|
||||
|
||||
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
|
||||
buildItems(query, '@') ?? []
|
||||
), [buildItems])
|
||||
|
||||
const getSlashMenuItems = useCallback(async (query: string) => (
|
||||
getTolariaSlashMenuItems(editor, query)
|
||||
), [editor])
|
||||
|
||||
return {
|
||||
getWikilinkItems,
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const editorRef = useRef(editor)
|
||||
@@ -150,22 +272,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
}) {
|
||||
const { cssVars } = useEditorTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
useBlockNoteSideMenuHoverGuard(containerRef)
|
||||
useEditorLinkActivation(containerRef, onNavigateWikilink)
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (shouldIgnoreContainerClick(target)) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
|
||||
useEffect(() => {
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
@@ -173,44 +285,19 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
useSeedBlockNoteTableBridge(editor)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries]
|
||||
)
|
||||
|
||||
const insertWikilink = useCallback((target: string) => {
|
||||
editor.insertInlineContent([
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
])
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, query, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < PERSON_MENTION_MIN_QUERY) return []
|
||||
const candidates = filterPersonMentions(baseItems, query)
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, query, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getSlashMenuItems = useCallback(async (query: string) => (
|
||||
getTolariaSlashMenuItems(editor, query)
|
||||
), [editor])
|
||||
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
|
||||
const insertWikilink = useInsertWikilink(editor)
|
||||
const {
|
||||
getWikilinkItems,
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
} = useSuggestionMenuItems({
|
||||
baseItems,
|
||||
editor,
|
||||
insertWikilink,
|
||||
typeEntryMap,
|
||||
vaultPath,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('StatusBar', () => {
|
||||
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exposes a hover-revealed remove action for non-active vaults', () => {
|
||||
it('exposes an in-row, hover-revealed remove action for non-active vaults', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
@@ -237,8 +237,16 @@ describe('StatusBar', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
|
||||
expect(screen.getByTestId('vault-menu-item-Work Vault').className).toContain('hover:bg-[var(--hover)]')
|
||||
expect(screen.getByTestId('vault-menu-remove-Work Vault').className).toContain('group-hover:opacity-100')
|
||||
const item = screen.getByTestId('vault-menu-item-Work Vault')
|
||||
const removeAction = screen.getByTestId('vault-menu-remove-Work Vault')
|
||||
|
||||
expect(item.className).toContain('hover:bg-[var(--hover)]')
|
||||
expect(item.className).toContain('pr-7')
|
||||
expect(removeAction.className).toContain('absolute')
|
||||
expect(removeAction.className).toContain('right-1')
|
||||
expect(removeAction.className).toContain('group-hover:opacity-100')
|
||||
expect(removeAction.className).toContain('group-focus-within:opacity-100')
|
||||
expect(removeAction.className).toContain('pointer-events-none')
|
||||
expect(screen.getByRole('button', { name: 'Remove Work Vault from list' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
59
src/components/StatusDropdown.extra.test.tsx
Normal file
59
src/components/StatusDropdown.extra.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as statusStyles from '../utils/statusStyles'
|
||||
import { StatusDropdown } from './StatusDropdown'
|
||||
|
||||
describe('StatusDropdown extra coverage', () => {
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('wraps keyboard navigation to the create option and scrolls highlighted items into view', () => {
|
||||
const scrollSpy = vi
|
||||
.spyOn(Element.prototype, 'scrollIntoView')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
render(
|
||||
<StatusDropdown
|
||||
value="Active"
|
||||
vaultStatuses={['Doing']}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Needs Review' } })
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalled()
|
||||
expect(onSave).toHaveBeenCalledWith('Needs Review')
|
||||
|
||||
scrollSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens color pickers and persists the selected accent color', () => {
|
||||
const setStatusColorSpy = vi.spyOn(statusStyles, 'setStatusColor')
|
||||
|
||||
render(
|
||||
<StatusDropdown
|
||||
value="Active"
|
||||
vaultStatuses={['Active']}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-color-swatch-Active'))
|
||||
expect(screen.getByTestId('color-picker-Active')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('color-option-green'))
|
||||
|
||||
expect(setStatusColorSpy).toHaveBeenCalledWith('Active', 'green')
|
||||
expect(screen.queryByTestId('color-picker-Active')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
import tolariaIcon from '@/assets/tolaria-icon.svg'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
@@ -30,19 +31,28 @@ describe('WelcomeScreen', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all three option buttons', () => {
|
||||
it('renders the local Tolaria branding icon', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
|
||||
|
||||
const brandIcon = screen.getByAltText('Tolaria icon')
|
||||
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
|
||||
})
|
||||
|
||||
it('shows the onboarding actions in the guided-first order', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
|
||||
const optionButtons = screen.getAllByRole('button')
|
||||
expect(optionButtons[0]).toBe(screen.getByTestId('welcome-create-vault'))
|
||||
expect(optionButtons[1]).toBe(screen.getByTestId('welcome-create-new'))
|
||||
expect(optionButtons[2]).toBe(screen.getByTestId('welcome-open-folder'))
|
||||
})
|
||||
|
||||
it('focuses the first action for keyboard users', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows the simplified template option description', () => {
|
||||
@@ -101,13 +111,13 @@ describe('WelcomeScreen', () => {
|
||||
})
|
||||
|
||||
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
|
||||
const onOpenFolder = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
|
||||
const onCreateEmptyVault = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Tab' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(onOpenFolder).toHaveBeenCalledOnce()
|
||||
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables all buttons while creating', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import tolariaIcon from '@/assets/tolaria-icon.svg'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
mode: 'welcome' | 'vault-missing'
|
||||
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const BRAND_ICON_STYLE: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const TITLE_STYLE: React.CSSProperties = {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
|
||||
): WelcomeScreenPresentation {
|
||||
if (mode === 'welcome') {
|
||||
return {
|
||||
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
|
||||
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>✦</span>,
|
||||
heroBackground: 'transparent',
|
||||
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
|
||||
openFolderLabel: 'Open existing vault',
|
||||
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
|
||||
subtitle: 'Markdown knowledge management for the age of AI',
|
||||
templateDescription: isOffline
|
||||
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
|
||||
: 'Download the getting started vault',
|
||||
@@ -303,18 +310,18 @@ function useWelcomeActionButtons({
|
||||
> & {
|
||||
busy: boolean
|
||||
}) {
|
||||
const primaryActionRef = useRef<HTMLButtonElement>(null)
|
||||
const openFolderActionRef = useRef<HTMLButtonElement>(null)
|
||||
const templateActionRef = useRef<HTMLButtonElement>(null)
|
||||
const createEmptyActionRef = useRef<HTMLButtonElement>(null)
|
||||
const openFolderActionRef = useRef<HTMLButtonElement>(null)
|
||||
const actionButtonRefs = useMemo(
|
||||
() => [primaryActionRef, openFolderActionRef, templateActionRef],
|
||||
() => [templateActionRef, createEmptyActionRef, openFolderActionRef],
|
||||
[],
|
||||
)
|
||||
const actions = useMemo<WelcomeAction[]>(
|
||||
() => ([
|
||||
{ disabled: isOffline, run: onCreateVault },
|
||||
{ disabled: false, run: onCreateEmptyVault },
|
||||
{ disabled: false, run: onOpenFolder },
|
||||
{ disabled: isOffline, run: onCreateVault },
|
||||
]),
|
||||
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
|
||||
)
|
||||
@@ -323,7 +330,7 @@ function useWelcomeActionButtons({
|
||||
if (busy) return
|
||||
|
||||
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
|
||||
// onboarding always starts on "Create empty vault".
|
||||
// onboarding always starts on the guided template flow.
|
||||
focusWelcomeAction(actionButtonRefs, 0)
|
||||
}, [actionButtonRefs, busy, mode])
|
||||
|
||||
@@ -355,9 +362,9 @@ function useWelcomeActionButtons({
|
||||
}, [actionButtonRefs, actions, busy])
|
||||
|
||||
return {
|
||||
primaryActionRef,
|
||||
openFolderActionRef,
|
||||
templateActionRef,
|
||||
createEmptyActionRef,
|
||||
openFolderActionRef,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +382,7 @@ export function WelcomeScreen({
|
||||
}: WelcomeScreenProps) {
|
||||
const busy = creatingAction !== null
|
||||
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
|
||||
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
|
||||
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
|
||||
mode,
|
||||
busy,
|
||||
isOffline,
|
||||
@@ -410,6 +417,21 @@ export function WelcomeScreen({
|
||||
<div style={DIVIDER_STYLE} />
|
||||
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<OptionButton
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light, #F3E8FF)"
|
||||
label="Get started with a template"
|
||||
description={presentation.templateDescription}
|
||||
loadingLabel="Downloading template…"
|
||||
loadingDescription="Cloning the Getting Started vault template"
|
||||
onClick={onCreateVault}
|
||||
disabled={busy || isOffline}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
autoFocus
|
||||
buttonRef={templateActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
|
||||
iconBg="var(--accent-blue-light, #EBF4FF)"
|
||||
@@ -421,8 +443,7 @@ export function WelcomeScreen({
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
testId="welcome-create-new"
|
||||
autoFocus
|
||||
buttonRef={primaryActionRef}
|
||||
buttonRef={createEmptyActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
@@ -435,20 +456,6 @@ export function WelcomeScreen({
|
||||
testId="welcome-open-folder"
|
||||
buttonRef={openFolderActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light, #F3E8FF)"
|
||||
label="Get started with a template"
|
||||
description={presentation.templateDescription}
|
||||
loadingLabel="Downloading template…"
|
||||
loadingDescription="Cloning the Getting Started vault template"
|
||||
onClick={onCreateVault}
|
||||
disabled={busy || isOffline}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
buttonRef={templateActionRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{creatingAction === 'template' && (
|
||||
|
||||
@@ -24,16 +24,16 @@ interface WikilinkSuggestionMenuProps {
|
||||
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
|
||||
return (
|
||||
<div className="wikilink-menu">
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={selectedIndex ?? 0}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => {
|
||||
item.onItemClick()
|
||||
onItemClick?.(item)
|
||||
}}
|
||||
emptyMessage="No results"
|
||||
/>
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={selectedIndex ?? 0}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => {
|
||||
item.onItemClick()
|
||||
onItemClick?.(item)
|
||||
}}
|
||||
emptyMessage="No results"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isWithinFormattingToolbarHoverBridge,
|
||||
shouldSuppressFormattingToolbarHoverUpdate,
|
||||
useBlockNoteFormattingToolbarHoverGuard,
|
||||
} from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
return DOMRect.fromRect({ x: left, y: top, width, height })
|
||||
}
|
||||
|
||||
function setRect(element: HTMLElement, nextRect: DOMRect) {
|
||||
element.getBoundingClientRect = () => nextRect
|
||||
}
|
||||
|
||||
function setupHoverDom() {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'bn-visual-media-wrapper'
|
||||
fileBlock.appendChild(wrapper)
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(wrapper, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
return { container, toolbar }
|
||||
}
|
||||
|
||||
function createEditor(options: {
|
||||
selectionType?: string | null
|
||||
selectionId?: string
|
||||
cursorType?: string
|
||||
cursorId?: string
|
||||
setTextCursorPosition?: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
const {
|
||||
selectionType = 'paragraph',
|
||||
selectionId = 'other-block',
|
||||
cursorType = 'paragraph',
|
||||
cursorId = 'cursor-block',
|
||||
setTextCursorPosition = vi.fn(),
|
||||
} = options
|
||||
|
||||
return {
|
||||
getSelection: () => (
|
||||
selectionType
|
||||
? { blocks: [{ type: selectionType, id: selectionId }] }
|
||||
: null
|
||||
),
|
||||
getTextCursorPosition: () => ({ block: { type: cursorType, id: cursorId } }),
|
||||
setTextCursorPosition,
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchBridgeMousemove(target: EventTarget = document.body) {
|
||||
const event = new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
})
|
||||
Object.defineProperty(event, 'target', { configurable: true, value: target })
|
||||
const stopPropagation = vi.fn()
|
||||
event.stopPropagation = stopPropagation
|
||||
act(() => {
|
||||
window.dispatchEvent(event)
|
||||
})
|
||||
return stopPropagation
|
||||
}
|
||||
|
||||
describe('blockNoteFormattingToolbarHoverGuard extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('treats invisible rects and missing bridge inputs as non-suppressing', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
{ x: 12, y: 12 },
|
||||
rect(10, 10, 0, 20),
|
||||
rect(10, 10, 20, 20),
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 12, y: 12 },
|
||||
container: null,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('remembers the last selected file block while open and clears it when closed', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { container } = setupHoverDom()
|
||||
const setTextCursorPosition = vi.fn()
|
||||
const editor = createEditor({ setTextCursorPosition })
|
||||
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ selectedFileBlockId, isOpen }) => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}),
|
||||
{
|
||||
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
|
||||
},
|
||||
)
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
const stopPropagation = dispatchBridgeMousemove()
|
||||
|
||||
expect(setTextCursorPosition).toHaveBeenCalledWith('image-block')
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: false })
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
dispatchBridgeMousemove()
|
||||
|
||||
expect(setTextCursorPosition).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
|
||||
})
|
||||
|
||||
it('skips listener setup when the environment is invalid and safely ignores already-active or failing restores', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const { container } = setupHoverDom()
|
||||
const alreadySelectedEditor = createEditor({
|
||||
selectionType: null,
|
||||
cursorType: 'image',
|
||||
cursorId: 'image-block',
|
||||
setTextCursorPosition: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: alreadySelectedEditor as never,
|
||||
container: null,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}))
|
||||
|
||||
expect(addSpy).not.toHaveBeenCalled()
|
||||
|
||||
const { unmount } = renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: alreadySelectedEditor as never,
|
||||
container,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}))
|
||||
|
||||
dispatchBridgeMousemove()
|
||||
expect(alreadySelectedEditor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
|
||||
const throwingEditor = createEditor({
|
||||
selectionType: null,
|
||||
cursorType: 'paragraph',
|
||||
cursorId: 'other-block',
|
||||
setTextCursorPosition: vi.fn(() => {
|
||||
throw new Error('gone')
|
||||
}),
|
||||
})
|
||||
|
||||
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: throwingEditor as never,
|
||||
container,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}))
|
||||
|
||||
expect(() => dispatchBridgeMousemove()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
isWithinFormattingToolbarHoverBridge,
|
||||
shouldSuppressFormattingToolbarHoverUpdate,
|
||||
useBlockNoteFormattingToolbarHoverGuard,
|
||||
} from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
@@ -12,7 +14,69 @@ function setRect(element: HTMLElement, nextRect: DOMRect) {
|
||||
element.getBoundingClientRect = () => nextRect
|
||||
}
|
||||
|
||||
function appendFileBlock({
|
||||
container,
|
||||
blockId,
|
||||
innerClass,
|
||||
}: {
|
||||
container: HTMLElement
|
||||
blockId: string
|
||||
innerClass: string
|
||||
}) {
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = blockId
|
||||
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
|
||||
const inner = document.createElement('div')
|
||||
inner.className = innerClass
|
||||
fileBlock.appendChild(inner)
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
|
||||
return inner
|
||||
}
|
||||
|
||||
function createToolbarHoverScenario(withToolbarButton = false) {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
const toolbarButton = withToolbarButton ? document.createElement('button') : null
|
||||
if (toolbarButton) toolbar.appendChild(toolbarButton)
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
return { container, toolbarButton }
|
||||
}
|
||||
|
||||
function makeEditor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getSelection: vi.fn(() => null),
|
||||
getTextCursorPosition: vi.fn(() => ({ block: { id: 'paragraph-block', type: 'paragraph' } })),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('blockNoteFormattingToolbarHoverGuard', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
@@ -23,92 +87,210 @@ describe('blockNoteFormattingToolbarHoverGuard', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates when the pointer is already over the toolbar', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
const toolbarButton = document.createElement('button')
|
||||
toolbar.appendChild(toolbarButton)
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
it('ignores invisible rectangles and missing hover bridge context', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
{ x: 10, y: 10 },
|
||||
rect(300, 130, 0, 90),
|
||||
rect(322, 78, 96, 24),
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: toolbarButton,
|
||||
eventTarget: document.body,
|
||||
point: { x: 350, y: 90 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 368, y: 104 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves unrelated pointer movement alone', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 520, y: 220 },
|
||||
container,
|
||||
container: null,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 350, y: 90 },
|
||||
container: document.createElement('div'),
|
||||
doc: document,
|
||||
selectedFileBlockId: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'suppresses hover updates when the pointer is already over the toolbar',
|
||||
point: { x: 350, y: 90 },
|
||||
eventTarget: 'toolbarButton',
|
||||
expected: true,
|
||||
withToolbarButton: true,
|
||||
},
|
||||
{
|
||||
name: 'suppresses hover updates while the pointer crosses the image-toolbar bridge',
|
||||
point: { x: 368, y: 104 },
|
||||
eventTarget: 'body',
|
||||
expected: true,
|
||||
withToolbarButton: false,
|
||||
},
|
||||
{
|
||||
name: 'leaves unrelated pointer movement alone',
|
||||
point: { x: 520, y: 220 },
|
||||
eventTarget: 'body',
|
||||
expected: false,
|
||||
withToolbarButton: false,
|
||||
},
|
||||
])('$name', ({ point, eventTarget, expected, withToolbarButton }) => {
|
||||
const { container, toolbarButton } = createToolbarHoverScenario(withToolbarButton)
|
||||
const target = eventTarget === 'toolbarButton' ? toolbarButton : document.body
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: target as EventTarget,
|
||||
point,
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses the filename bridge fallback and restores the last selected file block while the toolbar stays open', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const bridge = appendFileBlock({
|
||||
container,
|
||||
blockId: 'image-block',
|
||||
innerClass: 'bn-file-name-with-icon',
|
||||
})
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(bridge, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
const editor = makeEditor()
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ selectedFileBlockId, isOpen }) =>
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}),
|
||||
{
|
||||
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
|
||||
},
|
||||
)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('image-block')
|
||||
|
||||
vi.mocked(editor.setTextCursorPosition).mockClear()
|
||||
rerender({ selectedFileBlockId: null, isOpen: false })
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the add-file-button fallback and swallows restore errors when the block disappears', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const bridge = appendFileBlock({
|
||||
container,
|
||||
blockId: 'image-block',
|
||||
innerClass: 'bn-add-file-button',
|
||||
})
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(bridge, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
const editor = makeEditor({
|
||||
setTextCursorPosition: vi.fn(() => {
|
||||
throw new Error('gone')
|
||||
}),
|
||||
})
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ selectedFileBlockId, isOpen }) =>
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}),
|
||||
{
|
||||
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
|
||||
},
|
||||
)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
|
||||
expect(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('leaves the cursor alone when the selected file block is already active', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const bridge = appendFileBlock({
|
||||
container,
|
||||
blockId: 'image-block',
|
||||
innerClass: 'bn-file-name-with-icon',
|
||||
})
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(bridge, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
const editor = makeEditor({
|
||||
getSelection: vi.fn(() => ({
|
||||
blocks: [{ id: 'image-block', type: 'image' }],
|
||||
})),
|
||||
})
|
||||
|
||||
renderHook(() =>
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
100
src/components/inlineWikilinkDom.test.ts
Normal file
100
src/components/inlineWikilinkDom.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
applySelectionRange,
|
||||
readSelectionIndex,
|
||||
readSelectionRange,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
|
||||
function createChip(target: string): HTMLSpanElement {
|
||||
const chip = document.createElement('span')
|
||||
chip.dataset.chipTarget = target
|
||||
chip.textContent = target
|
||||
return chip
|
||||
}
|
||||
|
||||
function createMixedInlineRoot(): HTMLDivElement {
|
||||
const root = document.createElement('div')
|
||||
root.append('A')
|
||||
root.append(createChip('Project'))
|
||||
root.append('B')
|
||||
document.body.append(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function setSelectionRange(
|
||||
startContainer: Node,
|
||||
startOffset: number,
|
||||
endContainer: Node,
|
||||
endOffset: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.setStart(startContainer, startOffset)
|
||||
range.setEnd(endContainer, endOffset)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
describe('inlineWikilinkDom', () => {
|
||||
it('serializes text, chips, breaks, and nested content into inline markdown', () => {
|
||||
const root = document.createElement('div')
|
||||
const nested = document.createElement('span')
|
||||
|
||||
root.append('A\u00A0B\u200B\n')
|
||||
root.append(createChip('Project'))
|
||||
root.append(document.createElement('br'))
|
||||
nested.textContent = 'Tail'
|
||||
root.append(nested)
|
||||
|
||||
expect(serializeInlineNode(root)).toBe('A B [[Project]]Tail')
|
||||
})
|
||||
|
||||
it('reads selections inside the editor and falls back to the editor end for outside selections', () => {
|
||||
const root = createMixedInlineRoot()
|
||||
const [startText, , endText] = Array.from(root.childNodes)
|
||||
const outside = document.createElement('div')
|
||||
|
||||
outside.textContent = 'Outside'
|
||||
document.body.append(outside)
|
||||
|
||||
setSelectionRange(startText as Node, 1, endText as Node, 1)
|
||||
|
||||
expect(readSelectionRange(root)).toEqual({ start: 1, end: 13 })
|
||||
expect(readSelectionIndex(root)).toBe(13)
|
||||
|
||||
setSelectionRange(outside.firstChild as Node, 0, outside.firstChild as Node, 7)
|
||||
|
||||
expect(readSelectionRange(root)).toEqual({ start: 13, end: 13 })
|
||||
})
|
||||
|
||||
it('applies selection ranges across chips and clamps to the editor end', () => {
|
||||
const root = createMixedInlineRoot()
|
||||
|
||||
applySelectionRange(root, { start: 2, end: 999 })
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
expect(selection?.anchorNode?.textContent).toBe('B')
|
||||
expect(selection?.anchorOffset).toBe(0)
|
||||
expect(selection?.focusNode?.textContent).toBe('B')
|
||||
expect(selection?.focusOffset).toBe(1)
|
||||
})
|
||||
|
||||
it('applies collapsed indices before leading chips without requiring text nodes', () => {
|
||||
const root = document.createElement('div')
|
||||
|
||||
root.append(createChip('Task'))
|
||||
document.body.append(root)
|
||||
|
||||
applySelectionIndex(root, 0)
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
expect(selection?.anchorNode).toBe(root)
|
||||
expect(selection?.anchorOffset).toBe(0)
|
||||
expect(selection?.focusNode).toBe(root)
|
||||
expect(selection?.focusOffset).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
|
||||
import { humanizePropertyKey } from '../../utils/propertyLabels'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { PROPERTY_PANEL_LABEL_CLASS_NAME } from '../propertyPanelLayout'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { ActionTooltip } from '../ui/action-tooltip'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
export interface ReferencedByItem {
|
||||
@@ -16,44 +20,65 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry[]>()
|
||||
const map = new Map<string, { entriesByPath: Map<string, VaultEntry>; inverseKeys: Set<string> }>()
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.viaKey)
|
||||
if (existing) existing.push(item.entry)
|
||||
else map.set(item.viaKey, [item.entry])
|
||||
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
|
||||
const group = map.get(label) ?? { entriesByPath: new Map<string, VaultEntry>(), inverseKeys: new Set<string>() }
|
||||
group.entriesByPath.set(item.entry.path, item.entry)
|
||||
group.inverseKeys.add(humanizePropertyKey(item.viaKey))
|
||||
map.set(label, group)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
|
||||
return orderInverseRelationshipLabels(map.keys())
|
||||
.map((label) => {
|
||||
const group = map.get(label)
|
||||
if (!group) return null
|
||||
|
||||
return {
|
||||
entries: [...group.entriesByPath.values()],
|
||||
inverseKeys: [...group.inverseKeys].sort((left, right) => left.localeCompare(right)),
|
||||
label,
|
||||
}
|
||||
})
|
||||
.filter((group): group is { entries: VaultEntry[]; inverseKeys: string[]; label: string } => group !== null && group.entries.length > 0)
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
if (grouped.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="referenced-by-panel">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([viaKey, groupEntries]) => (
|
||||
<div key={viaKey}>
|
||||
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
|
||||
← {humanizePropertyKey(viaKey)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
noteIcon={e.icon}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="referenced-by-panel flex flex-col gap-3">
|
||||
<Separator data-testid="derived-relationships-separator" />
|
||||
<div className="flex flex-col gap-3">
|
||||
{grouped.map(({ label, entries: groupEntries, inverseKeys }) => {
|
||||
const tooltip = `Derived inverse relationship, inverse of ${inverseKeys.join(' and ')}.`
|
||||
|
||||
return (
|
||||
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
|
||||
<ActionTooltip copy={{ label: tooltip }} side="top" align="start">
|
||||
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} tabIndex={0} data-testid="derived-relationship-label">
|
||||
{label}
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
noteIcon={e.icon}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render as rtlRender, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DynamicRelationshipsPanel } from './RelationshipsPanel'
|
||||
import { ReferencedByPanel, type ReferencedByItem } from './ReferencedByPanel'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
@@ -105,14 +108,46 @@ describe('relationship display labels', () => {
|
||||
expect(relationshipRow).toHaveStyle({ gridColumn: '1 / -1' })
|
||||
})
|
||||
|
||||
it('humanizes snake_case keys in the referenced-by panel', () => {
|
||||
it('humanizes snake_case keys in the referenced-by panel', async () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
fireEvent.focus(screen.getByText('Children'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dedupes canonical and legacy inverse keys into a single normalized inspector group', () => {
|
||||
const entry = makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' })
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry, viaKey: 'belongs_to' },
|
||||
{ entry, viaKey: 'Belongs to' },
|
||||
{ entry: makeEntry({ path: '/vault/topic-alpha.md', filename: 'topic-alpha.md', title: 'Topic Alpha', isA: 'Note' }), viaKey: 'related_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Project Alpha')).toHaveLength(1)
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show empty preferred inverse groups', () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,7 +10,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr
|
||||
|
||||
const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
@@ -19,12 +20,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
sidebarCollapsed?: boolean
|
||||
searchVisible: boolean
|
||||
search: string
|
||||
isSearching: boolean
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
propertyPicker?: ListPropertiesPopoverProps | null
|
||||
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
onCreateNote: () => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -51,7 +55,24 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
</div>
|
||||
{searchVisible && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
|
||||
<div className="relative flex-1" aria-live="polite">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search notes..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
className="h-8 pr-8 text-[13px]"
|
||||
/>
|
||||
{isSearching && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||
data-testid="note-list-search-loading"
|
||||
>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -192,12 +192,18 @@ export function NoteListLayout({
|
||||
sidebarCollapsed,
|
||||
searchVisible,
|
||||
search,
|
||||
isSearching,
|
||||
searchInputRef,
|
||||
propertyPicker,
|
||||
handleSortChange,
|
||||
handleCreateNote,
|
||||
onOpenType,
|
||||
toggleSearch,
|
||||
setSearch,
|
||||
handleSearchKeyDown,
|
||||
noteListPanelRef,
|
||||
handleNoteListPanelBlurCapture,
|
||||
handleNoteListPanelFocusCapture,
|
||||
handleListKeyDown,
|
||||
noteListContainerRef,
|
||||
handleNoteListBlur,
|
||||
@@ -230,8 +236,11 @@ export function NoteListLayout({
|
||||
}: NoteListLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
ref={noteListPanelRef}
|
||||
className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground"
|
||||
style={{ height: '100%' }}
|
||||
onBlurCapture={handleNoteListPanelBlurCapture}
|
||||
onFocusCapture={handleNoteListPanelFocusCapture}
|
||||
>
|
||||
<NoteListHeader
|
||||
title={title}
|
||||
@@ -243,12 +252,15 @@ export function NoteListLayout({
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
searchVisible={searchVisible}
|
||||
search={search}
|
||||
isSearching={isSearching}
|
||||
searchInputRef={searchInputRef}
|
||||
propertyPicker={propertyPicker}
|
||||
onSortChange={handleSortChange}
|
||||
onCreateNote={handleCreateNote}
|
||||
onOpenType={onOpenType}
|
||||
onToggleSearch={toggleSearch}
|
||||
onSearchChange={setSearch}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<NoteListBody
|
||||
handleListKeyDown={handleListKeyDown}
|
||||
|
||||
91
src/components/note-list/NoteListSearchKeyboard.test.tsx
Normal file
91
src/components/note-list/NoteListSearchKeyboard.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeIndexedEntry, renderNoteList } from '../../test-utils/noteListTestUtils'
|
||||
|
||||
function installAnimationFrameStub() {
|
||||
let nextId = 1
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
|
||||
return {
|
||||
flushAnimationFrame: () => {
|
||||
const pending = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
pending.forEach((callback) => callback(0))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteList search keyboard behavior', () => {
|
||||
let flushAnimationFrame: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
;({ flushAnimationFrame } = installAnimationFrameStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('toggles note-list search with Cmd+F while the note list is active', () => {
|
||||
renderNoteList()
|
||||
const noteList = screen.getByTestId('note-list-container')
|
||||
|
||||
act(() => {
|
||||
noteList.focus()
|
||||
fireEvent.focus(noteList)
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true })
|
||||
})
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
act(() => {
|
||||
flushAnimationFrame()
|
||||
})
|
||||
expect(searchInput).toHaveFocus()
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true })
|
||||
flushAnimationFrame()
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
expect(noteList).toHaveFocus()
|
||||
})
|
||||
|
||||
it('debounces note-list filtering and shows loading feedback while waiting', () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(index + 1)),
|
||||
makeIndexedEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
fireEvent.change(searchInput, { target: { value: 'Strategy' } })
|
||||
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
expect(screen.getByText('Note 1')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(180)
|
||||
flushAnimationFrame()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Note 1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModifiedFile, VaultEntry, ViewFile } from '../../types'
|
||||
import { saveSortPreferences } from '../../utils/noteListHelpers'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useMultiSelectKeyboard,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
} from './noteListHooks'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const {
|
||||
multiSelectState,
|
||||
noteListKeyboardState,
|
||||
prefetchNoteContentMock,
|
||||
routeNoteClickMock,
|
||||
} = vi.hoisted(() => ({
|
||||
multiSelectState: {
|
||||
clear: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
selectRange: vi.fn(),
|
||||
setAnchor: vi.fn(),
|
||||
isMultiSelecting: false,
|
||||
},
|
||||
noteListKeyboardState: {
|
||||
highlightedPath: null as string | null,
|
||||
handleKeyDown: vi.fn(),
|
||||
lastOptions: null as null | Record<string, unknown>,
|
||||
},
|
||||
prefetchNoteContentMock: vi.fn(),
|
||||
routeNoteClickMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useMultiSelect', () => ({
|
||||
useMultiSelect: () => multiSelectState,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useNoteListKeyboard', () => ({
|
||||
useNoteListKeyboard: (options: Record<string, unknown>) => {
|
||||
noteListKeyboardState.lastOptions = options
|
||||
return {
|
||||
highlightedPath: noteListKeyboardState.highlightedPath,
|
||||
handleKeyDown: noteListKeyboardState.handleKeyDown,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useTabManagement', () => ({
|
||||
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
|
||||
}))
|
||||
|
||||
vi.mock('./noteListUtils', async () => {
|
||||
const actual = await vi.importActual<typeof import('./noteListUtils')>('./noteListUtils')
|
||||
return {
|
||||
...actual,
|
||||
routeNoteClick: (...args: unknown[]) => routeNoteClickMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/a.md',
|
||||
filename: 'a.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeletedEntry(): VaultEntry {
|
||||
return makeEntry({
|
||||
path: '/vault/note/deleted.md',
|
||||
filename: 'deleted.md',
|
||||
title: 'Deleted',
|
||||
__deletedNotePreview: true,
|
||||
__deletedRelativePath: 'note/deleted.md',
|
||||
__changeAddedLines: 0,
|
||||
__changeDeletedLines: 4,
|
||||
__changeBinary: false,
|
||||
} as Partial<VaultEntry>)
|
||||
}
|
||||
|
||||
describe('noteListHooks extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
multiSelectState.isMultiSelecting = false
|
||||
noteListKeyboardState.highlightedPath = null
|
||||
noteListKeyboardState.lastOptions = null
|
||||
routeNoteClickMock.mockImplementation((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onReplace: (value: VaultEntry) => void },
|
||||
) => {
|
||||
actions.onReplace(entry)
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles search visibility and clears the search when closing it', () => {
|
||||
const { result } = renderHook(() => useNoteListSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSearch()
|
||||
result.current.setSearch(' HELLO ')
|
||||
})
|
||||
|
||||
expect(result.current.searchVisible).toBe(true)
|
||||
expect(result.current.query).toBe('hello')
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSearch()
|
||||
})
|
||||
|
||||
expect(result.current.searchVisible).toBe(false)
|
||||
expect(result.current.search).toBe('')
|
||||
})
|
||||
|
||||
it('migrates stored list sorting into type documents', async () => {
|
||||
const typeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
sort: null,
|
||||
})
|
||||
const projectEntry = makeEntry()
|
||||
const onUpdateTypeSort = vi.fn()
|
||||
const updateEntry = vi.fn()
|
||||
|
||||
saveSortPreferences({
|
||||
__list__: { option: 'title', direction: 'asc' },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [typeDocument, projectEntry],
|
||||
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
onUpdateTypeSort,
|
||||
updateEntry,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'title:asc')
|
||||
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'title:asc' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'modified', 'desc')
|
||||
})
|
||||
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'modified:desc')
|
||||
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'modified:desc' })
|
||||
})
|
||||
|
||||
it('stores list sorting locally when no persistence target is available', () => {
|
||||
const entry = makeEntry({ isA: 'Note' })
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [entry],
|
||||
selection: { kind: 'filter', filter: 'all' },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'title', 'asc')
|
||||
})
|
||||
|
||||
expect(result.current.sortPrefs.__list__).toEqual({ option: 'title', direction: 'asc' })
|
||||
})
|
||||
|
||||
it('prefers selected view sort config and persists list sort changes back to the view definition', () => {
|
||||
const onUpdateViewDefinition = vi.fn()
|
||||
const view: ViewFile = {
|
||||
filename: 'work.view',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: 'title:asc',
|
||||
filters: { all: [] },
|
||||
},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [makeEntry()],
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
views: [view],
|
||||
onUpdateViewDefinition,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.listSort).toBe('title')
|
||||
expect(result.current.listDirection).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'modified', 'desc')
|
||||
})
|
||||
|
||||
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, { sort: 'modified:desc' })
|
||||
})
|
||||
|
||||
it('handles keyboard shortcuts for multi-select flows and ignores select-all in focused inputs', () => {
|
||||
const onArchive = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
|
||||
multiSelectState.isMultiSelecting = true
|
||||
renderHook(() => useMultiSelectKeyboard(multiSelectState as never, false, onArchive, onDelete))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
expect(multiSelectState.clear).toHaveBeenCalled()
|
||||
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
expect(multiSelectState.selectAll).not.toHaveBeenCalled()
|
||||
|
||||
input.blur()
|
||||
input.remove()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'e',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(multiSelectState.selectAll).toHaveBeenCalledOnce()
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('matches change status by relative path suffix and returns undefined outside the changes view', () => {
|
||||
const modifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/changes/note/alpha.md', relativePath: 'note/alpha.md', status: 'deleted' },
|
||||
]
|
||||
|
||||
const enabled = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
|
||||
expect(enabled.result.current('/mirror/worktree/note/alpha.md')).toBe('deleted')
|
||||
|
||||
const disabled = renderHook(() => useChangeStatusResolver(false, modifiedFiles))
|
||||
expect(disabled.result.current('/vault/changes/note/alpha.md')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for change-status lookups that do not match any modified file', () => {
|
||||
const modifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/note/a.md', relativePath: 'note/a.md', status: 'modified' },
|
||||
]
|
||||
const { result } = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
|
||||
|
||||
expect(result.current('/vault/note/a.md')).toBe('modified')
|
||||
expect(result.current('/vault/note/missing.md')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a type property picker that persists the chosen columns', () => {
|
||||
const typeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: ['status'],
|
||||
})
|
||||
const projectEntry = makeEntry({
|
||||
properties: { priority: 'High' },
|
||||
relationships: { related_to: ['Beta'] },
|
||||
})
|
||||
const onUpdateTypeSort = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListPropertyPicker({
|
||||
entries: [typeDocument, projectEntry],
|
||||
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
|
||||
inboxPeriod: 'month',
|
||||
typeDocument,
|
||||
typeEntryMap: { Project: typeDocument },
|
||||
onUpdateTypeSort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyPicker?.scope).toBe('type')
|
||||
|
||||
act(() => {
|
||||
result.current.propertyPicker?.onSave(['status', 'priority'])
|
||||
})
|
||||
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(
|
||||
typeDocument.path,
|
||||
'_list_properties_display',
|
||||
['status', 'priority'],
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the view property picker and saves view-specific list property display overrides', () => {
|
||||
const onUpdateViewDefinition = vi.fn()
|
||||
const view: ViewFile = {
|
||||
filename: 'focus.view',
|
||||
definition: {
|
||||
name: 'Focus',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
listPropertiesDisplay: [],
|
||||
},
|
||||
}
|
||||
const focusTypeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: ['status'],
|
||||
})
|
||||
const projectEntry = makeEntry({
|
||||
properties: { priority: 'High' },
|
||||
relationships: { related_to: ['Beta'] },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListPropertyPicker({
|
||||
entries: [focusTypeDocument, projectEntry],
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
inboxPeriod: 'month',
|
||||
typeDocument: null,
|
||||
typeEntryMap: { Project: focusTypeDocument },
|
||||
views: [view],
|
||||
onUpdateViewDefinition,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyPicker?.scope).toBe('view')
|
||||
|
||||
act(() => {
|
||||
result.current.propertyPicker?.onSave(null)
|
||||
})
|
||||
|
||||
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, {
|
||||
listPropertiesDisplay: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('routes deleted-note interactions through the deleted preview handlers and auto-triggers diffs for live changes', () => {
|
||||
vi.useFakeTimers()
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const onReplaceActiveTab = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
const onAutoTriggerDiff = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListInteractions({
|
||||
searched: [deletedEntry, liveEntry],
|
||||
searchedGroups: [],
|
||||
selectedNotePath: deletedEntry.path,
|
||||
selection: { kind: 'filter', filter: 'changes' },
|
||||
noteListFilter: 'open',
|
||||
isChangesView: true,
|
||||
entityEntry: null,
|
||||
searchVisible: false,
|
||||
toggleSearch: vi.fn(),
|
||||
onReplaceActiveTab,
|
||||
onOpenDeletedNote,
|
||||
onAutoTriggerDiff,
|
||||
openContextMenuForEntry: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const keyboardOptions = noteListKeyboardState.lastOptions as {
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onPrefetch: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
act(() => {
|
||||
keyboardOptions.onOpen(deletedEntry)
|
||||
keyboardOptions.onPrefetch(liveEntry)
|
||||
routeNoteClickMock.mockImplementationOnce((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onEnterNeighborhood?: (value: VaultEntry) => void },
|
||||
) => {
|
||||
actions.onEnterNeighborhood?.(entry)
|
||||
})
|
||||
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
|
||||
result.current.handleClickNote(liveEntry, {} as React.MouseEvent)
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
|
||||
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens live notes into Neighborhood mode and routes deleted clicks through the deleted preview action', async () => {
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const onReplaceActiveTab = vi.fn(async () => {})
|
||||
const onEnterNeighborhood = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
|
||||
routeNoteClickMock.mockImplementation((
|
||||
_entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onEnterNeighborhood: () => void },
|
||||
) => {
|
||||
actions.onEnterNeighborhood()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListInteractions({
|
||||
searched: [deletedEntry, liveEntry],
|
||||
searchedGroups: [],
|
||||
selectedNotePath: liveEntry.path,
|
||||
selection: { kind: 'filter', filter: 'changes' },
|
||||
noteListFilter: 'open',
|
||||
isChangesView: true,
|
||||
entityEntry: null,
|
||||
searchVisible: false,
|
||||
toggleSearch: vi.fn(),
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
openContextMenuForEntry: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const keyboardOptions = noteListKeyboardState.lastOptions as {
|
||||
onEnterNeighborhood: (entry: VaultEntry) => Promise<void>
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await keyboardOptions.onEnterNeighborhood(deletedEntry)
|
||||
await keyboardOptions.onEnterNeighborhood(liveEntry)
|
||||
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
|
||||
})
|
||||
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onEnterNeighborhood).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
})
|
||||
})
|
||||
@@ -264,22 +264,27 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
|
||||
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
|
||||
}
|
||||
|
||||
function persistOrSaveGroupSort(
|
||||
groupLabel: string,
|
||||
option: SortOption,
|
||||
direction: SortDirection,
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
|
||||
typeDocument: VaultEntry | null,
|
||||
selectedView: ViewFile | null,
|
||||
persistence: SortPersistence | null,
|
||||
) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
|
||||
if (!persistenceTarget || !persistence) {
|
||||
saveGroupSort(groupLabel, option, direction, setSortPrefs)
|
||||
function persistOrSaveGroupSort(params: {
|
||||
groupLabel: string
|
||||
option: SortOption
|
||||
direction: SortDirection
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>
|
||||
typeDocument: VaultEntry | null
|
||||
selectedView: ViewFile | null
|
||||
persistence: SortPersistence | null
|
||||
}) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(
|
||||
params.groupLabel,
|
||||
params.typeDocument,
|
||||
params.selectedView,
|
||||
params.persistence,
|
||||
)
|
||||
if (!persistenceTarget || !params.persistence) {
|
||||
saveGroupSort(params.groupLabel, params.option, params.direction, params.setSortPrefs)
|
||||
return
|
||||
}
|
||||
|
||||
persistListSort(persistenceTarget, { option, direction }, persistence)
|
||||
persistListSort(persistenceTarget, { option: params.option, direction: params.direction }, params.persistence)
|
||||
}
|
||||
|
||||
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
|
||||
@@ -332,7 +337,7 @@ export function useNoteListSort({
|
||||
}, [typeDocument, sortPrefs, persistence])
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
|
||||
persistOrSaveGroupSort(
|
||||
persistOrSaveGroupSort({
|
||||
groupLabel,
|
||||
option,
|
||||
direction,
|
||||
@@ -340,7 +345,7 @@ export function useNoteListSort({
|
||||
typeDocument,
|
||||
selectedView,
|
||||
persistence,
|
||||
)
|
||||
})
|
||||
}, [typeDocument, selectedView, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries({
|
||||
@@ -735,6 +740,59 @@ interface UseListPropertyPickerParams {
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function resolvePropertyPicker(options: {
|
||||
selectedView: ViewFile | null
|
||||
viewAvailableProperties: string[]
|
||||
viewDefaultDisplay: string[]
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
isAllNotesView: boolean
|
||||
allNotesAvailableProperties: string[]
|
||||
hasCustomAllNotesProperties: boolean
|
||||
allNotesNoteListProperties?: string[] | null
|
||||
allNotesDefaultDisplay: string[]
|
||||
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
|
||||
isInboxView: boolean
|
||||
inboxAvailableProperties: string[]
|
||||
hasCustomInboxProperties: boolean
|
||||
inboxNoteListProperties?: string[] | null
|
||||
inboxDefaultDisplay: string[]
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
isSectionGroup: boolean
|
||||
typeDocument: VaultEntry | null
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
typeAvailableProperties: string[]
|
||||
}) {
|
||||
return buildViewPropertyPicker({
|
||||
selectedView: options.selectedView,
|
||||
availableProperties: options.viewAvailableProperties,
|
||||
defaultDisplay: options.viewDefaultDisplay,
|
||||
onUpdateViewDefinition: options.onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: options.isAllNotesView,
|
||||
availableProperties: options.allNotesAvailableProperties,
|
||||
hasCustomProperties: options.hasCustomAllNotesProperties,
|
||||
noteListProperties: options.allNotesNoteListProperties,
|
||||
defaultDisplay: options.allNotesDefaultDisplay,
|
||||
onSave: options.onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: options.isInboxView,
|
||||
availableProperties: options.inboxAvailableProperties,
|
||||
hasCustomProperties: options.hasCustomInboxProperties,
|
||||
noteListProperties: options.inboxNoteListProperties,
|
||||
defaultDisplay: options.inboxDefaultDisplay,
|
||||
onSave: options.onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
isSectionGroup: options.isSectionGroup,
|
||||
typeDocument: options.typeDocument,
|
||||
onUpdateTypeSort: options.onUpdateTypeSort,
|
||||
typeAvailableProperties: options.typeAvailableProperties,
|
||||
})
|
||||
}
|
||||
|
||||
export function useListPropertyPicker({
|
||||
entries,
|
||||
selection,
|
||||
@@ -773,30 +831,23 @@ export function useListPropertyPicker({
|
||||
})
|
||||
|
||||
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
|
||||
return buildViewPropertyPicker({
|
||||
return resolvePropertyPicker({
|
||||
selectedView: viewState.selectedView,
|
||||
availableProperties: viewState.availableProperties,
|
||||
defaultDisplay: viewState.defaultDisplay,
|
||||
viewAvailableProperties: viewState.availableProperties,
|
||||
viewDefaultDisplay: viewState.defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: isAllNotesView,
|
||||
availableProperties: allNotesState.availableProperties,
|
||||
hasCustomProperties: hasCustomAllNotesProperties,
|
||||
noteListProperties: allNotesNoteListProperties,
|
||||
defaultDisplay: allNotesState.defaultDisplay,
|
||||
onSave: onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: isInboxView,
|
||||
availableProperties: inboxState.availableProperties,
|
||||
hasCustomProperties: hasCustomInboxProperties,
|
||||
noteListProperties: inboxNoteListProperties,
|
||||
defaultDisplay: inboxState.defaultDisplay,
|
||||
onSave: onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties: allNotesState.availableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay: allNotesState.defaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
isInboxView,
|
||||
inboxAvailableProperties: inboxState.availableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay: inboxState.defaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
@@ -838,6 +889,8 @@ interface UseNoteListInteractionsParams {
|
||||
noteListFilter: NoteListFilter
|
||||
isChangesView: boolean
|
||||
entityEntry: VaultEntry | null
|
||||
searchVisible: boolean
|
||||
toggleSearch: () => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
|
||||
@@ -886,6 +939,8 @@ function useKeyboardInteractionState({
|
||||
searchedGroups,
|
||||
entityEntry,
|
||||
selectedNotePath,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -895,6 +950,8 @@ function useKeyboardInteractionState({
|
||||
| 'searchedGroups'
|
||||
| 'entityEntry'
|
||||
| 'selectedNotePath'
|
||||
| 'searchVisible'
|
||||
| 'toggleSearch'
|
||||
| 'onReplaceActiveTab'
|
||||
| 'onEnterNeighborhood'
|
||||
| 'onOpenDeletedNote'
|
||||
@@ -928,6 +985,8 @@ function useKeyboardInteractionState({
|
||||
onOpen: handleKeyboardOpen,
|
||||
onEnterNeighborhood: handleNeighborhoodOpen,
|
||||
onPrefetch: handleKeyboardPrefetch,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
enabled: true,
|
||||
})
|
||||
const multiSelect = useMultiSelect(keyboardEntries, selectedNotePath)
|
||||
@@ -1025,6 +1084,8 @@ export function useNoteListInteractions({
|
||||
noteListFilter,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -1040,6 +1101,8 @@ export function useNoteListInteractions({
|
||||
searchedGroups,
|
||||
entityEntry,
|
||||
selectedNotePath,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useEffect, useMemo, useCallback } from 'react'
|
||||
import type {
|
||||
VaultEntry,
|
||||
SidebarSelection,
|
||||
@@ -15,18 +15,19 @@ import { prefetchNoteContent } from '../../hooks/useTabManagement'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
|
||||
import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch'
|
||||
import { useNoteListSearchState } from './useNoteListSearchState'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useModifiedFilesState,
|
||||
useNoteListData,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
useTypeEntryMap,
|
||||
useVisibleNotesSync,
|
||||
} from './noteListHooks'
|
||||
import { useChangesContextMenu } from './NoteListChangesMenu'
|
||||
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
|
||||
|
||||
type EntitySelection = Extract<SidebarSelection, { kind: 'entity' }>
|
||||
|
||||
@@ -138,7 +139,16 @@ function useNoteListContent({
|
||||
onUpdateViewDefinition,
|
||||
updateEntry,
|
||||
})
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const {
|
||||
closeSearch,
|
||||
isSearching,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
setSearch,
|
||||
toggleSearch,
|
||||
} = useNoteListSearchState()
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { displayPropsOverride, propertyPicker } = useListPropertyPicker({
|
||||
entries,
|
||||
@@ -191,15 +201,18 @@ function useNoteListContent({
|
||||
entityEntry,
|
||||
handleSortChange,
|
||||
isArchivedView,
|
||||
isSearching,
|
||||
isEntityView,
|
||||
listDirection,
|
||||
listSort,
|
||||
propertyPicker,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
searched,
|
||||
searchedGroups,
|
||||
closeSearch,
|
||||
setSearch,
|
||||
sortPrefs,
|
||||
toggleSearch,
|
||||
@@ -217,6 +230,8 @@ interface UseNoteListInteractionStateParams {
|
||||
isArchivedView: boolean
|
||||
isChangesView: boolean
|
||||
entityEntry: VaultEntry | null
|
||||
searchVisible: boolean
|
||||
toggleSearch: () => void
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
@@ -238,6 +253,8 @@ function useNoteListInteractionState({
|
||||
isArchivedView,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
modifiedFiles,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
@@ -266,6 +283,8 @@ function useNoteListInteractionState({
|
||||
noteListFilter,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -401,7 +420,9 @@ function buildNoteListLayoutModel(params: {
|
||||
filterCounts: ReturnType<typeof useFilterCounts>
|
||||
onNoteListFilterChange: (filter: NoteListFilter) => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
content: ReturnType<typeof useNoteListContent>
|
||||
content: ReturnType<typeof useNoteListContent> & {
|
||||
handleSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}
|
||||
interaction: ReturnType<typeof useNoteListInteractionState> & {
|
||||
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
|
||||
entitySelection: EntitySelection | null
|
||||
@@ -417,13 +438,19 @@ function buildNoteListLayoutModel(params: {
|
||||
sidebarCollapsed: params.sidebarCollapsed,
|
||||
searchVisible: params.content.searchVisible,
|
||||
search: params.content.search,
|
||||
isSearching: params.content.isSearching,
|
||||
searchInputRef: params.content.searchInputRef,
|
||||
propertyPicker: params.content.propertyPicker,
|
||||
handleSortChange: params.content.handleSortChange,
|
||||
handleCreateNote: params.interaction.handleCreateNote,
|
||||
onOpenType: params.onOpenType,
|
||||
toggleSearch: params.content.toggleSearch,
|
||||
setSearch: params.content.setSearch,
|
||||
handleSearchKeyDown: params.content.handleSearchKeyDown,
|
||||
handleListKeyDown: params.interaction.handleListKeyDown,
|
||||
noteListPanelRef: params.interaction.noteListKeyboard.panelRef,
|
||||
handleNoteListPanelBlurCapture: params.interaction.noteListKeyboard.handlePanelBlurCapture,
|
||||
handleNoteListPanelFocusCapture: params.interaction.noteListKeyboard.handlePanelFocusCapture,
|
||||
noteListContainerRef: params.interaction.noteListKeyboard.containerRef,
|
||||
handleNoteListBlur: params.interaction.noteListKeyboard.handleBlur,
|
||||
handleNoteListFocus: params.interaction.noteListKeyboard.handleFocus,
|
||||
@@ -518,6 +545,8 @@ export function useNoteListModel({
|
||||
isArchivedView: content.isArchivedView,
|
||||
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
|
||||
entityEntry: content.entityEntry,
|
||||
searchVisible: content.searchVisible,
|
||||
toggleSearch: content.toggleSearch,
|
||||
modifiedFiles,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
@@ -543,6 +572,27 @@ export function useNoteListModel({
|
||||
multiSelect: interaction.multiSelect,
|
||||
noteListKeyboard: interaction.noteListKeyboard,
|
||||
})
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
|
||||
event.preventDefault()
|
||||
content.closeSearch()
|
||||
requestAnimationFrame(() => {
|
||||
interaction.noteListKeyboard.focusList()
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive)
|
||||
return () => dispatchNoteListSearchAvailability(false)
|
||||
}, [interaction.noteListKeyboard.isPanelActive])
|
||||
|
||||
useEffect(() => {
|
||||
return addNoteListSearchToggleListener(() => {
|
||||
if (!interaction.noteListKeyboard.isPanelActive) return
|
||||
interaction.noteListKeyboard.toggleSearchShortcut()
|
||||
})
|
||||
}, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut])
|
||||
|
||||
return buildNoteListLayoutModel({
|
||||
selection,
|
||||
@@ -553,7 +603,10 @@ export function useNoteListModel({
|
||||
noteListFilter,
|
||||
filterCounts,
|
||||
onNoteListFilterChange,
|
||||
content,
|
||||
content: {
|
||||
...content,
|
||||
handleSearchKeyDown,
|
||||
},
|
||||
interaction: {
|
||||
...interaction,
|
||||
renderItem,
|
||||
|
||||
70
src/components/note-list/useNoteListSearchState.ts
Normal file
70
src/components/note-list/useNoteListSearchState.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const NOTE_LIST_SEARCH_DEBOUNCE_MS = 180
|
||||
|
||||
function normalizeSearch(search: string): string {
|
||||
return search.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function useNoteListSearchState() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const normalizedSearch = normalizeSearch(search)
|
||||
const query = useDeferredValue(debouncedQuery)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDebouncedQuery(normalizedSearch)
|
||||
}, NOTE_LIST_SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [normalizedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchVisible) return
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus()
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frameId)
|
||||
}, [searchVisible])
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearch('')
|
||||
setDebouncedQuery('')
|
||||
}, [])
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
setSearchVisible(true)
|
||||
}, [])
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setSearchVisible(false)
|
||||
clearSearch()
|
||||
}, [clearSearch])
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchVisible((visible) => {
|
||||
if (visible) clearSearch()
|
||||
return !visible
|
||||
})
|
||||
}, [clearSearch])
|
||||
|
||||
const isSearching = normalizedSearch.length > 0
|
||||
&& (normalizedSearch !== debouncedQuery || debouncedQuery !== query)
|
||||
|
||||
return {
|
||||
closeSearch,
|
||||
isSearching,
|
||||
openSearch,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
setSearch,
|
||||
toggleSearch,
|
||||
}
|
||||
}
|
||||
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openExternalUrlMock = vi.fn()
|
||||
|
||||
vi.mock('@/components/ui/action-tooltip', () => ({
|
||||
ActionTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" onClick={onClick} onKeyDown={onKeyDown} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrlMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./useDismissibleLayer', () => ({
|
||||
useDismissibleLayer: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
CommitBadge,
|
||||
ConflictBadge,
|
||||
NoRemoteBadge,
|
||||
SyncBadge,
|
||||
} from './StatusBarBadges'
|
||||
|
||||
describe('StatusBarBadges extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens commit links externally and falls back to a plain hash badge without a URL', () => {
|
||||
const { rerender } = render(
|
||||
<CommitBadge info={{ shortHash: 'abc1234', commitUrl: 'https://example.com/commit/abc1234' }} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-commit-link'))
|
||||
expect(openExternalUrlMock).toHaveBeenCalledWith('https://example.com/commit/abc1234')
|
||||
|
||||
rerender(<CommitBadge info={{ shortHash: 'def5678', commitUrl: null }} />)
|
||||
expect(screen.getByTestId('status-commit-hash')).toHaveTextContent('def5678')
|
||||
})
|
||||
|
||||
it('renders actionable and passive no-remote badges', () => {
|
||||
const onAddRemote = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NoRemoteBadge
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
onAddRemote={onAddRemote}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-no-remote'))
|
||||
expect(onAddRemote).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<NoRemoteBadge
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
|
||||
expect(screen.getByTestId('status-no-remote')).toHaveAttribute(
|
||||
'title',
|
||||
'This git vault has no remote configured. Commits stay local until you add one.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows sync popup details, handles pull actions, and covers no-remote summaries', () => {
|
||||
const onTriggerSync = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SyncBadge
|
||||
status="idle"
|
||||
lastSyncTime={Date.now() - 120_000}
|
||||
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
|
||||
onTriggerSync={onTriggerSync}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
|
||||
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('main')
|
||||
expect(screen.getByText('↑ 2 ahead')).toBeInTheDocument()
|
||||
expect(screen.getByText('↓ 1 behind')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Status: Synced/)).toBeInTheDocument()
|
||||
|
||||
const pullButton = screen.getByTestId('git-status-pull-btn')
|
||||
fireEvent.mouseEnter(pullButton)
|
||||
expect(pullButton.style.background).toBe('var(--hover)')
|
||||
fireEvent.mouseLeave(pullButton)
|
||||
expect(pullButton.style.background).toBe('transparent')
|
||||
|
||||
fireEvent.click(pullButton)
|
||||
expect(onTriggerSync).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SyncBadge
|
||||
status="idle"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={null}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('No remote configured')
|
||||
})
|
||||
|
||||
it('routes conflict and pull-required sync states to their dedicated actions', () => {
|
||||
const onOpenConflictResolver = vi.fn()
|
||||
const onPullAndPush = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SyncBadge
|
||||
status="conflict"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: true }}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onOpenConflictResolver).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SyncBadge
|
||||
status="pull_required"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 2, hasRemote: true }}
|
||||
onPullAndPush={onPullAndPush}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onPullAndPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders clickable conflict badges with plural copy', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ConflictBadge count={2} onClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-conflict-count'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('status-conflict-count')).toHaveTextContent('2 conflicts')
|
||||
})
|
||||
})
|
||||
@@ -104,9 +104,16 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
|
||||
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
|
||||
const unavailable = vault.available === false
|
||||
const removeLabel = `Remove ${vault.label} from list`
|
||||
const itemClassName = [
|
||||
'w-full justify-start rounded-sm px-2 py-1 text-xs font-normal',
|
||||
canRemove ? 'pr-7' : '',
|
||||
isActive
|
||||
? 'text-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-1 rounded-sm">
|
||||
<div className="group relative flex w-full items-center rounded-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -116,17 +123,14 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
className={isActive
|
||||
? 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
}
|
||||
className={itemClassName}
|
||||
style={{
|
||||
height: 'auto',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
<span className="truncate">{vault.label}</span>
|
||||
</span>
|
||||
@@ -143,7 +147,7 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
|
||||
title={removeLabel}
|
||||
aria-label={removeLabel}
|
||||
data-testid={`vault-menu-remove-${vault.label}`}
|
||||
className="rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2 rounded-sm text-muted-foreground opacity-0 pointer-events-none transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:pointer-events-auto group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||
>
|
||||
<X size={10} />
|
||||
</Button>
|
||||
|
||||
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const {
|
||||
blockHasTypeMock,
|
||||
editorHasBlockWithTypeMock,
|
||||
formattingToolbarStore,
|
||||
hoverGuardMock,
|
||||
positionPopoverState,
|
||||
showState,
|
||||
useBlockNoteEditorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
blockHasTypeMock: vi.fn(() => true),
|
||||
editorHasBlockWithTypeMock: vi.fn(() => true),
|
||||
formattingToolbarStore: { setState: vi.fn() },
|
||||
hoverGuardMock: vi.fn(),
|
||||
positionPopoverState: { lastProps: null as null | Record<string, unknown> },
|
||||
showState: { value: true },
|
||||
useBlockNoteEditorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
function MockIcon() {
|
||||
return <svg data-testid="mock-icon" />
|
||||
}
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
FormattingToolbar: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="mock-formatting-toolbar">{children}</div>
|
||||
),
|
||||
getFormattingToolbarItems: () => [
|
||||
<div key="blockTypeSelect" />,
|
||||
<div key="boldStyleButton" />,
|
||||
<div key="italicStyleButton" />,
|
||||
<div key="strikeStyleButton" />,
|
||||
<div key="createLinkButton" />,
|
||||
],
|
||||
PositionPopover: (props: Record<string, unknown> & { children?: ReactNode }) => {
|
||||
positionPopoverState.lastProps = props
|
||||
return <div data-testid="mock-position-popover">{props.children}</div>
|
||||
},
|
||||
useBlockNoteEditor: useBlockNoteEditorMock,
|
||||
useComponentsContext: () => ({
|
||||
FormattingToolbar: {
|
||||
Button: ({
|
||||
children,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
}),
|
||||
useEditorState: ({ editor, selector }: { editor: unknown; selector: (context: { editor: unknown }) => unknown }) => selector({ editor }),
|
||||
useExtension: () => ({ store: formattingToolbarStore }),
|
||||
useExtensionState: () => showState.value,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
blockHasType: blockHasTypeMock,
|
||||
defaultProps: { textAlignment: 'left' },
|
||||
editorHasBlockWithType: editorHasBlockWithTypeMock,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
FormattingToolbarExtension: Symbol('FormattingToolbarExtension'),
|
||||
}))
|
||||
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
|
||||
CheckIcon: () => <span data-testid="mantine-check">check</span>,
|
||||
Menu: Object.assign(
|
||||
({ children }: { children?: ReactNode }) => <div data-testid="mantine-menu">{children}</div>,
|
||||
{
|
||||
Target: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
Dropdown: ({ children, ...props }: { children?: ReactNode }) => <div {...props}>{children}</div>,
|
||||
Item: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Bold: MockIcon,
|
||||
ChevronDown: MockIcon,
|
||||
Code2: MockIcon,
|
||||
Italic: MockIcon,
|
||||
Strikethrough: MockIcon,
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormattingConfig', () => ({
|
||||
filterTolariaFormattingToolbarItems: (items: ReactNode[]) => items,
|
||||
getTolariaBlockTypeSelectItems: () => [
|
||||
{ name: 'Paragraph', type: 'paragraph', props: {}, icon: MockIcon },
|
||||
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: MockIcon },
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('./blockNoteFormattingToolbarHoverGuard', () => ({
|
||||
useBlockNoteFormattingToolbarHoverGuard: hoverGuardMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
TolariaFormattingToolbar,
|
||||
TolariaFormattingToolbarController,
|
||||
} from './tolariaEditorFormatting'
|
||||
|
||||
function createMockEditor(blockType = 'image') {
|
||||
const selectedBlock = {
|
||||
id: 'file-block',
|
||||
type: blockType,
|
||||
props: { textAlignment: 'center', level: 1 },
|
||||
content: [{ type: 'text', text: 'Selected block' }],
|
||||
}
|
||||
|
||||
return {
|
||||
isEditable: true,
|
||||
schema: {
|
||||
styleSchema: {
|
||||
bold: { type: 'bold', propSchema: 'boolean' },
|
||||
italic: { type: 'italic', propSchema: 'boolean' },
|
||||
strike: { type: 'strike', propSchema: 'boolean' },
|
||||
code: { type: 'code', propSchema: 'boolean' },
|
||||
},
|
||||
},
|
||||
prosemirrorState: { selection: { from: 1, to: 5 } },
|
||||
domElement: document.createElement('div'),
|
||||
focus: vi.fn(),
|
||||
getActiveStyles: () => ({ bold: true }),
|
||||
getSelection: () => ({ blocks: [selectedBlock] }),
|
||||
getTextCursorPosition: () => ({ block: selectedBlock }),
|
||||
toggleStyles: vi.fn(),
|
||||
transact: vi.fn((callback: () => void) => callback()),
|
||||
updateBlock: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('tolariaEditorFormatting behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
positionPopoverState.lastProps = null
|
||||
showState.value = true
|
||||
useBlockNoteEditorMock.mockReturnValue(createMockEditor())
|
||||
})
|
||||
|
||||
it('renders toolbar controls, inserts the inline code button, and updates block types', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
|
||||
expect(editor.transact).toHaveBeenCalledTimes(1)
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'file-block' }),
|
||||
{ type: 'heading', props: { level: 1 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('controls the floating toolbar placement, hover guard, and escape-key close behavior', () => {
|
||||
const editor = createMockEditor()
|
||||
const toolbarComponent = () => <div data-testid="custom-toolbar">Toolbar</div>
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={toolbarComponent}
|
||||
floatingUIOptions={{ useFloatingOptions: { placement: 'top-start' } }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument()
|
||||
expect(hoverGuardMock).toHaveBeenCalledWith({
|
||||
editor,
|
||||
container: editor.domElement,
|
||||
selectedFileBlockId: 'file-block',
|
||||
isOpen: true,
|
||||
})
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
open: true,
|
||||
placement: 'top-start',
|
||||
}),
|
||||
}))
|
||||
|
||||
const onOpenChange = positionPopoverState.lastProps?.useFloatingOptions as {
|
||||
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
|
||||
}
|
||||
|
||||
onOpenChange.onOpenChange(false, undefined, 'escape-key')
|
||||
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
expect(editor.focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses block alignment when deciding the floating placement', () => {
|
||||
const editor = createMockEditor()
|
||||
editor.getTextCursorPosition = () => ({
|
||||
block: {
|
||||
id: 'paragraph-block',
|
||||
type: 'paragraph',
|
||||
props: { textAlignment: 'right' },
|
||||
content: [{ type: 'text', text: 'Paragraph' }],
|
||||
},
|
||||
})
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
placement: 'top-end',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('falls back to top-start and focuses the block type trigger on mouse down', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const focusSpy = vi.spyOn(HTMLButtonElement.prototype, 'focus').mockImplementation(() => {})
|
||||
|
||||
blockHasTypeMock.mockReturnValue(false)
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Paragraph' })[0] as HTMLButtonElement)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
placement: 'top-start',
|
||||
}),
|
||||
}))
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the toolbar open during close grace and clears the timeout on unmount', () => {
|
||||
vi.useFakeTimers()
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
const editor = createMockEditor('paragraph')
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
const { rerender, unmount } = render(<TolariaFormattingToolbarController />)
|
||||
|
||||
showState.value = false
|
||||
rerender(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(screen.getByTestId('mock-position-popover')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
|
||||
clearTimeoutSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores internal pointer and focus transitions before closing on external blur', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
|
||||
|
||||
fireEvent.pointerEnter(toolbarWrapper)
|
||||
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
|
||||
fireEvent.focus(toolbarWrapper)
|
||||
fireEvent.blur(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
|
||||
|
||||
expect(screen.getByTestId('toolbar-action')).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: document.body })
|
||||
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
|
||||
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,18 @@ interface UseAiPanelControllerArgs {
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export interface AiPanelController {
|
||||
agent: ReturnType<typeof useCliAiAgent>
|
||||
input: string
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>
|
||||
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
|
||||
hasContext: boolean
|
||||
isActive: boolean
|
||||
handleSend: (text: string, references: NoteReference[]) => void
|
||||
handleNavigateWikilink: (target: string) => void
|
||||
handleNewChat: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
@@ -38,7 +50,7 @@ export function useAiPanelController({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: UseAiPanelControllerArgs) {
|
||||
}: UseAiPanelControllerArgs): AiPanelController {
|
||||
const [input, setInput] = useState('')
|
||||
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
@@ -73,6 +85,11 @@ export function useAiPanelController({
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
agent.clearConversation()
|
||||
setInput('')
|
||||
}, [agent])
|
||||
|
||||
return {
|
||||
agent,
|
||||
input,
|
||||
@@ -82,5 +99,6 @@ export function useAiPanelController({
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
handleNewChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,53 @@ import { useCallback, useEffect } from 'react'
|
||||
interface UseAiPanelFocusArgs {
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
panelRef: React.RefObject<HTMLElement | null>
|
||||
hasMessages: boolean
|
||||
isActive: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function focusPreferredElement(
|
||||
panelRef: React.RefObject<HTMLElement | null>,
|
||||
inputRef: React.RefObject<HTMLDivElement | null>,
|
||||
shouldFocusPanel: boolean,
|
||||
) {
|
||||
if (shouldFocusPanel) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function shouldHandleEscape(
|
||||
event: KeyboardEvent,
|
||||
panelRef: React.RefObject<HTMLElement | null>,
|
||||
): boolean {
|
||||
return event.key === 'Escape' && !!panelRef.current?.contains(document.activeElement)
|
||||
}
|
||||
|
||||
export function useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
hasMessages,
|
||||
isActive,
|
||||
onClose,
|
||||
}: UseAiPanelFocusArgs) {
|
||||
const shouldFocusPanel = hasMessages || isActive
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
const timer = setTimeout(() => {
|
||||
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputRef])
|
||||
}, [inputRef, panelRef, shouldFocusPanel])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef, isActive, panelRef])
|
||||
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
|
||||
}, [inputRef, panelRef, shouldFocusPanel])
|
||||
|
||||
const handleEscape = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (!panelRef.current?.contains(document.activeElement)) return
|
||||
if (!shouldHandleEscape(event, panelRef)) return
|
||||
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
|
||||
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
|
||||
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/editorLinkAutolink', () => ({
|
||||
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
|
||||
}))
|
||||
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
|
||||
function Harness({ editor }: { editor: unknown }) {
|
||||
useFilenameAutolinkGuard(editor as never)
|
||||
return null
|
||||
}
|
||||
|
||||
function createEditor({
|
||||
nodes = [],
|
||||
docChanged = true,
|
||||
withEvents = true,
|
||||
withLinkMark = true,
|
||||
}: {
|
||||
nodes?: Array<{ node: unknown; pos: number }>
|
||||
docChanged?: boolean
|
||||
withEvents?: boolean
|
||||
withLinkMark?: boolean
|
||||
}) {
|
||||
let updateHandler:
|
||||
| ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void)
|
||||
| undefined
|
||||
|
||||
const removeMark = vi.fn()
|
||||
const setMeta = vi.fn()
|
||||
const dispatch = vi.fn()
|
||||
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
|
||||
for (const entry of nodes) {
|
||||
callback(entry.node, entry.pos)
|
||||
}
|
||||
})
|
||||
|
||||
const tiptap = {
|
||||
schema: {
|
||||
marks: {
|
||||
...(withLinkMark ? { link: 'link-mark' } : {}),
|
||||
},
|
||||
},
|
||||
state: {
|
||||
doc: { descendants },
|
||||
tr: {
|
||||
docChanged,
|
||||
removeMark,
|
||||
setMeta,
|
||||
},
|
||||
},
|
||||
...(withEvents
|
||||
? {
|
||||
on: vi.fn((event: string, handler: typeof updateHandler) => {
|
||||
if (event === 'update') {
|
||||
updateHandler = handler
|
||||
}
|
||||
}),
|
||||
off: vi.fn(),
|
||||
}
|
||||
: {}),
|
||||
view: {
|
||||
dispatch,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
editor: {
|
||||
_tiptapEditor: tiptap,
|
||||
},
|
||||
tiptap,
|
||||
descendants,
|
||||
removeMark,
|
||||
setMeta,
|
||||
dispatch,
|
||||
getUpdateHandler: () => updateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFilenameAutolinkGuard extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not subscribe when the editor lacks an event API', () => {
|
||||
const fixture = createEditor({ withEvents: false })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
|
||||
expect('on' in fixture.tiptap).toBe(false)
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips traversal when the editor has no link mark registered', () => {
|
||||
const fixture = createEditor({ withLinkMark: false })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
fixture.getUpdateHandler()?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores text nodes whose marks are not accidental filename links', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(false)
|
||||
const fixture = createEditor({
|
||||
nodes: [
|
||||
{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{ type: 'link-mark', attrs: { href: 'draft.md' } }],
|
||||
},
|
||||
pos: 5,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 6,
|
||||
text: 'notes',
|
||||
marks: [{ type: 'other-mark', attrs: { href: 'notes' } }],
|
||||
},
|
||||
pos: 20,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
isText: false,
|
||||
nodeSize: 3,
|
||||
text: null,
|
||||
marks: [],
|
||||
},
|
||||
pos: 40,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
fixture.getUpdateHandler()?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(shouldStripAutoLinkedLocalFileMarkMock).toHaveBeenCalledWith({
|
||||
href: { raw: 'draft.md' },
|
||||
text: { raw: 'draft.md' },
|
||||
})
|
||||
expect(fixture.removeMark).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
|
||||
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/editorLinkAutolink', () => ({
|
||||
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
|
||||
}))
|
||||
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
|
||||
function Harness({ editor }: { editor: unknown }) {
|
||||
useFilenameAutolinkGuard(editor as never)
|
||||
return null
|
||||
}
|
||||
|
||||
function createEditor({
|
||||
nodes,
|
||||
docChanged = true,
|
||||
}: {
|
||||
nodes: Array<{ node: unknown; pos: number }>
|
||||
docChanged?: boolean
|
||||
}) {
|
||||
let updateHandler: ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void) | undefined
|
||||
const removeMark = vi.fn()
|
||||
const setMeta = vi.fn()
|
||||
const dispatch = vi.fn()
|
||||
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
|
||||
for (const entry of nodes) {
|
||||
callback(entry.node, entry.pos)
|
||||
}
|
||||
})
|
||||
|
||||
const tr = {
|
||||
docChanged,
|
||||
removeMark,
|
||||
setMeta,
|
||||
}
|
||||
|
||||
const tiptap = {
|
||||
schema: {
|
||||
marks: {
|
||||
link: 'link-mark',
|
||||
},
|
||||
},
|
||||
state: {
|
||||
doc: { descendants },
|
||||
tr,
|
||||
},
|
||||
on: vi.fn((event: string, handler: typeof updateHandler) => {
|
||||
if (event === 'update') {
|
||||
updateHandler = handler
|
||||
}
|
||||
}),
|
||||
off: vi.fn(),
|
||||
view: {
|
||||
dispatch,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
editor: {
|
||||
_tiptapEditor: tiptap,
|
||||
},
|
||||
tiptap,
|
||||
tr,
|
||||
descendants,
|
||||
dispatch,
|
||||
getUpdateHandler: () => updateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFilenameAutolinkGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('removes accidental filename link marks and tags the transaction to avoid loops', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
|
||||
const fixture = createEditor({
|
||||
nodes: [{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{
|
||||
type: 'link-mark',
|
||||
attrs: { href: 'draft.md' },
|
||||
}],
|
||||
},
|
||||
pos: 4,
|
||||
}],
|
||||
})
|
||||
|
||||
const { unmount } = render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
expect(updateHandler).toBeTypeOf('function')
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.tr.removeMark).toHaveBeenCalledWith(4, 12, 'link-mark')
|
||||
expect(fixture.tr.setMeta).toHaveBeenCalledWith('tolaria-filename-autolink-guard', true)
|
||||
expect(fixture.dispatch).toHaveBeenCalledWith(fixture.tr)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(fixture.tiptap.off).toHaveBeenCalledWith('update', updateHandler)
|
||||
})
|
||||
|
||||
it('skips guard runs that are already tagged or have no document changes', () => {
|
||||
const fixture = createEditor({ nodes: [] })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: false,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => true),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch when the stripped ranges leave the document unchanged', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
|
||||
const fixture = createEditor({
|
||||
docChanged: false,
|
||||
nodes: [{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{
|
||||
type: 'link-mark',
|
||||
attrs: { href: 'draft.md' },
|
||||
}],
|
||||
},
|
||||
pos: 10,
|
||||
}],
|
||||
})
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.tr.removeMark).toHaveBeenCalledWith(10, 18, 'link-mark')
|
||||
expect(fixture.tr.setMeta).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -79,18 +79,21 @@ function useTrackRawBuffer({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
}: {
|
||||
activeTabContent: string | null
|
||||
activeTabPath: string | null
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTabPath) {
|
||||
rawLatestContentRef.current = null
|
||||
rawInitialContentRef.current = null
|
||||
rawBufferPathRef.current = null
|
||||
rawSourceContentRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,21 +104,25 @@ function useTrackRawBuffer({
|
||||
rawLatestContentRef.current = activeTabContent
|
||||
rawInitialContentRef.current = activeTabContent
|
||||
rawBufferPathRef.current = activeTabContent === null ? null : activeTabPath
|
||||
}, [activeTabContent, activeTabPath, rawBufferPathRef, rawInitialContentRef, rawLatestContentRef])
|
||||
rawSourceContentRef.current = activeTabContent
|
||||
}, [activeTabContent, activeTabPath, rawBufferPathRef, rawInitialContentRef, rawLatestContentRef, rawSourceContentRef])
|
||||
}
|
||||
|
||||
function resetRawBufferState({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
}: {
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
rawInitialContentRef.current = null
|
||||
rawBufferPathRef.current = null
|
||||
rawLatestContentRef.current = null
|
||||
rawSourceContentRef.current = null
|
||||
}
|
||||
|
||||
function useHandleFlushPending({
|
||||
@@ -124,6 +131,7 @@ function useHandleFlushPending({
|
||||
activeTabContent,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
@@ -133,11 +141,13 @@ function useHandleFlushPending({
|
||||
activeTabContent: string | null
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
return useCallback(async () => {
|
||||
rawSourceContentRef.current = activeTabContent
|
||||
const syncedContent = syncActiveTabIntoRawBuffer({
|
||||
editor,
|
||||
activeTabPath,
|
||||
@@ -164,6 +174,7 @@ function useHandleFlushPending({
|
||||
pendingRoundTripRawRestoreRef,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
setRawModeContentOverride,
|
||||
])
|
||||
}
|
||||
@@ -175,6 +186,7 @@ function useHandleBeforeRawEnd({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
@@ -187,6 +199,7 @@ function useHandleBeforeRawEnd({
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRichRestoreRef: React.MutableRefObject<RawEditorPositionSnapshot | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
@@ -205,7 +218,7 @@ function useHandleBeforeRawEnd({
|
||||
onContentChange,
|
||||
}))
|
||||
setRawModeContentOverride(null)
|
||||
resetRawBufferState({ rawInitialContentRef, rawBufferPathRef, rawLatestContentRef })
|
||||
resetRawBufferState({ rawInitialContentRef, rawBufferPathRef, rawLatestContentRef, rawSourceContentRef })
|
||||
}, [
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
@@ -216,11 +229,41 @@ function useHandleBeforeRawEnd({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
setPendingRawExitContent,
|
||||
setRawModeContentOverride,
|
||||
])
|
||||
}
|
||||
|
||||
function useSyncRawModeContentOverride({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
rawSourceContentRef,
|
||||
setRawModeContentOverride,
|
||||
}: {
|
||||
activeTabContent: string | null
|
||||
activeTabPath: string | null
|
||||
rawSourceContentRef: React.MutableRefObject<string | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
const syncRawModeContentOverride = (
|
||||
current: PendingRawExitContent | null,
|
||||
nextContent: string,
|
||||
) => {
|
||||
if (!current) return current
|
||||
if (current.path !== activeTabPath || current.content === nextContent) return current
|
||||
return { path: activeTabPath, content: nextContent }
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTabPath || activeTabContent === null) return
|
||||
if (rawSourceContentRef.current === null || activeTabContent === rawSourceContentRef.current) return
|
||||
const nextContent = activeTabContent
|
||||
|
||||
setRawModeContentOverride((current) => syncRawModeContentOverride(current, nextContent))
|
||||
}, [activeTabContent, activeTabPath, rawSourceContentRef, setRawModeContentOverride])
|
||||
}
|
||||
|
||||
export function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
@@ -230,6 +273,7 @@ export function useRawModeWithFlush(
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const rawInitialContentRef = useRef<string | null>(null)
|
||||
const rawBufferPathRef = useRef<string | null>(null)
|
||||
const rawSourceContentRef = useRef<string | null>(null)
|
||||
const pendingRawRestoreRef = useRef<CodeMirrorRestoreState | null>(null)
|
||||
const pendingRichRestoreRef = useRef<RawEditorPositionSnapshot | null>(null)
|
||||
const pendingRoundTripRawRestoreRef = useRef<PendingRoundTripRawRestore | null>(null)
|
||||
@@ -241,6 +285,13 @@ export function useRawModeWithFlush(
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
})
|
||||
useSyncRawModeContentOverride({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
rawSourceContentRef,
|
||||
setRawModeContentOverride,
|
||||
})
|
||||
|
||||
const handleFlushPending = useHandleFlushPending({
|
||||
@@ -249,6 +300,7 @@ export function useRawModeWithFlush(
|
||||
activeTabContent,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
@@ -260,6 +312,7 @@ export function useRawModeWithFlush(
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
rawSourceContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
|
||||
161
src/extensions/zoomCursorFix.behavior.test.ts
Normal file
161
src/extensions/zoomCursorFix.behavior.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { defineMock } = vi.hoisted(() => ({
|
||||
defineMock: vi.fn((factory: (view: unknown) => unknown) => factory),
|
||||
}))
|
||||
|
||||
vi.mock('@codemirror/view', () => ({
|
||||
EditorView: class EditorView {},
|
||||
ViewPlugin: {
|
||||
define: defineMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
|
||||
|
||||
function mockComputedZoom(value: string) {
|
||||
const real = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
|
||||
const style = real(elt, pseudo)
|
||||
if (elt === document.documentElement) {
|
||||
return new Proxy(style, {
|
||||
get(target, prop) {
|
||||
if (prop === 'zoom') return value
|
||||
const next = Reflect.get(target, prop)
|
||||
return typeof next === 'function' ? next.bind(target) : next
|
||||
},
|
||||
})
|
||||
}
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
function mockInlineZoom(value: string) {
|
||||
return vi.spyOn(document.documentElement.style, 'getPropertyValue').mockImplementation((name: string) => {
|
||||
if (name === 'zoom') return value
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
function createView() {
|
||||
const contentDOM = document.createElement('div')
|
||||
const textNode = document.createTextNode('hello')
|
||||
contentDOM.appendChild(textNode)
|
||||
|
||||
const origPosAtCoords = vi.fn(() => 11)
|
||||
const origPosAndSideAtCoords = vi.fn(() => ({ pos: 13, assoc: -1 as const }))
|
||||
const prototype = {
|
||||
posAtCoords: origPosAtCoords,
|
||||
posAndSideAtCoords: origPosAndSideAtCoords,
|
||||
}
|
||||
|
||||
const view = Object.assign(Object.create(prototype), {
|
||||
contentDOM,
|
||||
posAtDOM: vi.fn(() => 17),
|
||||
})
|
||||
|
||||
return {
|
||||
view,
|
||||
textNode,
|
||||
origPosAtCoords,
|
||||
origPosAndSideAtCoords,
|
||||
}
|
||||
}
|
||||
|
||||
describe('zoomCursorFix behavior', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
delete (document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
||||
}).caretRangeFromPoint
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads inline percentage zoom values when computed zoom is normal', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('125%')
|
||||
|
||||
expect(getDocumentZoom()).toBe(1.25)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to 1 for invalid inline zoom values', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('banana')
|
||||
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses caretRangeFromPoint when CSS zoom is active and restores prototype methods on destroy', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('150%')
|
||||
const { view, textNode } = createView()
|
||||
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ startContainer: textNode, startOffset: 2 })),
|
||||
})
|
||||
|
||||
const pluginFactory = zoomCursorFix() as unknown as (view: typeof view) => { destroy: () => void }
|
||||
const plugin = pluginFactory(view)
|
||||
|
||||
expect(view.posAtCoords({ x: 30, y: 40 }, true)).toBe(17)
|
||||
expect(view.posAndSideAtCoords({ x: 30, y: 40 }, false)).toEqual({ pos: 17, assoc: 1 })
|
||||
expect(view.posAtDOM).toHaveBeenCalledWith(textNode, 2)
|
||||
expect(Object.hasOwn(view, 'posAtCoords')).toBe(true)
|
||||
|
||||
plugin.destroy()
|
||||
|
||||
expect(Object.hasOwn(view, 'posAtCoords')).toBe(false)
|
||||
expect(view.posAtCoords({ x: 5, y: 6 }, false)).toBe(11)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to adjusted coordinates when the browser API is unavailable or returns an unusable range', () => {
|
||||
const computedSpy = mockComputedZoom('normal')
|
||||
const inlineSpy = mockInlineZoom('200%')
|
||||
|
||||
const pluginFactory = zoomCursorFix() as unknown as (view: ReturnType<typeof createView>['view']) => { destroy: () => void }
|
||||
|
||||
const first = createView()
|
||||
pluginFactory(first.view)
|
||||
expect(first.view.posAtCoords({ x: 40, y: 60 }, true)).toBe(11)
|
||||
expect(first.origPosAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, true)
|
||||
expect(first.view.posAndSideAtCoords({ x: 40, y: 60 }, false)).toEqual({ pos: 13, assoc: -1 })
|
||||
expect(first.origPosAndSideAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, false)
|
||||
|
||||
const second = createView()
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ startContainer: document.createTextNode('outside'), startOffset: 0 })),
|
||||
})
|
||||
pluginFactory(second.view)
|
||||
expect(second.view.posAtCoords({ x: 50, y: 70 }, false)).toBe(11)
|
||||
expect(second.origPosAtCoords).toHaveBeenCalledWith({ x: 25, y: 35 }, false)
|
||||
|
||||
const third = createView()
|
||||
third.view.posAtDOM.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ startContainer: third.textNode, startOffset: 1 })),
|
||||
})
|
||||
pluginFactory(third.view)
|
||||
expect(third.view.posAtCoords({ x: 60, y: 80 }, false)).toBe(11)
|
||||
expect(third.origPosAtCoords).toHaveBeenCalledWith({ x: 30, y: 40 }, false)
|
||||
|
||||
inlineSpy.mockRestore()
|
||||
computedSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
113
src/extensions/zoomCursorFix.extra.test.ts
Normal file
113
src/extensions/zoomCursorFix.extra.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
|
||||
|
||||
function mockComputedZoom(value: string) {
|
||||
const realGetComputedStyle = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => {
|
||||
const style = realGetComputedStyle(element, pseudo)
|
||||
if (element === document.documentElement) {
|
||||
return new Proxy(style, {
|
||||
get(target, prop) {
|
||||
if (prop === 'zoom') return value
|
||||
const current = Reflect.get(target, prop)
|
||||
return typeof current === 'function' ? current.bind(target) : current
|
||||
},
|
||||
})
|
||||
}
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
describe('zoomCursorFix extra coverage', () => {
|
||||
let parent: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
parent = document.createElement('div')
|
||||
document.body.appendChild(parent)
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
document.body.innerHTML = ''
|
||||
delete (document as Document & { caretRangeFromPoint?: unknown }).caretRangeFromPoint
|
||||
})
|
||||
|
||||
function createView() {
|
||||
return new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: 'hello world',
|
||||
extensions: [zoomCursorFix()],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
it('falls back to inline zoom values when computed zoom is invalid', () => {
|
||||
mockComputedZoom('not-a-number')
|
||||
const inlineZoomSpy = vi.spyOn(document.documentElement.style, 'getPropertyValue')
|
||||
|
||||
inlineZoomSpy.mockReturnValueOnce('125%')
|
||||
expect(getDocumentZoom()).toBe(1.25)
|
||||
|
||||
inlineZoomSpy.mockReturnValueOnce('-20%')
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
})
|
||||
|
||||
it('uses caretRangeFromPoint for zoomed coordinates and restores prototype methods on destroy', () => {
|
||||
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockReturnValue(99)
|
||||
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockReturnValue({ pos: 99, assoc: -1 })
|
||||
const view = createView()
|
||||
mockComputedZoom('2')
|
||||
|
||||
const textNode = view.contentDOM.querySelector('.cm-line')?.firstChild
|
||||
expect(textNode).toBeTruthy()
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(textNode as Node, 3)
|
||||
range.setEnd(textNode as Node, 3)
|
||||
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => range),
|
||||
})
|
||||
|
||||
expect(view.posAtCoords({ x: 20, y: 30 }, true)).toBe(3)
|
||||
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
|
||||
.posAndSideAtCoords({ x: 20, y: 30 }, false)).toEqual({ pos: 3, assoc: 1 })
|
||||
expect(protoPosAtCoords).not.toHaveBeenCalled()
|
||||
expect(protoPosAndSideAtCoords).not.toHaveBeenCalled()
|
||||
|
||||
view.destroy()
|
||||
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(false)
|
||||
expect(Object.prototype.hasOwnProperty.call(view, 'posAndSideAtCoords')).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to original coordinate methods when zoom is 1 or caret lookup misses', () => {
|
||||
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockImplementation((coords) => (
|
||||
coords.x === 10 && coords.y === 15 ? 11 : 7
|
||||
))
|
||||
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockImplementation((coords) => (
|
||||
coords.x === 10 && coords.y === 15 ? { pos: 11, assoc: -1 } : { pos: 7, assoc: -1 }
|
||||
))
|
||||
const view = createView()
|
||||
|
||||
expect(view.posAtCoords({ x: 8, y: 12 }, true)).toBe(7)
|
||||
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 8, y: 12 }, true)
|
||||
|
||||
mockComputedZoom('2')
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => null),
|
||||
})
|
||||
|
||||
expect(view.posAtCoords({ x: 20, y: 30 }, false)).toBe(11)
|
||||
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, false)
|
||||
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
|
||||
.posAndSideAtCoords({ x: 20, y: 30 }, true)).toEqual({ pos: 11, assoc: -1 })
|
||||
expect(protoPosAndSideAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
import { requestNewAiChat } from '../../utils/aiPromptBridge'
|
||||
|
||||
interface ViewCommandsConfig {
|
||||
hasActiveNote: boolean
|
||||
@@ -34,6 +35,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
|
||||
93
src/hooks/editorFocusUtils.extra.test.ts
Normal file
93
src/hooks/editorFocusUtils.extra.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { focusEditorWithRetries } from './editorFocusUtils'
|
||||
|
||||
describe('editorFocusUtils extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('uses window focus and selection fallback before logging successful focus timing', () => {
|
||||
const editable = document.createElement('div')
|
||||
editable.className = 'ProseMirror'
|
||||
editable.contentEditable = 'true'
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
Object.defineProperty(editable, 'isContentEditable', { configurable: true, value: true })
|
||||
document.body.appendChild(editable)
|
||||
|
||||
const realFocus = HTMLElement.prototype.focus.bind(editable)
|
||||
let focusCalls = 0
|
||||
vi.spyOn(editable, 'focus').mockImplementation(() => {
|
||||
focusCalls += 1
|
||||
if (focusCalls >= 2) realFocus()
|
||||
})
|
||||
|
||||
const selection = {
|
||||
removeAllRanges: vi.fn(),
|
||||
addRange: vi.fn(),
|
||||
} as unknown as Selection
|
||||
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0')
|
||||
const windowFocusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue(selection)
|
||||
vi.spyOn(performance, 'now').mockReturnValue(150)
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
|
||||
focusEditorWithRetries({ focus: vi.fn() }, false, 100)
|
||||
|
||||
expect(windowFocusSpy).toHaveBeenCalled()
|
||||
expect(selection.removeAllRanges).toHaveBeenCalled()
|
||||
expect(selection.addRange).toHaveBeenCalledTimes(1)
|
||||
expect(debugSpy).toHaveBeenCalledWith('[perf] createNote → focus: 50.0ms')
|
||||
})
|
||||
|
||||
it('uses the fallback editable selector and treats mixed heading content as empty text', () => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'bn-editor'
|
||||
const editable = document.createElement('div')
|
||||
editable.contentEditable = 'true'
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
Object.defineProperty(editable, 'isContentEditable', { configurable: true, value: true })
|
||||
wrapper.appendChild(editable)
|
||||
document.body.appendChild(wrapper)
|
||||
|
||||
const realFocus = HTMLElement.prototype.focus.bind(editable)
|
||||
vi.spyOn(editable, 'focus').mockImplementation(() => realFocus())
|
||||
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 1
|
||||
})
|
||||
|
||||
const setTextCursorPosition = vi.fn()
|
||||
|
||||
focusEditorWithRetries({
|
||||
focus: vi.fn(),
|
||||
document: [
|
||||
{
|
||||
id: 'title',
|
||||
type: 'heading',
|
||||
content: [{ kind: 'ignored' }, { type: 'text' }],
|
||||
},
|
||||
],
|
||||
setTextCursorPosition,
|
||||
}, true, undefined)
|
||||
|
||||
expect(rAF).toHaveBeenCalled()
|
||||
expect(setTextCursorPosition).toHaveBeenCalledWith('title', 'start')
|
||||
})
|
||||
|
||||
it('schedules another animation frame when nothing focusable is available yet', () => {
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
|
||||
focusEditorWithRetries({ focus: vi.fn() }, false, undefined)
|
||||
|
||||
expect(rAF).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,13 @@ interface HeadingRange {
|
||||
to: number
|
||||
}
|
||||
|
||||
interface FocusableHeadingBlock {
|
||||
id: string
|
||||
type?: string
|
||||
props?: { level?: number } & Record<string, unknown>
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
@@ -21,6 +28,8 @@ export interface TiptapEditor {
|
||||
export interface FocusableEditor {
|
||||
focus: () => void
|
||||
_tiptapEditor?: TiptapEditor
|
||||
document?: FocusableHeadingBlock[]
|
||||
setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void
|
||||
}
|
||||
|
||||
function buildHeadingRange(pos: number, nodeSize: number): HeadingRange | null {
|
||||
@@ -42,7 +51,38 @@ function findFirstHeadingRange(tiptap: TiptapEditor): HeadingRange | null {
|
||||
return range
|
||||
}
|
||||
|
||||
function isTopLevelHeadingBlock(block: FocusableHeadingBlock): boolean {
|
||||
return block.type === 'heading' && (block.props?.level === undefined || block.props?.level === 1)
|
||||
}
|
||||
|
||||
function getHeadingBlockText(block: FocusableHeadingBlock | undefined): string {
|
||||
if (!Array.isArray(block?.content)) return ''
|
||||
|
||||
return block.content
|
||||
.filter((item): item is { type?: string; text?: string } => (
|
||||
typeof item === 'object' && item !== null
|
||||
))
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text ?? '')
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getFirstHeadingBlock(editor: FocusableEditor): FocusableHeadingBlock | undefined {
|
||||
return editor.document?.find(isTopLevelHeadingBlock)
|
||||
}
|
||||
|
||||
function trySelectEmptyFirstHeading(editor: FocusableEditor): boolean {
|
||||
const firstHeadingBlock = getFirstHeadingBlock(editor)
|
||||
if (!firstHeadingBlock || !editor.setTextCursorPosition) return false
|
||||
if (getHeadingBlockText(firstHeadingBlock)) return false
|
||||
|
||||
editor.setTextCursorPosition(firstHeadingBlock.id, 'start')
|
||||
return true
|
||||
}
|
||||
|
||||
function trySelectFirstHeading(editor: FocusableEditor): boolean {
|
||||
if (trySelectEmptyFirstHeading(editor)) return true
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return false
|
||||
|
||||
|
||||
62
src/hooks/rawEditorEntryState.ts
Normal file
62
src/hooks/rawEditorEntryState.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { frontmatterToEntryPatch } from './frontmatterOps'
|
||||
|
||||
function createRawEditorEntryState(): Partial<VaultEntry> {
|
||||
return {
|
||||
aliases: [],
|
||||
archived: false,
|
||||
belongsTo: [],
|
||||
color: null,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
icon: null,
|
||||
isA: null,
|
||||
listPropertiesDisplay: [],
|
||||
order: null,
|
||||
organized: false,
|
||||
properties: {},
|
||||
relatedTo: [],
|
||||
relationships: {},
|
||||
sidebarLabel: null,
|
||||
sort: null,
|
||||
status: null,
|
||||
template: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRelationships(target: Record<string, string[]>, source: Record<string, string[] | null> | null): void {
|
||||
if (!source) return
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (Array.isArray(value) && value.length > 0) target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProperties(
|
||||
target: Record<string, string | number | boolean | null>,
|
||||
source: Record<string, string | number | boolean | null> | null,
|
||||
): void {
|
||||
if (!source) return
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value !== null) target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveRawEditorEntryState(content: string): Partial<VaultEntry> {
|
||||
const derived = createRawEditorEntryState()
|
||||
const properties: Record<string, string | number | boolean | null> = {}
|
||||
const relationships: Record<string, string[]> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(parseFrontmatter(content))) {
|
||||
const { patch, relationshipPatch, propertiesPatch } = frontmatterToEntryPatch('update', key, value)
|
||||
Object.assign(derived, patch)
|
||||
mergeRelationships(relationships, relationshipPatch)
|
||||
mergeProperties(properties, propertiesPatch)
|
||||
}
|
||||
|
||||
derived.properties = properties
|
||||
derived.relationships = relationships
|
||||
return derived
|
||||
}
|
||||
201
src/hooks/useAutoSync.extra.test.ts
Normal file
201
src/hooks/useAutoSync.extra.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAutoSync } from './useAutoSync'
|
||||
import type { GitPullResult, GitRemoteStatus } from '../types'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
const LAST_COMMIT_INFO = {
|
||||
shortHash: 'a1b2c3d',
|
||||
commitUrl: 'https://github.com/owner/repo/commit/abc123',
|
||||
}
|
||||
|
||||
const REMOTE_STATUS: GitRemoteStatus = {
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
}
|
||||
|
||||
function upToDate(): GitPullResult {
|
||||
return {
|
||||
status: 'up_to_date',
|
||||
message: 'Already up to date',
|
||||
updatedFiles: [],
|
||||
conflictFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function updated(files: string[]): GitPullResult {
|
||||
return {
|
||||
status: 'updated',
|
||||
message: `${files.length} files updated`,
|
||||
updatedFiles: files,
|
||||
conflictFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function conflict(files: string[]): GitPullResult {
|
||||
return {
|
||||
status: 'conflict',
|
||||
message: 'Merge conflicts detected',
|
||||
updatedFiles: [],
|
||||
conflictFiles: files,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMockImplementation(command: string) {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
|
||||
return Promise.resolve(upToDate())
|
||||
}
|
||||
|
||||
function renderSync(overrides: Partial<Parameters<typeof useAutoSync>[0]> = {}) {
|
||||
const onVaultUpdated = vi.fn()
|
||||
const onSyncUpdated = vi.fn()
|
||||
const onConflict = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
const hook = renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
...overrides,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
...hook,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForInitialIdle(
|
||||
result: ReturnType<typeof renderSync>['result'],
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
expect(result.current.remoteStatus).toEqual(REMOTE_STATUS)
|
||||
})
|
||||
mockInvokeFn.mockClear()
|
||||
}
|
||||
|
||||
describe('useAutoSync extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(defaultMockImplementation)
|
||||
})
|
||||
|
||||
it('pulls, pushes, and refreshes remote status after a recovery sync', async () => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve({ ...REMOTE_STATUS, behind: 1 })
|
||||
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
|
||||
return Promise.resolve(updated(['notes/today.md']))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'])
|
||||
expect(hook.onSyncUpdated).toHaveBeenCalledOnce()
|
||||
expect(hook.onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
|
||||
expect(hook.result.current.syncStatus).toBe('idle')
|
||||
expect(hook.result.current.remoteStatus).toEqual({ ...REMOTE_STATUS, behind: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces conflicts from pullAndPush without attempting a push', async () => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
|
||||
return Promise.resolve(conflict(['plans/weekly.md']))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.onConflict).toHaveBeenCalledWith(['plans/weekly.md'])
|
||||
expect(hook.result.current.syncStatus).toBe('conflict')
|
||||
expect(hook.result.current.conflictFiles).toEqual(['plans/weekly.md'])
|
||||
})
|
||||
expect(
|
||||
mockInvokeFn.mock.calls.some(([command]) => command === 'git_push'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'marks pull_required when the follow-up push is rejected',
|
||||
pushResult: { status: 'rejected', message: 'Remote advanced again' },
|
||||
expectedStatus: 'pull_required',
|
||||
expectedToast: 'Push still rejected after pull — try again',
|
||||
},
|
||||
{
|
||||
name: 'surfaces follow-up push errors',
|
||||
pushResult: { status: 'network_error', message: 'Push failed: offline' },
|
||||
expectedStatus: 'error',
|
||||
expectedToast: 'Push failed: offline',
|
||||
},
|
||||
])('$name', async ({ pushResult, expectedStatus, expectedToast }) => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
|
||||
if (command === 'get_conflict_files') return Promise.resolve([])
|
||||
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
|
||||
if (command === 'git_push') return Promise.resolve(pushResult)
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.syncStatus).toBe(expectedStatus)
|
||||
expect(hook.onToast).toHaveBeenCalledWith(expectedToast)
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes a manual pull-required state for rejected save flows', async () => {
|
||||
const hook = renderSync()
|
||||
await waitForInitialIdle(hook.result)
|
||||
|
||||
act(() => {
|
||||
hook.result.current.handlePushRejected()
|
||||
})
|
||||
|
||||
expect(hook.result.current.syncStatus).toBe('pull_required')
|
||||
})
|
||||
})
|
||||
@@ -75,12 +75,47 @@ describe('useAutoSync', () => {
|
||||
const { result } = renderSync()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultUpdated).toHaveBeenCalled()
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'])
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote')
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for vault refresh before showing the updated toast', async () => {
|
||||
let releaseVaultRefresh: (() => void) | null = null
|
||||
const asyncVaultRefresh = vi.fn(() => new Promise<void>((resolve) => {
|
||||
releaseVaultRefresh = resolve
|
||||
}))
|
||||
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
return Promise.resolve(updated(['note.md']))
|
||||
})
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated: asyncVaultRefresh,
|
||||
onConflict,
|
||||
onToast,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'])
|
||||
})
|
||||
expect(onToast).not.toHaveBeenCalledWith('Pulled 1 update(s) from remote')
|
||||
|
||||
await act(async () => {
|
||||
releaseVaultRefresh?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled 1 update(s) from remote')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onConflict and sets conflict status when pull has conflicts', async () => {
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
@@ -324,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
|
||||
type MaybePromise = void | Promise<void>
|
||||
|
||||
type SyncCallbacks = Pick<UseAutoSyncOptions, 'onVaultUpdated' | 'onSyncUpdated' | 'onConflict' | 'onToast'>
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
@@ -13,8 +19,8 @@ function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
interface UseAutoSyncOptions {
|
||||
vaultPath: string
|
||||
intervalMinutes: number | null
|
||||
onVaultUpdated: () => void
|
||||
onSyncUpdated?: () => void
|
||||
onVaultUpdated: (updatedFiles: string[]) => MaybePromise
|
||||
onSyncUpdated?: () => MaybePromise
|
||||
onConflict: (files: string[]) => void
|
||||
onToast: (msg: string) => void
|
||||
}
|
||||
@@ -36,6 +42,214 @@ export interface AutoSyncState {
|
||||
handlePushRejected: () => void
|
||||
}
|
||||
|
||||
type SyncSetState<T> = Dispatch<SetStateAction<T>>
|
||||
|
||||
interface PullErrorResolution {
|
||||
checkExistingConflicts: () => Promise<boolean>
|
||||
notifyError?: string
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}
|
||||
|
||||
interface SyncTaskOptions {
|
||||
blockWhenPaused: boolean
|
||||
pauseRef: MutableRefObject<boolean>
|
||||
syncingRef: MutableRefObject<boolean>
|
||||
setLastSyncTime: SyncSetState<number | null>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
task: () => Promise<void>
|
||||
}
|
||||
|
||||
function clearConflictState(
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
): void {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
}
|
||||
|
||||
function setConflictState(
|
||||
files: string[],
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
): void {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(files)
|
||||
void callbacksRef.current.onConflict(files)
|
||||
}
|
||||
|
||||
function markPullTimestamp(
|
||||
setLastSyncTime: SyncSetState<number | null>,
|
||||
refreshCommitInfo: () => void,
|
||||
): void {
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
}
|
||||
|
||||
function useRemoteStatusRefresher(
|
||||
vaultPath: string,
|
||||
setRemoteStatus: SyncSetState<GitRemoteStatus | null>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [vaultPath, setRemoteStatus])
|
||||
}
|
||||
|
||||
function useConflictChecker(
|
||||
vaultPath: string,
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
) {
|
||||
return useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
|
||||
if (!Array.isArray(files) || files.length === 0) return false
|
||||
setConflictState(files, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [vaultPath, setSyncStatus, setConflictFiles, callbacksRef])
|
||||
}
|
||||
|
||||
function useCommitInfoRefresher(
|
||||
vaultPath: string,
|
||||
setLastCommitInfo: SyncSetState<LastCommitInfo | null>,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
}, [vaultPath, setLastCommitInfo])
|
||||
}
|
||||
|
||||
async function handleUpdatedPull(options: {
|
||||
result: GitPullResult
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
result,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
await callbacksRef.current.onVaultUpdated(result.updatedFiles)
|
||||
await callbacksRef.current.onSyncUpdated?.()
|
||||
await callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
||||
}
|
||||
|
||||
async function resolvePullError(options: PullErrorResolution): Promise<void> {
|
||||
const {
|
||||
checkExistingConflicts,
|
||||
notifyError,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (hasConflicts) return
|
||||
setSyncStatus('error')
|
||||
if (notifyError) await callbacksRef.current.onToast(notifyError)
|
||||
}
|
||||
|
||||
function handlePushResult(options: {
|
||||
pushResult: GitPushResult
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): void {
|
||||
const {
|
||||
pushResult,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
if (pushResult.status === 'ok') {
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
void callbacksRef.current.onToast('Pulled and pushed successfully')
|
||||
return
|
||||
}
|
||||
if (pushResult.status === 'rejected') {
|
||||
setSyncStatus('pull_required')
|
||||
void callbacksRef.current.onToast('Push still rejected after pull — try again')
|
||||
return
|
||||
}
|
||||
setSyncStatus('error')
|
||||
void callbacksRef.current.onToast(pushResult.message)
|
||||
}
|
||||
|
||||
async function runSyncTask(options: SyncTaskOptions): Promise<void> {
|
||||
const {
|
||||
blockWhenPaused,
|
||||
pauseRef,
|
||||
syncingRef,
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task,
|
||||
} = options
|
||||
if (syncingRef.current || (blockWhenPaused && pauseRef.current)) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
|
||||
try {
|
||||
await task()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function useAutoSyncLifecycle(options: {
|
||||
checkExistingConflicts: () => Promise<boolean>
|
||||
intervalMinutes: number | null
|
||||
performPull: () => Promise<void>
|
||||
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
|
||||
}) {
|
||||
const {
|
||||
checkExistingConflicts,
|
||||
intervalMinutes,
|
||||
performPull,
|
||||
refreshRemoteStatus,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
void checkExistingConflicts().then(hasConflicts => {
|
||||
if (!hasConflicts) void performPull()
|
||||
})
|
||||
void refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastPullTimeRef.current = now
|
||||
void performPull()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [performPull])
|
||||
|
||||
useEffect(() => {
|
||||
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
|
||||
const id = setInterval(() => { void performPull() }, ms)
|
||||
return () => clearInterval(id)
|
||||
}, [performPull, intervalMinutes])
|
||||
}
|
||||
|
||||
export function useAutoSync({
|
||||
vaultPath,
|
||||
intervalMinutes,
|
||||
@@ -51,178 +265,111 @@ export function useAutoSync({
|
||||
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
|
||||
const syncingRef = useRef(false)
|
||||
const pauseRef = useRef(false)
|
||||
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
||||
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
||||
|
||||
const refreshRemoteStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
|
||||
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
|
||||
if (files.length > 0) {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(files)
|
||||
callbacksRef.current.onConflict(files)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// If the command doesn't exist (e.g. browser mock), ignore
|
||||
}
|
||||
return false
|
||||
}, [vaultPath])
|
||||
|
||||
const refreshCommitInfo = useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
}, [vaultPath])
|
||||
const callbacksRef = useRef<SyncCallbacks>({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
||||
}, [onVaultUpdated, onSyncUpdated, onConflict, onToast])
|
||||
const refreshRemoteStatus = useRemoteStatusRefresher(vaultPath, setRemoteStatus)
|
||||
const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo)
|
||||
|
||||
const performPull = useCallback(async () => {
|
||||
if (syncingRef.current || pauseRef.current) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
await runSyncTask({
|
||||
blockWhenPaused: true,
|
||||
pauseRef,
|
||||
syncingRef,
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task: async () => {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
|
||||
|
||||
try {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
|
||||
if (result.status === 'updated') {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
||||
} else if (result.status === 'conflict') {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(result.conflictFiles)
|
||||
callbacksRef.current.onConflict(result.conflictFiles)
|
||||
} else if (result.status === 'error') {
|
||||
// Pull failed — check if there are pre-existing conflicts that caused it
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (!hasConflicts) {
|
||||
setSyncStatus('error')
|
||||
if (result.status === 'updated') {
|
||||
await handleUpdatedPull({
|
||||
result,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
})
|
||||
} else if (result.status === 'conflict') {
|
||||
setConflictState(result.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
} else if (result.status === 'error') {
|
||||
await resolvePullError({
|
||||
checkExistingConflicts,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
})
|
||||
} else {
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
}
|
||||
} else {
|
||||
// up_to_date or no_remote
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
}
|
||||
|
||||
// Refresh remote status after pull
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
void refreshRemoteStatus()
|
||||
},
|
||||
})
|
||||
}, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
|
||||
|
||||
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
|
||||
const pullAndPush = useCallback(async () => {
|
||||
if (syncingRef.current) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
await runSyncTask({
|
||||
blockWhenPaused: false,
|
||||
pauseRef,
|
||||
syncingRef,
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task: async () => {
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
|
||||
|
||||
try {
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
|
||||
if (pullResult.status === 'conflict') {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(pullResult.conflictFiles)
|
||||
callbacksRef.current.onConflict(pullResult.conflictFiles)
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'error') {
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (!hasConflicts) {
|
||||
setSyncStatus('error')
|
||||
callbacksRef.current.onToast('Pull failed: ' + pullResult.message)
|
||||
if (pullResult.status === 'conflict') {
|
||||
setConflictState(pullResult.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'updated') {
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
if (pullResult.status === 'error') {
|
||||
await resolvePullError({
|
||||
checkExistingConflicts,
|
||||
notifyError: `Pull failed: ${pullResult.message}`,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Now push
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
||||
if (pushResult.status === 'ok') {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
callbacksRef.current.onToast('Pulled and pushed successfully')
|
||||
} else if (pushResult.status === 'rejected') {
|
||||
// Still diverged — shouldn't happen after pull but handle gracefully
|
||||
setSyncStatus('pull_required')
|
||||
callbacksRef.current.onToast('Push still rejected after pull — try again')
|
||||
} else {
|
||||
setSyncStatus('error')
|
||||
callbacksRef.current.onToast(pushResult.message)
|
||||
}
|
||||
if (pullResult.status === 'updated') {
|
||||
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles)
|
||||
await callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
||||
handlePushResult({
|
||||
pushResult,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
})
|
||||
|
||||
void refreshRemoteStatus()
|
||||
},
|
||||
})
|
||||
}, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
|
||||
|
||||
const handlePushRejected = useCallback(() => {
|
||||
setSyncStatus('pull_required')
|
||||
}, [])
|
||||
|
||||
// Check for pre-existing conflicts on mount, then pull
|
||||
useEffect(() => {
|
||||
checkExistingConflicts().then(hasConflicts => {
|
||||
if (!hasConflicts) performPull()
|
||||
})
|
||||
refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastPullTimeRef.current = now
|
||||
performPull()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [performPull])
|
||||
|
||||
// Periodic pull
|
||||
useEffect(() => {
|
||||
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
|
||||
const id = setInterval(performPull, ms)
|
||||
return () => clearInterval(id)
|
||||
}, [performPull, intervalMinutes])
|
||||
useAutoSyncLifecycle({
|
||||
checkExistingConflicts,
|
||||
intervalMinutes,
|
||||
performPull,
|
||||
refreshRemoteStatus,
|
||||
})
|
||||
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
const triggerSync = useCallback(() => {
|
||||
trackEvent('sync_triggered')
|
||||
performPull()
|
||||
void performPull()
|
||||
}, [performPull])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
|
||||
function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -220,6 +221,24 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('includes a New AI chat command that opens and resets the panel session', () => {
|
||||
const config = makeConfig()
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'new-ai-chat')
|
||||
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.label).toBe('New AI chat')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
|
||||
cmd!.execute()
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: NEW_AI_CHAT_EVENT }))
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: OPEN_AI_CHAT_EVENT }))
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('omits Inbox navigation when the explicit workflow is disabled', () => {
|
||||
const config = makeConfig({ showInbox: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
@@ -357,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', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useEditorFocus } from './useEditorFocus'
|
||||
import type { FocusableEditor } from './editorFocusUtils'
|
||||
|
||||
function makeTiptapMock(hasHeading: boolean | Array<number | null> = true, headingNodeSize = 15) {
|
||||
const headingSizes = Array.isArray(hasHeading)
|
||||
@@ -36,13 +37,20 @@ describe('useEditorFocus', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
||||
function setup(
|
||||
isMounted: boolean,
|
||||
tiptap?: ReturnType<typeof makeTiptapMock>,
|
||||
editorOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
document.body.appendChild(editable)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(() => editable.focus()), _tiptapEditor: tiptap } as any
|
||||
const editor = {
|
||||
focus: vi.fn(() => editable.focus()),
|
||||
_tiptapEditor: tiptap,
|
||||
...editorOverrides,
|
||||
} as FocusableEditor & Record<string, unknown>
|
||||
const mountedRef = { current: isMounted }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
return { editor, tiptap, editable }
|
||||
@@ -186,6 +194,25 @@ describe('useEditorFocus', () => {
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places a text cursor inside an empty H1 instead of selecting through the next block', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const setTextCursorPosition = vi.fn()
|
||||
const { editor } = setup(true, tiptap, {
|
||||
document: [
|
||||
{ id: 'title', type: 'heading', props: { level: 1 }, content: [] },
|
||||
{ id: 'body', type: 'paragraph', props: {}, content: [] },
|
||||
],
|
||||
setTextCursorPosition,
|
||||
})
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(setTextCursorPosition).toHaveBeenCalledWith('title', 'start')
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects H1 text after timeout when editor not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
// Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus
|
||||
|
||||
@@ -157,10 +157,10 @@ describe('useEditorSaveWithLinks', () => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Project',
|
||||
status: 'Active',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
@@ -185,9 +185,9 @@ describe('useEditorSaveWithLinks', () => {
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Essay',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
@@ -196,15 +196,66 @@ describe('useEditorSaveWithLinks', () => {
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Note',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs custom relationships and properties from raw frontmatter immediately', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
properties: { Owner: '[[person/alice]]', custom: 'value' },
|
||||
relationships: { Owner: ['[[person/alice]]'] },
|
||||
}))
|
||||
})
|
||||
|
||||
it('clears stale note-list and inspector metadata when raw frontmatter is removed', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
|
||||
})
|
||||
|
||||
updateEntry.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'Body without frontmatter')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
belongsTo: [],
|
||||
properties: {},
|
||||
relationships: {},
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps the last derived entry state while the raw frontmatter is temporarily incomplete', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\n---\nBody')
|
||||
})
|
||||
|
||||
updateEntry.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/old-title.md', '# Renamed Note\n\nBody', { title: 'Renamed Note', hasH1: true }],
|
||||
['/renamed-note.md', 'Body without a heading', { title: 'Renamed Note', hasH1: false }],
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { startTransition, useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import { contentToEntryPatch } from './frontmatterOps'
|
||||
import { deriveRawEditorEntryState } from './rawEditorEntryState'
|
||||
import { deriveDisplayTitleState } from '../utils/noteTitle'
|
||||
import { detectFrontmatterState } from '../utils/frontmatter'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const EMPTY_DERIVED_ENTRY_STATE_KEY = JSON.stringify(deriveRawEditorEntryState(''))
|
||||
|
||||
function shouldSyncFrontmatterState(content: string): boolean {
|
||||
const frontmatterState = detectFrontmatterState(content)
|
||||
if (frontmatterState === 'invalid') return false
|
||||
return !(frontmatterState === 'none' && content.startsWith('---\n'))
|
||||
}
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
@@ -34,7 +43,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
})
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)
|
||||
const prevTitleKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
@@ -44,19 +53,23 @@ export function useEditorSaveWithLinks(config: {
|
||||
prevLinksKeyRef.current = key
|
||||
updateEntry(path, { outgoingLinks: links })
|
||||
}
|
||||
const frontmatterPatch = contentToEntryPatch(content)
|
||||
const frontmatterPatch = shouldSyncFrontmatterState(content)
|
||||
? deriveRawEditorEntryState(content)
|
||||
: null
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const titlePatch = deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
frontmatterTitle: typeof frontmatterPatch?.title === 'string' ? frontmatterPatch.title : null,
|
||||
})
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
|
||||
if (frontmatterPatch) {
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
updateEntry(path, fmPatch)
|
||||
}
|
||||
}
|
||||
const titleKey = JSON.stringify(titlePatch)
|
||||
if (titleKey !== prevTitleKeyRef.current) {
|
||||
|
||||
@@ -10,6 +10,7 @@ function makeTab(path: string, title: string, body: string) {
|
||||
}
|
||||
|
||||
function makeMockEditor(currentMarkdown: string) {
|
||||
const markdownRef = { current: currentMarkdown }
|
||||
const docRef = {
|
||||
current: [
|
||||
{
|
||||
@@ -30,58 +31,95 @@ function makeMockEditor(currentMarkdown: string) {
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
replaceBlocks: vi.fn(),
|
||||
insertBlocks: vi.fn(),
|
||||
blocksToMarkdownLossy: vi.fn(() => currentMarkdown),
|
||||
blocksToMarkdownLossy: vi.fn(() => markdownRef.current),
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
tryParseMarkdownToBlocks: vi.fn(() => []),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
setMarkdown: (markdown: string) => {
|
||||
markdownRef.current = markdown
|
||||
},
|
||||
}
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function setupMountedEditorMocks() {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
}
|
||||
|
||||
function renderRenameHarness(options?: { onContentChange?: ReturnType<typeof vi.fn> }) {
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = options?.onContentChange ?? vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const hook = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
)
|
||||
|
||||
return {
|
||||
editor,
|
||||
onContentChange,
|
||||
untitledTab,
|
||||
renamedTab,
|
||||
...hook,
|
||||
}
|
||||
}
|
||||
|
||||
async function settleRenameHarness(editor: ReturnType<typeof makeMockEditor>) {
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
}
|
||||
|
||||
async function expectRenameSessionContinues(options: { renamedTabArrivesLate: boolean }) {
|
||||
const {
|
||||
editor,
|
||||
onContentChange,
|
||||
renamedTab,
|
||||
result,
|
||||
rerender,
|
||||
untitledTab,
|
||||
} = renderRenameHarness()
|
||||
|
||||
await settleRenameHarness(editor)
|
||||
|
||||
if (options.renamedTabArrivesLate) {
|
||||
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
}
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
it('keeps the live editor session when an untitled note auto-renames', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
)
|
||||
setupMountedEditorMocks()
|
||||
await expectRenameSessionContinues({ renamedTabArrivesLate: false })
|
||||
})
|
||||
|
||||
it('still swaps when the next note does not match the live untitled body', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
setupMountedEditorMocks()
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
@@ -107,13 +145,16 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
})
|
||||
|
||||
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
setupMountedEditorMocks()
|
||||
await expectRenameSessionContinues({ renamedTabArrivesLate: true })
|
||||
})
|
||||
|
||||
it('does not re-swap the active note when app state catches up with live typing', async () => {
|
||||
setupMountedEditorMocks()
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
const tab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
@@ -122,29 +163,83 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
{ initialProps: { tabs: [tab], activeTabPath: tab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
const syncedContent = onContentChange.mock.calls.at(-1)?.[1]
|
||||
expect(typeof syncedContent).toBe('string')
|
||||
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({
|
||||
tabs: [{ ...tab, content: syncedContent }],
|
||||
activeTabPath: tab.entry.path,
|
||||
})
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-swap while local wikilink insertion is ahead of the latest tab props', async () => {
|
||||
setupMountedEditorMocks()
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody')
|
||||
const onContentChange = vi.fn()
|
||||
const tab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [tab], activeTabPath: tab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
editor.setMarkdown('# Fresh Title\n\nBody\n\n[[Mana')
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
const queryContent = onContentChange.mock.calls.at(-1)?.[1]
|
||||
expect(typeof queryContent).toBe('string')
|
||||
|
||||
editor.setMarkdown('# Fresh Title\n\nBody\n\n[[manage-sponsorships]] ')
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
const insertedContent = onContentChange.mock.calls.at(-1)?.[1]
|
||||
expect(typeof insertedContent).toBe('string')
|
||||
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({
|
||||
tabs: [{ ...tab, content: queryContent }],
|
||||
activeTabPath: tab.entry.path,
|
||||
})
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
rerender({
|
||||
tabs: [{ ...tab, content: insertedContent }],
|
||||
activeTabPath: tab.entry.path,
|
||||
})
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -361,6 +361,28 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-parses when the active tab content changes without a path change', async () => {
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const refreshedTabA = {
|
||||
...tabA,
|
||||
content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh after pull.',
|
||||
}
|
||||
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' })
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Fresh after pull.'),
|
||||
)
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Tab {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
type EditorBlocks = any[]
|
||||
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
|
||||
type PendingLocalContent = { path: string; content: string }
|
||||
const TAB_STATE_CACHE_LIMIT = 24
|
||||
|
||||
interface TabSwapState {
|
||||
@@ -372,6 +373,8 @@ function useEditorChangeHandler(options: {
|
||||
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
editor,
|
||||
@@ -379,6 +382,8 @@ function useEditorChangeHandler(options: {
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
return useCallback(() => {
|
||||
@@ -393,8 +398,15 @@ function useEditorChangeHandler(options: {
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`)
|
||||
}, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef])
|
||||
const nextContent = `${frontmatter}${bodyMarkdown}`
|
||||
pendingLocalContentRef.current = { path, content: nextContent }
|
||||
cacheEditorState(tabCacheRef.current, path, {
|
||||
blocks,
|
||||
scrollTop: readEditorScrollTop(),
|
||||
sourceContent: nextContent,
|
||||
})
|
||||
onContentChangeRef.current?.(path, nextContent)
|
||||
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef])
|
||||
}
|
||||
|
||||
function consumeRawModeTransition(
|
||||
@@ -481,6 +493,113 @@ function syncActivePathTransition(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
function markRawModeReswapPending(options: {
|
||||
activeTabPath: string | null
|
||||
cache: Map<string, CachedTabState>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const { activeTabPath, cache, rawSwapPendingRef } = options
|
||||
if (!activeTabPath) return false
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
return true
|
||||
}
|
||||
|
||||
function currentEditorMatchesActiveTab(options: {
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
} = options
|
||||
|
||||
return Boolean(
|
||||
activeTabPath
|
||||
&& activeTab
|
||||
&& editorMountedRef.current
|
||||
&& typeof editor.blocksToMarkdownLossy === 'function'
|
||||
&& serializeEditorBody(editor) === normalizeTabBody({ content: activeTab.content }),
|
||||
)
|
||||
}
|
||||
|
||||
function cacheStableActiveTabAndClearPending(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
cacheStableActivePath({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
})
|
||||
pendingLocalContentRef.current = null
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldKeepPendingLocalContent(options: {
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
const pendingLocalContent = pendingLocalContentRef.current
|
||||
if (!activeTabPath || !activeTab || pendingLocalContent?.path !== activeTabPath) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function consumePendingLocalContent(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
const pendingLocalContent = pendingLocalContentRef.current
|
||||
if (!pendingLocalContent || pendingLocalContent.content !== activeTab?.content) return true
|
||||
return cacheStableActiveTabAndClearPending({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
|
||||
function handleStableActivePath(options: {
|
||||
pathChanged: boolean
|
||||
rawModeJustEnded: boolean
|
||||
@@ -490,6 +609,7 @@ function handleStableActivePath(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
pathChanged,
|
||||
@@ -500,14 +620,34 @@ function handleStableActivePath(options: {
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
if (pathChanged) return false
|
||||
if (rawModeJustEnded && activeTabPath) {
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
return false
|
||||
if (rawModeJustEnded) {
|
||||
return !markRawModeReswapPending({ activeTabPath, cache, rawSwapPendingRef })
|
||||
}
|
||||
if (currentEditorMatchesActiveTab({ activeTabPath, activeTab, editor, editorMountedRef })) {
|
||||
return cacheStableActiveTabAndClearPending({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
if (shouldKeepPendingLocalContent({ activeTabPath, activeTab, pendingLocalContentRef })) {
|
||||
return consumePendingLocalContent({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
if (shouldRefreshStableActivePath({ activeTabPath, activeTab, cache })) return false
|
||||
if (rawSwapPendingRef.current) return true
|
||||
|
||||
cacheStableActivePath({
|
||||
@@ -520,6 +660,22 @@ function handleStableActivePath(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldRefreshStableActivePath(options: {
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
cache: Map<string, CachedTabState>
|
||||
}): boolean {
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
} = options
|
||||
|
||||
if (!activeTabPath || !activeTab) return false
|
||||
const cachedState = cache.get(activeTabPath)
|
||||
return !cachedState || cachedState.sourceContent !== activeTab.content
|
||||
}
|
||||
|
||||
function cacheStableActivePath(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
activeTabPath: string | null
|
||||
@@ -779,6 +935,7 @@ function shouldSkipScheduledTabSwap(options: {
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
@@ -787,8 +944,13 @@ function shouldSkipScheduledTabSwap(options: {
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
if (state.pathChanged) {
|
||||
pendingLocalContentRef.current = null
|
||||
}
|
||||
|
||||
if (syncActivePathTransition({
|
||||
prevPath: state.prevPath,
|
||||
pathChanged: state.pathChanged,
|
||||
@@ -812,6 +974,7 @@ function shouldSkipScheduledTabSwap(options: {
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -827,6 +990,7 @@ function runTabSwapEffect(options: {
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
@@ -840,6 +1004,7 @@ function runTabSwapEffect(options: {
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
@@ -859,6 +1024,7 @@ function runTabSwapEffect(options: {
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
@@ -889,6 +1055,7 @@ function useTabSwapEffect(options: {
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
@@ -902,6 +1069,7 @@ function useTabSwapEffect(options: {
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
@@ -917,6 +1085,7 @@ function useTabSwapEffect(options: {
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}, [
|
||||
activeTabPath,
|
||||
@@ -930,6 +1099,7 @@ function useTabSwapEffect(options: {
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
tabs,
|
||||
pendingLocalContentRef,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -946,6 +1116,7 @@ function useTabSwapEffect(options: {
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
|
||||
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
|
||||
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
@@ -960,6 +1131,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
|
||||
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
|
||||
@@ -975,6 +1148,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
|
||||
return { handleEditorChange, editorMountedRef }
|
||||
|
||||
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchMenuEvent, useMenuEvents, type MenuEventHandlers } from './useMenuEvents'
|
||||
|
||||
const isTauriMock = vi.fn(() => false)
|
||||
const listenMock = vi.fn()
|
||||
const invokeMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: (...args: unknown[]) => listenMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}))
|
||||
|
||||
function makeHandlers(): MenuEventHandlers {
|
||||
return {
|
||||
onSetViewMode: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onCreateType: vi.fn(),
|
||||
onQuickOpen: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onToggleInspector: vi.fn(),
|
||||
onCommandPalette: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
onToggleRawEditor: vi.fn(),
|
||||
onToggleDiff: vi.fn(),
|
||||
onToggleAIChat: vi.fn(),
|
||||
onGoBack: vi.fn(),
|
||||
onGoForward: vi.fn(),
|
||||
onCheckForUpdates: vi.fn(),
|
||||
onSelectFilter: vi.fn(),
|
||||
onOpenVault: vi.fn(),
|
||||
onRemoveActiveVault: vi.fn(),
|
||||
onRestoreGettingStarted: vi.fn(),
|
||||
onAddRemote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
onRestoreDeletedNote: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
multiSelectionCommandRef: { current: null },
|
||||
activeTabPath: '/vault/test.md',
|
||||
hasRestorableDeletedNote: false,
|
||||
hasNoRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMenuEvents note-list search bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('dispatches the note-list search toggle event for the native Cmd+F menu item', () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('laputa:toggle-note-list-search', listener)
|
||||
|
||||
dispatchMenuEvent('edit-toggle-note-list-search', makeHandlers())
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
window.removeEventListener('laputa:toggle-note-list-search', listener)
|
||||
})
|
||||
|
||||
it('syncs note-list search availability into the native menu state', async () => {
|
||||
isTauriMock.mockReturnValue(true)
|
||||
listenMock.mockResolvedValue(vi.fn())
|
||||
|
||||
renderHook(() => useMenuEvents(makeHandlers()))
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:note-list-search-availability', {
|
||||
detail: { enabled: true },
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenLastCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: true }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
isAppCommandId,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
import {
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
dispatchNoteListSearchToggle,
|
||||
readNoteListSearchAvailability,
|
||||
} from '../utils/noteListSearchEvents'
|
||||
|
||||
const NOTE_LIST_SEARCH_MENU_ID = 'edit-toggle-note-list-search'
|
||||
|
||||
export interface MenuEventHandlers extends AppCommandHandlers {
|
||||
activeTabPath: string | null
|
||||
@@ -21,6 +28,7 @@ interface MenuStatePayload {
|
||||
hasConflicts?: boolean
|
||||
hasRestorableDeletedNote?: boolean
|
||||
hasNoRemote?: boolean
|
||||
noteListSearchEnabled?: boolean
|
||||
}
|
||||
|
||||
function readCustomEventDetail(event: Event): string | null {
|
||||
@@ -118,8 +126,28 @@ function useNativeMenuStateSync(state: MenuStatePayload) {
|
||||
}, [state])
|
||||
}
|
||||
|
||||
function useNoteListSearchMenuState() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleAvailabilityEvent = (event: Event) => {
|
||||
const nextEnabled = readNoteListSearchAvailability(event)
|
||||
if (nextEnabled !== null) setEnabled(nextEnabled)
|
||||
}
|
||||
|
||||
window.addEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
}, [])
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
|
||||
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
if (id === NOTE_LIST_SEARCH_MENU_ID) {
|
||||
dispatchNoteListSearchToggle()
|
||||
return
|
||||
}
|
||||
if (!isAppCommandId(id)) return
|
||||
executeAppCommand(id, h, 'native-menu')
|
||||
}
|
||||
@@ -127,6 +155,7 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
|
||||
export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
const ref = useRef(handlers)
|
||||
const noteListSearchEnabled = useNoteListSearchMenuState()
|
||||
const hasActiveNote = handlers.activeTabPath !== null
|
||||
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
|
||||
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
|
||||
@@ -146,5 +175,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
hasConflicts,
|
||||
hasRestorableDeletedNote,
|
||||
hasNoRemote,
|
||||
noteListSearchEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,4 +55,31 @@ describe('useNoteActions frontmatter persistence', () => {
|
||||
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'update',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'delete',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
||||
},
|
||||
},
|
||||
])('flushes pending raw content before a frontmatter $label', async ({ run }) => {
|
||||
const flushBeforeFrontmatterChange = vi.fn().mockResolvedValue(undefined)
|
||||
const { result } = renderHook(() => useNoteActions({
|
||||
...makeConfig(vi.fn()),
|
||||
flushBeforeFrontmatterChange,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await run(result)
|
||||
})
|
||||
|
||||
expect(flushBeforeFrontmatterChange).toHaveBeenCalledWith('/vault/note.md')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface NoteActionsConfig {
|
||||
removeEntry: (path: string) => void
|
||||
entries: VaultEntry[]
|
||||
flushBeforeNoteSwitch?: (path: string) => Promise<void>
|
||||
flushBeforeFrontmatterChange?: (path: string) => Promise<void>
|
||||
flushBeforePathRename?: (path: string) => Promise<void>
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -128,6 +129,20 @@ async function flushBeforeTitleRename(
|
||||
}
|
||||
}
|
||||
|
||||
async function flushBeforeFrontmatterMutation(
|
||||
path: string,
|
||||
flushBeforeFrontmatterChange?: (path: string) => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (!flushBeforeFrontmatterChange) return true
|
||||
|
||||
try {
|
||||
await flushBeforeFrontmatterChange(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRenameAfterFrontmatterUpdate({
|
||||
path,
|
||||
key,
|
||||
@@ -167,6 +182,9 @@ async function updateFrontmatterAndMaybeRename({
|
||||
runFrontmatterOp,
|
||||
value,
|
||||
}: UpdateFrontmatterAndMaybeRenameParams): Promise<void> {
|
||||
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
|
||||
if (!canFlush) return
|
||||
|
||||
const canRename = await flushBeforeTitleRename(path, key, value, config.flushBeforePathRename)
|
||||
if (!canRename) return
|
||||
|
||||
@@ -239,12 +257,18 @@ function useFrontmatterActionHandlers({
|
||||
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
|
||||
|
||||
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
|
||||
if (!canFlush) return
|
||||
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange)
|
||||
if (!canFlush) return
|
||||
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
|
||||
@@ -9,6 +9,8 @@ interface NoteListKeyboardOptions {
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onPrefetch?: (entry: VaultEntry) => void
|
||||
searchVisible?: boolean
|
||||
toggleSearch?: () => void
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
@@ -55,6 +57,12 @@ function isListActive(container: HTMLDivElement | null): boolean {
|
||||
return activeElement instanceof Node && container.contains(activeElement)
|
||||
}
|
||||
|
||||
function isPanelActive(panel: HTMLDivElement | null): boolean {
|
||||
if (!panel) return false
|
||||
const activeElement = document.activeElement
|
||||
return activeElement instanceof Node && panel.contains(activeElement)
|
||||
}
|
||||
|
||||
function isEditableElement(element: Element | null): boolean {
|
||||
if (!element) return false
|
||||
if (
|
||||
@@ -119,6 +127,13 @@ function usesCommandModifier(event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey'>):
|
||||
return event.metaKey || event.ctrlKey
|
||||
}
|
||||
|
||||
function isToggleSearchShortcut(
|
||||
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
|
||||
): boolean {
|
||||
if (!usesCommandModifier(event) || event.altKey || event.shiftKey) return false
|
||||
return event.code === 'KeyF' || event.key.toLowerCase() === 'f'
|
||||
}
|
||||
|
||||
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
||||
return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey
|
||||
}
|
||||
@@ -334,6 +349,7 @@ function useProcessKeyDown({
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
onToggleSearchShortcut,
|
||||
}: {
|
||||
enabled: boolean
|
||||
items: VaultEntry[]
|
||||
@@ -342,34 +358,25 @@ function useProcessKeyDown({
|
||||
flushOpen: (entry?: VaultEntry) => void
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onToggleSearchShortcut?: () => void
|
||||
}) {
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
|
||||
if (!enabled || items.length === 0) return
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>) => {
|
||||
if (!enabled) return
|
||||
|
||||
if (isNeighborhoodKey(event)) {
|
||||
handleNeighborhoodActivation({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (usesCommandModifier(event) || event.altKey) return
|
||||
|
||||
if (handleArrowNavigation(event, moveHighlight)) return
|
||||
|
||||
if (event.key !== 'Enter') return
|
||||
|
||||
handleHighlightedOpen({
|
||||
if (handleSearchShortcutEvent(event, onToggleSearchShortcut)) return
|
||||
if (items.length === 0) return
|
||||
if (handleNeighborhoodShortcutEvent({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
})
|
||||
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood])
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})) return
|
||||
if (shouldIgnoreListKeyboardEvent(event)) return
|
||||
if (handleArrowNavigation(event, moveHighlight)) return
|
||||
|
||||
handleEnterShortcutEvent(event, items, highlightedPathRef, flushOpen)
|
||||
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood, onToggleSearchShortcut])
|
||||
}
|
||||
|
||||
function useFocusHandlers({
|
||||
@@ -402,44 +409,194 @@ function useFocusHandlers({
|
||||
return { focusList, handleBlur, handleFocus }
|
||||
}
|
||||
|
||||
function usePanelFocusState(panelRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const [isPanelActiveState, setIsPanelActiveState] = useState(false)
|
||||
|
||||
const syncPanelState = useCallback(() => {
|
||||
setIsPanelActiveState(isPanelActive(panelRef.current))
|
||||
}, [panelRef])
|
||||
|
||||
const handlePanelFocusCapture = useCallback(() => {
|
||||
setIsPanelActiveState(true)
|
||||
}, [])
|
||||
|
||||
const handlePanelBlurCapture = useCallback(() => {
|
||||
requestAnimationFrame(syncPanelState)
|
||||
}, [syncPanelState])
|
||||
|
||||
return {
|
||||
handlePanelBlurCapture,
|
||||
handlePanelFocusCapture,
|
||||
isPanelActive: isPanelActiveState,
|
||||
}
|
||||
}
|
||||
|
||||
function useGlobalKeyboardHandling({
|
||||
enabled,
|
||||
panelRef,
|
||||
containerRef,
|
||||
processKeyDown,
|
||||
}: {
|
||||
enabled: boolean
|
||||
panelRef: React.RefObject<HTMLDivElement | null>
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
processKeyDown: (event: KeyboardEvent) => void
|
||||
}) {
|
||||
const shouldSkipGlobalKeyDown = useCallback((activeElement: Element | null) => {
|
||||
if (isEditableElement(activeElement)) return true
|
||||
return Boolean(
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
)
|
||||
}, [containerRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
const activeElement = document.activeElement
|
||||
if (isEditableElement(activeElement)) return
|
||||
if (
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
) return
|
||||
processKeyDown(event)
|
||||
}
|
||||
const handleWindowKeyDown = createGlobalKeyDownHandler(panelRef, shouldSkipGlobalKeyDown, processKeyDown)
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [containerRef, enabled, processKeyDown])
|
||||
}, [enabled, panelRef, processKeyDown, shouldSkipGlobalKeyDown])
|
||||
}
|
||||
|
||||
function useSearchToggleShortcut({
|
||||
toggleSearch,
|
||||
searchVisible,
|
||||
focusList,
|
||||
}: {
|
||||
toggleSearch?: () => void
|
||||
searchVisible: boolean
|
||||
focusList: () => void
|
||||
}) {
|
||||
return useCallback(() => {
|
||||
if (!toggleSearch) return
|
||||
|
||||
toggleSearch()
|
||||
if (!searchVisible) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusList()
|
||||
})
|
||||
}, [focusList, searchVisible, toggleSearch])
|
||||
}
|
||||
|
||||
function useDirectKeyDownHandler(
|
||||
processKeyDown: (event: React.KeyboardEvent) => void,
|
||||
) {
|
||||
return useCallback((event: React.KeyboardEvent) => {
|
||||
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
||||
processKeyDown(event)
|
||||
}, [processKeyDown])
|
||||
}
|
||||
|
||||
function resolveStableHighlightedPath(items: VaultEntry[], highlightedPathState: string | null): string | null {
|
||||
return getItemIndex(items).entryByPath.has(highlightedPathState ?? '')
|
||||
? highlightedPathState
|
||||
: null
|
||||
}
|
||||
|
||||
function handleSearchShortcutEvent(
|
||||
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>,
|
||||
onToggleSearchShortcut?: () => void,
|
||||
): boolean {
|
||||
if (!isToggleSearchShortcut(event) || !onToggleSearchShortcut) return false
|
||||
event.preventDefault()
|
||||
onToggleSearchShortcut()
|
||||
return true
|
||||
}
|
||||
|
||||
function handleNeighborhoodShortcutEvent(options: {
|
||||
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>
|
||||
items: VaultEntry[]
|
||||
highlightedPathRef: React.RefObject<string | null>
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
}): boolean {
|
||||
const {
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
} = options
|
||||
|
||||
if (!isNeighborhoodKey(event)) return false
|
||||
handleNeighborhoodActivation({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldIgnoreListKeyboardEvent(
|
||||
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey'>,
|
||||
): boolean {
|
||||
return usesCommandModifier(event) || event.altKey
|
||||
}
|
||||
|
||||
function handleEnterShortcutEvent(
|
||||
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
|
||||
items: VaultEntry[],
|
||||
highlightedPathRef: React.RefObject<string | null>,
|
||||
flushOpen: (entry?: VaultEntry) => void,
|
||||
) {
|
||||
if (event.key !== 'Enter') return
|
||||
handleHighlightedOpen({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
})
|
||||
}
|
||||
|
||||
function createGlobalKeyDownHandler(
|
||||
panelRef: React.RefObject<HTMLDivElement | null>,
|
||||
shouldSkipGlobalKeyDown: (activeElement: Element | null) => boolean,
|
||||
processKeyDown: (event: KeyboardEvent) => void,
|
||||
) {
|
||||
return (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isToggleSearchShortcut(event) && isPanelActive(panelRef.current)) {
|
||||
processKeyDown(event)
|
||||
return
|
||||
}
|
||||
if (shouldSkipGlobalKeyDown(document.activeElement)) return
|
||||
processKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteListKeyboard({
|
||||
items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled,
|
||||
items,
|
||||
selectedNotePath,
|
||||
onOpen,
|
||||
onEnterNeighborhood,
|
||||
onPrefetch,
|
||||
searchVisible = false,
|
||||
toggleSearch,
|
||||
enabled,
|
||||
}: NoteListKeyboardOptions) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath)
|
||||
const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath()
|
||||
const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath)
|
||||
const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled)
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
const { handlePanelBlurCapture, handlePanelFocusCapture, isPanelActive: isPanelActiveState } = usePanelFocusState(panelRef)
|
||||
const handleToggleSearchShortcut = useSearchToggleShortcut({
|
||||
focusList,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
})
|
||||
const moveHighlight = useMoveHighlight({
|
||||
items,
|
||||
selectedNotePath,
|
||||
@@ -457,32 +614,28 @@ export function useNoteListKeyboard({
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
onToggleSearchShortcut: handleToggleSearchShortcut,
|
||||
})
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
||||
processKeyDown(event)
|
||||
}, [processKeyDown])
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown })
|
||||
const handleKeyDown = useDirectKeyDownHandler(processKeyDown)
|
||||
useGlobalKeyboardHandling({ enabled, panelRef, containerRef, processKeyDown })
|
||||
useEffect(() => {
|
||||
cancelOpen()
|
||||
}, [cancelOpen, selectedNotePath])
|
||||
|
||||
const highlightedPath = getItemIndex(items).entryByPath.has(highlightedPathState ?? '')
|
||||
? highlightedPathState
|
||||
: null
|
||||
const highlightedPath = resolveStableHighlightedPath(items, highlightedPathState)
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
focusList,
|
||||
handlePanelBlurCapture,
|
||||
handlePanelFocusCapture,
|
||||
highlightedPath,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
isPanelActive: isPanelActiveState,
|
||||
panelRef,
|
||||
toggleSearchShortcut: handleToggleSearchShortcut,
|
||||
virtuosoRef,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,21 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows welcome instead of vault-missing when no persisted active vault matches the missing path', async () => {
|
||||
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands({
|
||||
load_vault_list: {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = await renderOnboarding('/vault/deleted')
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
})
|
||||
|
||||
it('clears the persisted active vault when the saved path no longer exists', async () => {
|
||||
localStorage.setItem(LEGACY_APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands({
|
||||
|
||||
@@ -40,10 +40,10 @@ function markDismissed(): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
async function clearMissingActiveVault(missingPath: string): Promise<boolean> {
|
||||
try {
|
||||
const list = await tauriCall<PersistedVaultList>('load_vault_list', {})
|
||||
if (!list || list.active_vault !== missingPath) return
|
||||
if (!list || list.active_vault !== missingPath) return false
|
||||
await tauriCall('save_vault_list', {
|
||||
list: {
|
||||
vaults: list.vaults ?? [],
|
||||
@@ -51,8 +51,10 @@ async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
hidden_defaults: list.hidden_defaults ?? [],
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Best effort only — onboarding should still proceed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,17 +86,14 @@ export function useOnboarding(
|
||||
|
||||
if (exists) {
|
||||
setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
} else {
|
||||
await clearMissingActiveVault(initialVaultPath)
|
||||
if (cancelled) return
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
|
||||
if (wasDismissed()) {
|
||||
// User previously dismissed — show vault-missing instead of welcome
|
||||
const missingWasPersistedActiveVault = await clearMissingActiveVault(initialVaultPath)
|
||||
if (cancelled) return
|
||||
|
||||
if (wasDismissed() && missingWasPersistedActiveVault) {
|
||||
// Only show vault-missing when a previously selected vault path truly disappeared.
|
||||
setState({ status: 'vault-missing', vaultPath: initialVaultPath, defaultPath })
|
||||
} else {
|
||||
setState({ status: 'welcome', defaultPath })
|
||||
|
||||
191
src/hooks/usePropertyPanelState.extra.test.ts
Normal file
191
src/hooks/usePropertyPanelState.extra.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
|
||||
const propertyModeState = vi.hoisted(() => ({
|
||||
overrides: {} as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../components/DynamicPropertiesPanel', () => ({
|
||||
containsWikilinks: (value: unknown) => typeof value === 'string' && value.includes('[['),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/propertyTypes', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/propertyTypes')>('../utils/propertyTypes')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
loadDisplayModeOverrides: vi.fn(() => ({ ...propertyModeState.overrides })),
|
||||
saveDisplayModeOverride: vi.fn((key: string, mode: string) => {
|
||||
propertyModeState.overrides[key] = mode
|
||||
}),
|
||||
removeDisplayModeOverride: vi.fn((key: string) => {
|
||||
delete propertyModeState.overrides[key]
|
||||
}),
|
||||
getEffectiveDisplayMode: vi.fn((key: string, value: unknown, overrides: Record<string, string>) => (
|
||||
overrides[key] ?? actual.detectPropertyType(key, value as never)
|
||||
)),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
loadDisplayModeOverrides,
|
||||
removeDisplayModeOverride,
|
||||
saveDisplayModeOverride,
|
||||
} from '../utils/propertyTypes'
|
||||
import { usePropertyPanelState } from './usePropertyPanelState'
|
||||
|
||||
function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 1,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createFrontmatter(overrides: ParsedFrontmatter = {}): ParsedFrontmatter {
|
||||
return {
|
||||
Status: 'Active',
|
||||
Alias: 'shown',
|
||||
Icon: 'sparkle',
|
||||
_icon: 'duplicate-hidden',
|
||||
Tags: ['alpha', 'beta'],
|
||||
Related_to: '[[Linked note]]',
|
||||
Workspace: 'hidden',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePropertyPanelState extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
propertyModeState.overrides = {}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('derives type, status, tag, and visible property metadata from entries and frontmatter', () => {
|
||||
const { result } = renderHook(() => usePropertyPanelState({
|
||||
entries: [
|
||||
entry({ title: 'Project', isA: 'Type', color: 'sky', icon: 'rocket' }),
|
||||
entry({ title: 'Topic', isA: 'Type', color: 'mint', icon: 'hash' }),
|
||||
entry({ title: 'Roadmap', status: 'Paused', properties: { Tags: ['alpha', 'gamma'], People: ['Brian'] } }),
|
||||
],
|
||||
entryIsA: 'Project',
|
||||
frontmatter: createFrontmatter(),
|
||||
}))
|
||||
|
||||
expect(result.current.availableTypes).toEqual(['Project', 'Topic'])
|
||||
expect(result.current.customColorKey).toBe('sky')
|
||||
expect(result.current.typeColorKeys).toEqual({ Project: 'sky', Topic: 'mint' })
|
||||
expect(result.current.typeIconKeys).toEqual({ Project: 'rocket', Topic: 'hash' })
|
||||
expect(result.current.vaultStatuses).toEqual(['Paused'])
|
||||
expect(result.current.vaultTagsByKey).toEqual({
|
||||
People: ['Brian'],
|
||||
Tags: ['alpha', 'gamma'],
|
||||
})
|
||||
expect(result.current.propertyEntries).toEqual([
|
||||
['Status', 'Active'],
|
||||
['Alias', 'shown'],
|
||||
['Icon', 'sparkle'],
|
||||
['Tags', ['alpha', 'beta']],
|
||||
])
|
||||
})
|
||||
|
||||
it('saves scalar values using number-aware coercion and deletes empty numeric values', () => {
|
||||
propertyModeState.overrides = { Estimate: 'number' }
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: { Estimate: 2, Done: false },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setEditingKey('Estimate')
|
||||
result.current.handleSaveValue('Estimate', ' 42 ')
|
||||
result.current.handleSaveValue('Estimate', ' ')
|
||||
result.current.handleSaveValue('Done', 'true')
|
||||
})
|
||||
|
||||
expect(result.current.editingKey).toBeNull()
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Estimate', 42)
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Estimate')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Done', true)
|
||||
})
|
||||
|
||||
it('reconciles list values, persists added display modes, and supports clearing overrides', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const onUpdateProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: {},
|
||||
onAddProperty,
|
||||
onDeleteProperty,
|
||||
onUpdateProperty,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAddDialog(true)
|
||||
result.current.handleSaveList('Tags', [])
|
||||
result.current.handleSaveList('Tags', ['solo'])
|
||||
result.current.handleSaveList('Tags', ['alpha', 'beta'])
|
||||
result.current.handleAdd('Priority', ' 3 ', 'number')
|
||||
result.current.handleAdd('People', ' Luca, Brian ', 'tags')
|
||||
result.current.handleAdd('Enabled', 'TRUE', 'boolean')
|
||||
result.current.handleAdd(' ', 'ignored', 'text')
|
||||
result.current.handleDisplayModeChange('Priority', null)
|
||||
})
|
||||
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Tags')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', 'solo')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Priority', 3)
|
||||
expect(onAddProperty).toHaveBeenCalledWith('People', ['Luca', 'Brian'])
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Enabled', true)
|
||||
expect(onAddProperty).toHaveBeenCalledTimes(3)
|
||||
expect(saveDisplayModeOverride).toHaveBeenCalledWith('Priority', 'number')
|
||||
expect(saveDisplayModeOverride).toHaveBeenCalledWith('People', 'tags')
|
||||
expect(saveDisplayModeOverride).toHaveBeenCalledWith('Enabled', 'boolean')
|
||||
expect(loadDisplayModeOverrides).toHaveBeenCalled()
|
||||
expect(removeDisplayModeOverride).toHaveBeenCalledWith('Priority')
|
||||
expect(result.current.showAddDialog).toBe(false)
|
||||
})
|
||||
})
|
||||
202
src/hooks/usePropertyPanelState.test.ts
Normal file
202
src/hooks/usePropertyPanelState.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
vi.mock('../components/DynamicPropertiesPanel', () => ({
|
||||
containsWikilinks: (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => String(item).includes('[['))
|
||||
}
|
||||
return String(value).includes('[[')
|
||||
},
|
||||
}))
|
||||
|
||||
import { initDisplayModeOverrides } from '../utils/propertyTypes'
|
||||
import { usePropertyPanelState } from './usePropertyPanelState'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 1,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePropertyPanelState', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initDisplayModeOverrides({})
|
||||
})
|
||||
|
||||
it('derives visible properties, available types, statuses, and aggregated tags', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Topic', isA: 'Type', color: '#334455', icon: 'book' }),
|
||||
makeEntry({ title: 'Project', isA: 'Type', color: '#112233', icon: 'rocket' }),
|
||||
makeEntry({
|
||||
title: 'Alpha',
|
||||
status: 'Doing',
|
||||
properties: {
|
||||
Tags: ['alpha', 'beta'],
|
||||
Areas: ['ops'],
|
||||
},
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Beta',
|
||||
status: 'Review',
|
||||
properties: {
|
||||
Tags: ['beta', 'gamma'],
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const frontmatter = {
|
||||
_icon: 'sparkles',
|
||||
icon: 'duplicate',
|
||||
title: 'Hidden',
|
||||
Count: 3,
|
||||
Custom: 'value',
|
||||
'Related to': '[[Alpha]]',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries,
|
||||
entryIsA: 'Project',
|
||||
frontmatter,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.availableTypes).toEqual(['Project', 'Topic'])
|
||||
expect(result.current.customColorKey).toBe('#112233')
|
||||
expect(result.current.typeIconKeys.Project).toBe('rocket')
|
||||
expect(result.current.vaultStatuses).toEqual(['Doing', 'Review'])
|
||||
expect(result.current.vaultTagsByKey).toEqual({
|
||||
Areas: ['ops'],
|
||||
Tags: ['alpha', 'beta', 'gamma'],
|
||||
})
|
||||
expect(result.current.propertyEntries).toEqual([
|
||||
['_icon', 'sparkles'],
|
||||
['Count', 3],
|
||||
['Custom', 'value'],
|
||||
])
|
||||
})
|
||||
|
||||
it('saves scalar and list properties through the correct handlers', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: {
|
||||
Count: 7,
|
||||
Flag: false,
|
||||
Title: 'kept',
|
||||
},
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setEditingKey('Count')
|
||||
result.current.handleSaveValue('Count', ' ')
|
||||
})
|
||||
expect(result.current.editingKey).toBeNull()
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Count')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSaveValue('Flag', 'true')
|
||||
})
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Flag', true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSaveList('Tags', [])
|
||||
result.current.handleSaveList('Tags', ['solo'])
|
||||
result.current.handleSaveList('Tags', ['solo', 'duo'])
|
||||
})
|
||||
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Tags')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', 'solo')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', ['solo', 'duo'])
|
||||
})
|
||||
|
||||
it('adds properties, persists non-text display modes, and supports clearing overrides', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries: [],
|
||||
entryIsA: null,
|
||||
frontmatter: {},
|
||||
onAddProperty,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAddDialog(true)
|
||||
result.current.handleAdd(' ', 'ignored', 'text')
|
||||
})
|
||||
expect(onAddProperty).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleAdd('Rating', '42', 'number')
|
||||
})
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Rating', 42)
|
||||
expect(result.current.displayOverrides.Rating).toBe('number')
|
||||
expect(result.current.showAddDialog).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.handleAdd('Labels', 'alpha, beta', 'tags')
|
||||
})
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Labels', ['alpha', 'beta'])
|
||||
expect(result.current.displayOverrides.Labels).toBe('tags')
|
||||
|
||||
act(() => {
|
||||
result.current.handleDisplayModeChange('Rating', null)
|
||||
})
|
||||
expect(result.current.displayOverrides.Rating).toBeUndefined()
|
||||
})
|
||||
})
|
||||
173
src/hooks/useStatusBarAddRemote.test.ts
Normal file
173
src/hooks/useStatusBarAddRemote.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitRemoteStatus } from '../types'
|
||||
import { REQUEST_ADD_REMOTE_EVENT } from '../utils/addRemoteEvents'
|
||||
import { useStatusBarAddRemote } from './useStatusBarAddRemote'
|
||||
|
||||
const invokeMock = vi.fn()
|
||||
const mockInvokeMock = vi.fn()
|
||||
const isTauriMock = vi.fn(() => false)
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriMock(),
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeMock(...args),
|
||||
}))
|
||||
|
||||
function remoteStatus(hasRemote: boolean): GitRemoteStatus {
|
||||
return {
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useStatusBarAddRemote', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriMock.mockReturnValue(false)
|
||||
mockInvokeMock.mockResolvedValue(remoteStatus(false))
|
||||
invokeMock.mockResolvedValue(remoteStatus(false))
|
||||
})
|
||||
|
||||
it('delegates to onAddRemote when provided', async () => {
|
||||
const onAddRemote = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remoteStatus(false),
|
||||
onAddRemote,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(onAddRemote).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvokeMock).not.toHaveBeenCalled()
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when the vault is not git-backed', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: false,
|
||||
remoteStatus: remoteStatus(false),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
expect(mockInvokeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens when the refreshed remote status has no remote and closes when it does', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ remote }) =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remote,
|
||||
}),
|
||||
{
|
||||
initialProps: { remote: remoteStatus(false) },
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(mockInvokeMock).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
|
||||
expect(result.current.showAddRemote).toBe(true)
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(false))
|
||||
|
||||
mockInvokeMock.mockResolvedValue(remoteStatus(true))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRemoteConnected('connected')
|
||||
})
|
||||
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
|
||||
await act(async () => {
|
||||
result.current.closeAddRemote()
|
||||
})
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
|
||||
rerender({ remote: remoteStatus(false) })
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
})
|
||||
|
||||
it('stays closed when the latest refresh already has a remote', async () => {
|
||||
mockInvokeMock.mockResolvedValue(remoteStatus(true))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remoteStatus(false),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
|
||||
expect(result.current.showAddRemote).toBe(false)
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
})
|
||||
|
||||
it('reacts to the global add-remote request event', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: remoteStatus(false),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(REQUEST_ADD_REMOTE_EVENT))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showAddRemote).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the Tauri invoke path in native mode and tolerates refresh failures', async () => {
|
||||
isTauriMock.mockReturnValue(true)
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(remoteStatus(true))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStatusBarAddRemote({
|
||||
vaultPath: '/vault',
|
||||
isGitVault: true,
|
||||
remoteStatus: null,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openAddRemote()
|
||||
})
|
||||
expect(result.current.showAddRemote).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRemoteConnected('connected')
|
||||
})
|
||||
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
|
||||
})
|
||||
})
|
||||
@@ -152,15 +152,45 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('is a no-op when replacing with the same entry', async () => {
|
||||
it('treats /tmp and /private/tmp aliases as the same active note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Stale before pull')
|
||||
.mockResolvedValueOnce('# Fresh after pull')
|
||||
const beforeNavigate = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/private/tmp/vault/active.md', title: 'Active' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(
|
||||
makeEntry({ path: '/tmp/vault/active.md', title: 'Active' }),
|
||||
)
|
||||
})
|
||||
|
||||
expect(beforeNavigate).not.toHaveBeenCalled()
|
||||
expect(result.current.activeTabPath).toBe('/tmp/vault/active.md')
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('# Fresh after pull')
|
||||
})
|
||||
|
||||
it('reloads content when replacing with the same entry', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Stale before pull')
|
||||
.mockResolvedValueOnce('# Fresh after pull')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = { path: '/vault/a.md' }
|
||||
const entry = { path: '/vault/a.md', title: 'A' }
|
||||
await selectNote(result, entry)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(makeEntry(entry))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('# Fresh after pull')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('opens a note when no note is active', async () => {
|
||||
|
||||
@@ -93,7 +93,8 @@ function getCachedNoteContent(path: string): string | null {
|
||||
return prefetchCache.get(path)?.value ?? null
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
async function loadNoteContent(path: string, forceFresh = false): Promise<string> {
|
||||
if (forceFresh) return requestNoteContent({ path }).promise
|
||||
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
|
||||
}
|
||||
|
||||
@@ -112,6 +113,18 @@ function syncActiveTabPath(
|
||||
setActiveTabPath(path)
|
||||
}
|
||||
|
||||
function normalizeComparablePath(path: string): string {
|
||||
return path
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
|
||||
.replace(/\/+$/u, '')
|
||||
}
|
||||
|
||||
function pathsMatch(leftPath: string | null, rightPath: string | null): boolean {
|
||||
if (!leftPath || !rightPath) return false
|
||||
return normalizeComparablePath(leftPath) === normalizeComparablePath(rightPath)
|
||||
}
|
||||
|
||||
function setSingleTab(
|
||||
tabsRef: React.MutableRefObject<Tab[]>,
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
|
||||
@@ -126,7 +139,8 @@ function isAlreadyViewingPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
path: string,
|
||||
) {
|
||||
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
|
||||
return pathsMatch(activeTabPathRef.current, path)
|
||||
|| tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path))
|
||||
}
|
||||
|
||||
function startEntryNavigation(options: {
|
||||
@@ -162,6 +176,7 @@ function shouldApplyLoadedEntry(options: {
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
cachedContent: string | null
|
||||
content: string
|
||||
forceReload: boolean
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
path: string
|
||||
}) {
|
||||
@@ -170,12 +185,14 @@ function shouldApplyLoadedEntry(options: {
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
forceReload,
|
||||
activeTabPathRef,
|
||||
path,
|
||||
} = options
|
||||
|
||||
if (navSeqRef.current !== seq) return false
|
||||
return cachedContent !== content || activeTabPathRef.current !== path
|
||||
if (forceReload) return true
|
||||
return cachedContent !== content || !pathsMatch(activeTabPathRef.current, path)
|
||||
}
|
||||
|
||||
function handleEntryLoadFailure(options: {
|
||||
@@ -203,6 +220,7 @@ function handleEntryLoadFailure(options: {
|
||||
|
||||
async function navigateToEntry(options: {
|
||||
entry: VaultEntry
|
||||
forceReload?: boolean
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
@@ -211,6 +229,7 @@ async function navigateToEntry(options: {
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
forceReload = false,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
@@ -222,7 +241,7 @@ async function navigateToEntry(options: {
|
||||
failNoteOpenTrace(entry.path, 'binary-entry')
|
||||
return
|
||||
}
|
||||
if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
|
||||
if (!forceReload && isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
finishNoteOpenTrace(entry.path)
|
||||
return
|
||||
@@ -239,13 +258,14 @@ async function navigateToEntry(options: {
|
||||
|
||||
try {
|
||||
markNoteOpenTrace(entry.path, 'contentLoadStart')
|
||||
const content = await loadNoteContent(entry.path)
|
||||
const content = await loadNoteContent(entry.path, forceReload)
|
||||
markNoteOpenTrace(entry.path, 'contentLoadEnd')
|
||||
if (!shouldApplyLoadedEntry({
|
||||
seq,
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
forceReload,
|
||||
activeTabPathRef,
|
||||
path: entry.path,
|
||||
})) return
|
||||
@@ -282,7 +302,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
) => {
|
||||
const seq = ++beforeNavigateSeqRef.current
|
||||
const currentPath = activeTabPathRef.current
|
||||
if (beforeNavigate && currentPath && currentPath !== targetPath) {
|
||||
if (beforeNavigate && currentPath && !pathsMatch(currentPath, targetPath)) {
|
||||
try {
|
||||
markNoteOpenTrace(targetPath, 'beforeNavigateStart')
|
||||
await beforeNavigate(currentPath, targetPath)
|
||||
@@ -299,7 +319,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
if (entry.path !== activeTabPathRef.current) {
|
||||
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
|
||||
beginNoteOpenTrace(entry.path, 'select-note')
|
||||
}
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
@@ -325,11 +345,12 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
if (entry.path !== activeTabPathRef.current) {
|
||||
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
|
||||
beginNoteOpenTrace(entry.path, 'replace-active-tab')
|
||||
}
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
forceReload: true,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
|
||||
@@ -7,22 +7,54 @@ function makeEntry(path: string, title = 'Test'): VaultEntry {
|
||||
return { path, title, filename: path.split('/').pop()!, content: '', outgoingLinks: [], snippet: '', wordCount: 0, isA: 'Note', status: null, createdAt: null, modifiedAt: null, icon: null, tags: [] } as unknown as VaultEntry
|
||||
}
|
||||
|
||||
function expectVaultDerivedStateReloaded(options: {
|
||||
reloadVault: ReturnType<typeof vi.fn>
|
||||
reloadFolders: ReturnType<typeof vi.fn>
|
||||
reloadViews: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
const { reloadVault, reloadFolders, reloadViews } = options
|
||||
expect(reloadVault).toHaveBeenCalledOnce()
|
||||
expect(reloadFolders).toHaveBeenCalledOnce()
|
||||
expect(reloadViews).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
describe('useVaultBridge', () => {
|
||||
const onSelectNote = vi.fn()
|
||||
let reloadVault: ReturnType<typeof vi.fn>
|
||||
let reloadFolders: ReturnType<typeof vi.fn>
|
||||
let reloadViews: ReturnType<typeof vi.fn>
|
||||
let closeAllTabs: ReturnType<typeof vi.fn>
|
||||
let replaceActiveTab: ReturnType<typeof vi.fn>
|
||||
let hasUnsavedChanges: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reloadVault = vi.fn().mockResolvedValue([])
|
||||
reloadFolders = vi.fn()
|
||||
reloadViews = vi.fn()
|
||||
closeAllTabs = vi.fn()
|
||||
replaceActiveTab = vi.fn().mockResolvedValue(undefined)
|
||||
hasUnsavedChanges = vi.fn(() => false)
|
||||
})
|
||||
|
||||
function renderBridge(entries: VaultEntry[] = [], activeTabPath: string | null = null) {
|
||||
function renderBridge(
|
||||
entries: VaultEntry[] = [],
|
||||
activeTabPath: string | null = null,
|
||||
overrides: Partial<{
|
||||
hasUnsavedChanges: typeof hasUnsavedChanges
|
||||
}> = {},
|
||||
) {
|
||||
const entriesByPath = new Map(entries.map(e => [e.path, e]))
|
||||
return renderHook(() =>
|
||||
useVaultBridge({
|
||||
entriesByPath,
|
||||
resolvedPath: '/vault',
|
||||
reloadVault,
|
||||
reloadFolders,
|
||||
reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
hasUnsavedChanges: overrides.hasUnsavedChanges ?? hasUnsavedChanges,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
}),
|
||||
@@ -87,27 +119,52 @@ describe('useVaultBridge', () => {
|
||||
expect(onSelectNote).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('handleAgentFileModified reloads when active tab matches', () => {
|
||||
it('handleAgentFileModified refreshes the active tab with fresh disk content', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([], '/vault/active.md')
|
||||
|
||||
act(() => { result.current.handleAgentFileModified('active.md') })
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).toHaveBeenCalledOnce()
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('handleAgentFileModified does not reload for different tab', () => {
|
||||
it('handleAgentFileModified still refreshes vault-derived UI for other notes', async () => {
|
||||
const active = makeEntry('/vault/other.md', 'Other')
|
||||
reloadVault.mockResolvedValue([active])
|
||||
const { result } = renderBridge([], '/vault/other.md')
|
||||
|
||||
act(() => { result.current.handleAgentFileModified('active.md') })
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).not.toHaveBeenCalled()
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(active)
|
||||
})
|
||||
|
||||
it('handleAgentVaultChanged always reloads', () => {
|
||||
const { result } = renderBridge([])
|
||||
it('keeps unsaved active note content intact while reloading agent changes', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const hasUnsaved = vi.fn((path: string) => path === '/vault/active.md')
|
||||
const { result } = renderBridge([], '/vault/active.md', { hasUnsavedChanges: hasUnsaved })
|
||||
|
||||
act(() => { result.current.handleAgentVaultChanged() })
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(replaceActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleAgentVaultChanged reloads vault-derived state and refreshes the active note when safe', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([], '/vault/active.md')
|
||||
|
||||
await act(async () => { result.current.handleAgentVaultChanged() })
|
||||
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { refreshPulledVaultState } from '../utils/pulledVaultRefresh'
|
||||
|
||||
interface VaultBridgeDeps {
|
||||
entriesByPath: Map<string, VaultEntry>
|
||||
resolvedPath: string
|
||||
reloadVault: () => Promise<unknown>
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
reloadFolders: () => Promise<unknown> | unknown
|
||||
reloadViews: () => Promise<unknown> | unknown
|
||||
closeAllTabs: () => void
|
||||
replaceActiveTab: (entry: VaultEntry) => Promise<void>
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
activeTabPath: string | null
|
||||
}
|
||||
@@ -13,12 +19,21 @@ function findEntry(entriesByPath: Map<string, VaultEntry>, resolvedPath: string,
|
||||
return entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
function findInFresh(entries: unknown, resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return (entries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
function findInFresh(entries: VaultEntry[], resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
export function useVaultBridge({
|
||||
entriesByPath, resolvedPath, reloadVault, onSelectNote, activeTabPath,
|
||||
entriesByPath,
|
||||
resolvedPath,
|
||||
reloadVault,
|
||||
reloadFolders,
|
||||
reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
hasUnsavedChanges,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
}: VaultBridgeDeps) {
|
||||
const reloadAndOpen = useCallback((path: string) => {
|
||||
reloadVault().then(fresh => {
|
||||
@@ -27,6 +42,29 @@ export function useVaultBridge({
|
||||
})
|
||||
}, [reloadVault, onSelectNote, resolvedPath])
|
||||
|
||||
const refreshAgentChanges = useCallback((updatedFiles: string[]) => (
|
||||
refreshPulledVaultState({
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
), [
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
resolvedPath,
|
||||
])
|
||||
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = findEntry(entriesByPath, resolvedPath, path)
|
||||
if (entry) onSelectNote(entry)
|
||||
@@ -40,11 +78,12 @@ export function useVaultBridge({
|
||||
}, [entriesByPath, resolvedPath, onSelectNote])
|
||||
|
||||
const handleAgentFileModified = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
if (activeTabPath === relativePath || activeTabPath === fullPath) reloadVault()
|
||||
}, [reloadVault, activeTabPath, resolvedPath])
|
||||
void refreshAgentChanges([relativePath])
|
||||
}, [refreshAgentChanges])
|
||||
|
||||
const handleAgentVaultChanged = useCallback(() => { reloadVault() }, [reloadVault])
|
||||
const handleAgentVaultChanged = useCallback(() => {
|
||||
void refreshAgentChanges([])
|
||||
}, [refreshAgentChanges])
|
||||
|
||||
return {
|
||||
openNoteByPath,
|
||||
|
||||
298
src/hooks/useVaultLoader.extra.test.ts
Normal file
298
src/hooks/useVaultLoader.extra.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useVaultLoader } from './useVaultLoader'
|
||||
import type { ModifiedFile, VaultEntry, ViewFile } from '../types'
|
||||
|
||||
const clearPrefetchCache = vi.fn()
|
||||
const backendInvokeFn = vi.fn()
|
||||
let mockIsTauri = false
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => backendInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => mockIsTauri,
|
||||
mockInvoke: (command: string, args?: Record<string, unknown>) => backendInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
vi.mock('./useTabManagement', () => ({
|
||||
clearPrefetchCache: () => clearPrefetchCache(),
|
||||
}))
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/hello.md',
|
||||
filename: 'hello.md',
|
||||
title: 'Hello',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeModifiedFile(overrides: Partial<ModifiedFile> = {}): ModifiedFile {
|
||||
return {
|
||||
path: '/vault/note/hello.md',
|
||||
relativePath: 'note/hello.md',
|
||||
status: 'modified',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeView(name: string): ViewFile {
|
||||
return {
|
||||
path: `/vault/.views/${name.toLowerCase()}.yml`,
|
||||
filename: `${name.toLowerCase()}.yml`,
|
||||
definition: {
|
||||
name,
|
||||
icon: 'Folder',
|
||||
filters: [],
|
||||
sort: null,
|
||||
listPropertiesDisplay: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function configureBackend(overrides: Partial<Record<string, unknown | Error>> = {}) {
|
||||
const defaults: Record<string, unknown> = {
|
||||
list_vault: [makeEntry()],
|
||||
reload_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
git_commit: 'committed',
|
||||
git_push: { status: 'ok', message: 'pushed' },
|
||||
}
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
const value = command in overrides ? overrides[command] : defaults[command]
|
||||
if (value instanceof Error) return Promise.reject(value)
|
||||
return Promise.resolve(value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForEntries(
|
||||
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, string>>['result'],
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useVaultLoader extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsTauri = false
|
||||
configureBackend()
|
||||
})
|
||||
|
||||
it('uses native commit and push commands when Tauri mode is active', async () => {
|
||||
mockIsTauri = true
|
||||
configureBackend({
|
||||
reload_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
let response = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('save current note')
|
||||
})
|
||||
|
||||
expect(response.status).toBe('ok')
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', {
|
||||
vaultPath: '/vault',
|
||||
message: 'save current note',
|
||||
})
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_push', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
it('tracks pending saves and replaces entries in place', async () => {
|
||||
const initialEntry = makeEntry()
|
||||
configureBackend({
|
||||
list_vault: [initialEntry],
|
||||
get_modified_files: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
act(() => {
|
||||
result.current.addPendingSave(initialEntry.path)
|
||||
})
|
||||
expect(result.current.getNoteStatus(initialEntry.path)).toBe('pendingSave')
|
||||
|
||||
act(() => {
|
||||
result.current.removePendingSave(initialEntry.path)
|
||||
result.current.replaceEntry(initialEntry.path, {
|
||||
path: '/vault/note/renamed.md',
|
||||
title: 'Renamed',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus(initialEntry.path)).toBe('clean')
|
||||
expect(result.current.entries[0]?.path).toBe('/vault/note/renamed.md')
|
||||
expect(result.current.entries[0]?.title).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('surfaces modified-file refresh failures with an empty fallback list', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
get_modified_files: new Error('backend offline'),
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFilesError).toBe('Failed to load changes')
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads the vault, refreshes modified files, and clears the prefetch cache', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
reload_vault: [makeEntry({ path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' })],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'reload_vault') {
|
||||
return Promise.resolve([
|
||||
makeEntry({ path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' }),
|
||||
])
|
||||
}
|
||||
if (command === 'get_modified_files') {
|
||||
return Promise.resolve([
|
||||
makeModifiedFile({ path: '/vault/note/fresh.md', relativePath: 'note/fresh.md' }),
|
||||
])
|
||||
}
|
||||
if (command === 'list_vault_folders' || command === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve([makeEntry()])
|
||||
})
|
||||
|
||||
let reloaded: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
reloaded = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(clearPrefetchCache).toHaveBeenCalledOnce()
|
||||
expect(reloaded.map((entry) => entry.title)).toEqual(['Fresh'])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries[0]?.title).toBe('Fresh')
|
||||
expect(result.current.modifiedFiles[0]?.path).toBe('/vault/note/fresh.md')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty list when vault reload fails', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
get_modified_files: [],
|
||||
reload_vault: new Error('reload failed'),
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
let reloaded: VaultEntry[] = [makeEntry({ title: 'sentinel' })]
|
||||
await act(async () => {
|
||||
reloaded = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(reloaded).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads views when the backend succeeds', async () => {
|
||||
const views = [makeView('Projects')]
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
list_views: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'list_views') return Promise.resolve(views)
|
||||
if (command === 'get_modified_files' || command === 'list_vault_folders') return Promise.resolve([])
|
||||
return Promise.resolve([makeEntry()])
|
||||
})
|
||||
|
||||
let reloaded: ViewFile[] = []
|
||||
await act(async () => {
|
||||
reloaded = await result.current.reloadViews()
|
||||
})
|
||||
|
||||
expect(reloaded.map((view) => view.definition.name)).toEqual(['Projects'])
|
||||
expect(result.current.views[0]?.definition.name).toBe('Projects')
|
||||
})
|
||||
|
||||
it('returns empty arrays when folder or view reloads fail', async () => {
|
||||
configureBackend({
|
||||
list_vault: [makeEntry()],
|
||||
list_vault_folders: [{ name: 'projects', path: 'projects', children: [] }],
|
||||
list_views: [makeView('Inbox')],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitForEntries(result)
|
||||
|
||||
backendInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'list_vault_folders' || command === 'list_views') {
|
||||
return Promise.reject(new Error('unavailable'))
|
||||
}
|
||||
if (command === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve([makeEntry()])
|
||||
})
|
||||
|
||||
let folders: unknown[] = []
|
||||
let views: ViewFile[] = []
|
||||
await act(async () => {
|
||||
folders = await result.current.reloadFolders()
|
||||
views = await result.current.reloadViews()
|
||||
})
|
||||
|
||||
expect(folders).toEqual([])
|
||||
expect(views).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -26,39 +26,124 @@ const mockGitHistory: GitCommit[] = [
|
||||
{ hash: 'abc1234567', shortHash: 'abc1234', message: 'initial commit', author: 'luca', date: 1700000000 },
|
||||
]
|
||||
|
||||
function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'get_file_history') return Promise.resolve(mockGitHistory)
|
||||
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
|
||||
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
return Promise.resolve(null)
|
||||
type MockCommandHandler = (args?: Record<string, unknown>) => unknown
|
||||
|
||||
const defaultMockHandlers: Record<string, MockCommandHandler> = {
|
||||
list_vault: () => mockEntries,
|
||||
reload_vault: () => mockEntries,
|
||||
get_all_content: () => mockContent,
|
||||
get_modified_files: () => mockModifiedFiles,
|
||||
get_file_history: () => mockGitHistory,
|
||||
get_file_diff: () => '--- a/note.md\n+++ b/note.md',
|
||||
get_file_diff_at_commit: (args) => `diff for ${(args as Record<string, string>)?.commitHash}`,
|
||||
git_commit: () => 'committed',
|
||||
git_push: () => ({ status: 'ok', message: 'Pushed to remote' }),
|
||||
}
|
||||
|
||||
const mockInvokeFn = vi.fn(defaultMockInvoke)
|
||||
function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
const handler = defaultMockHandlers[cmd]
|
||||
return Promise.resolve(handler ? handler(args) : null)
|
||||
}
|
||||
|
||||
let mockIsTauri = false
|
||||
const backendInvokeFn = vi.fn(defaultMockInvoke)
|
||||
|
||||
function isVaultLoadCommand(cmd: string) {
|
||||
return cmd === 'list_vault' || cmd === 'reload_vault'
|
||||
}
|
||||
|
||||
function buildVaultLoaderMock(options: {
|
||||
entries?: VaultEntry[]
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
pushResult?: { status: string; message: string }
|
||||
failHistory?: boolean
|
||||
} = {}) {
|
||||
const {
|
||||
entries = mockEntries,
|
||||
modifiedFiles = mockModifiedFiles,
|
||||
pushResult,
|
||||
failHistory = false,
|
||||
} = options
|
||||
|
||||
return ((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(entries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(modifiedFiles)
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
if (cmd === 'get_file_history' && failHistory) return Promise.reject(new Error('fail'))
|
||||
if (cmd === 'git_push' && pushResult) return Promise.resolve(pushResult)
|
||||
return defaultMockInvoke(cmd, args)
|
||||
}) as typeof defaultMockInvoke
|
||||
}
|
||||
|
||||
function buildReloadVaultPathMock(loads: Record<string, Promise<VaultEntry[]>>) {
|
||||
return ((cmd: string, args?: Record<string, unknown>) => {
|
||||
const path = typeof args?.path === 'string' ? args.path : undefined
|
||||
if (cmd === 'reload_vault' && path) return loads[path] ?? Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
|
||||
isTauri: () => mockIsTauri,
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => backendInvokeFn(cmd, args),
|
||||
}))
|
||||
|
||||
async function waitForEntries(
|
||||
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, undefined>>['result'],
|
||||
length = 1,
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(length)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForModifiedFiles(
|
||||
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, undefined>>['result'],
|
||||
length = 1,
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(length)
|
||||
})
|
||||
}
|
||||
|
||||
/** Render the vault loader hook and wait for initial data to load. */
|
||||
async function renderVaultLoader() {
|
||||
const hook = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(hook.result.current.entries).toHaveLength(1) })
|
||||
await waitForEntries(hook.result)
|
||||
return hook
|
||||
}
|
||||
|
||||
async function enableTauriMode() {
|
||||
mockIsTauri = true
|
||||
const tauri = await import('@tauri-apps/api/core')
|
||||
vi.mocked(tauri.invoke).mockImplementation((command: string, args?: Record<string, unknown>) =>
|
||||
backendInvokeFn(command, args),
|
||||
)
|
||||
}
|
||||
|
||||
describe('useVaultLoader', () => {
|
||||
beforeEach(() => {
|
||||
mockInvokeFn.mockImplementation(defaultMockInvoke)
|
||||
mockIsTauri = false
|
||||
backendInvokeFn.mockReset()
|
||||
backendInvokeFn.mockImplementation(defaultMockInvoke)
|
||||
})
|
||||
|
||||
it('loads entries on mount', async () => {
|
||||
@@ -70,13 +155,97 @@ describe('useVaultLoader', () => {
|
||||
it('loads modified files on mount', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
await waitForModifiedFiles(result)
|
||||
|
||||
expect(result.current.modifiedFiles[0].status).toBe('modified')
|
||||
})
|
||||
|
||||
it('does nothing until a real vault path exists', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader(''))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toEqual([])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
expect(result.current.modifiedFilesError).toBeNull()
|
||||
})
|
||||
|
||||
expect(backendInvokeFn).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('loads initial vault entries from a fresh reload in Tauri mode', async () => {
|
||||
await enableTauriMode()
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') {
|
||||
return Promise.resolve([
|
||||
{ ...mockEntries[0], path: '/vault/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' },
|
||||
])
|
||||
}
|
||||
if (cmd === 'reload_vault') {
|
||||
return Promise.resolve([
|
||||
{ ...mockEntries[0], path: '/vault/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' },
|
||||
{ ...mockEntries[0], path: '/vault/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
])
|
||||
}
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11'])
|
||||
})
|
||||
const issuedCommands = backendInvokeFn.mock.calls.map(([command]) => command)
|
||||
expect(issuedCommands).toContain('reload_vault')
|
||||
expect(issuedCommands).not.toContain('list_vault')
|
||||
})
|
||||
|
||||
it('ignores stale reload_vault results after the vault path changes', async () => {
|
||||
await enableTauriMode()
|
||||
const firstLoad = createDeferred<VaultEntry[]>()
|
||||
const secondLoad = createDeferred<VaultEntry[]>()
|
||||
|
||||
backendInvokeFn.mockImplementation(buildReloadVaultPathMock({
|
||||
'/vault-a': firstLoad.promise,
|
||||
'/vault-b': secondLoad.promise,
|
||||
}))
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ path }) => useVaultLoader(path),
|
||||
{ initialProps: { path: '/vault-a' } },
|
||||
)
|
||||
|
||||
rerender({ path: '/vault-b' })
|
||||
|
||||
await act(async () => {
|
||||
firstLoad.resolve([
|
||||
{ ...mockEntries[0], path: '/vault-a/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' },
|
||||
])
|
||||
await firstLoad.promise
|
||||
})
|
||||
|
||||
expect(result.current.entries).toEqual([])
|
||||
|
||||
await act(async () => {
|
||||
secondLoad.resolve([
|
||||
{ ...mockEntries[0], path: '/vault-b/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' },
|
||||
{ ...mockEntries[0], path: '/vault-b/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
])
|
||||
await secondLoad.promise
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addEntry', () => {
|
||||
it('prepends new entry', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
@@ -174,54 +343,44 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns new for git-untracked files (saved but not committed)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/brand-new.md', relativePath: 'note/brand-new.md', status: 'untracked' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
it.each([
|
||||
{
|
||||
name: 'returns new for git-untracked files (saved but not committed)',
|
||||
path: '/vault/note/brand-new.md',
|
||||
relativePath: 'note/brand-new.md',
|
||||
status: 'untracked',
|
||||
},
|
||||
{
|
||||
name: 'returns new for git-added files (staged but not committed)',
|
||||
path: '/vault/note/staged.md',
|
||||
relativePath: 'note/staged.md',
|
||||
status: 'added',
|
||||
},
|
||||
{
|
||||
name: 'treats untracked files as new (green dot, not orange)',
|
||||
path: '/vault/note/hello.md',
|
||||
relativePath: 'note/hello.md',
|
||||
status: 'untracked',
|
||||
},
|
||||
])('$name', async ({ path, relativePath, status }) => {
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
modifiedFiles: [{ path, relativePath, status }],
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
await waitForModifiedFiles(result)
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns new for git-added files (staged but not committed)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/staged.md', relativePath: 'note/staged.md', status: 'added' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/staged.md')).toBe('new')
|
||||
expect(result.current.getNoteStatus(path)).toBe('new')
|
||||
})
|
||||
|
||||
it('new status takes priority over git modified', async () => {
|
||||
// If a path is both new and in modifiedFiles, it should show as new
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
modifiedFiles: [
|
||||
{ path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
],
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
@@ -284,33 +443,24 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('treats untracked files as new (green dot, not orange)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
it('tracks and clears pendingSave states separately from unsaved/new markers', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
act(() => {
|
||||
result.current.addPendingSave('/vault/note/hello.md')
|
||||
})
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('pendingSave')
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('new')
|
||||
act(() => {
|
||||
result.current.removePendingSave('/vault/note/hello.md')
|
||||
})
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadGitHistory', () => {
|
||||
it('returns git commits for a file', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let history: GitCommit[] = []
|
||||
await act(async () => {
|
||||
@@ -322,13 +472,11 @@ describe('useVaultLoader', () => {
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'get_file_history') return Promise.reject(new Error('fail'))
|
||||
if (cmd === 'list_vault') return Promise.resolve([])
|
||||
if (cmd === 'get_all_content') return Promise.resolve({})
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
entries: [],
|
||||
modifiedFiles: [],
|
||||
failHistory: true,
|
||||
}))
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
@@ -345,11 +493,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
describe('loadDiff', () => {
|
||||
it('returns diff string for a file', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let diff = ''
|
||||
await act(async () => {
|
||||
@@ -362,11 +506,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
describe('loadDiffAtCommit', () => {
|
||||
it('returns diff for a specific commit', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let diff = ''
|
||||
await act(async () => {
|
||||
@@ -379,11 +519,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
describe('commitAndPush', () => {
|
||||
it('commits and pushes in mock mode', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
@@ -393,56 +529,63 @@ describe('useVaultLoader', () => {
|
||||
expect(response.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns rejected status when push is rejected', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
it('commits and pushes through the Tauri invoke path', async () => {
|
||||
await enableTauriMode()
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
await waitForEntries(result)
|
||||
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
response = await result.current.commitAndPush('tauri commit')
|
||||
})
|
||||
|
||||
expect(response.status).toBe('rejected')
|
||||
expect(response.message).toContain('Pull first')
|
||||
expect(response.status).toBe('ok')
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', {
|
||||
vaultPath: '/vault',
|
||||
message: 'tauri commit',
|
||||
})
|
||||
expect(backendInvokeFn).toHaveBeenCalledWith('git_push', {
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns network error status on network failure', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
it.each([
|
||||
{
|
||||
name: 'returns rejected status when push is rejected',
|
||||
pushResult: { status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' },
|
||||
expectedStatus: 'rejected',
|
||||
expectedMessage: 'Pull first',
|
||||
},
|
||||
{
|
||||
name: 'returns network error status on network failure',
|
||||
pushResult: { status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' },
|
||||
expectedStatus: 'network_error',
|
||||
expectedMessage: 'network error',
|
||||
},
|
||||
])('$name', async ({ pushResult, expectedStatus, expectedMessage }) => {
|
||||
backendInvokeFn.mockImplementation(buildVaultLoaderMock({
|
||||
modifiedFiles: [],
|
||||
pushResult,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response.status).toBe('network_error')
|
||||
expect(response.message).toContain('network error')
|
||||
expect(response.status).toBe(expectedStatus)
|
||||
expect(response.message).toContain(expectedMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadFolders', () => {
|
||||
it('refreshes folder tree from backend', async () => {
|
||||
const folders = [{ name: 'projects', path: 'projects', children: [] }]
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve(folders)
|
||||
return Promise.resolve(null)
|
||||
@@ -453,7 +596,7 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.folders).toEqual(folders)
|
||||
|
||||
const updatedFolders = [...folders, { name: 'journal', path: 'journal', children: [] }]
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve(updatedFolders)
|
||||
return defaultMockInvoke(cmd)
|
||||
}) as typeof defaultMockInvoke)
|
||||
@@ -462,15 +605,32 @@ describe('useVaultLoader', () => {
|
||||
|
||||
expect(result.current.folders).toEqual(updatedFolders)
|
||||
})
|
||||
|
||||
it('returns an empty folder list when the refresh fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.reject(new Error('no folders'))
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
let folders: Array<{ name: string; path: string; children: [] }> = []
|
||||
await act(async () => {
|
||||
folders = await result.current.reloadFolders()
|
||||
})
|
||||
|
||||
expect(folders).toEqual([])
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModifiedFiles', () => {
|
||||
it('refreshes modified files list', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadModifiedFiles()
|
||||
@@ -478,6 +638,158 @@ describe('useVaultLoader', () => {
|
||||
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('captures backend errors when modified files cannot be loaded', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.reject('git unavailable')
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
expect(result.current.modifiedFilesError).toBe('git unavailable')
|
||||
})
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to load modified files:', 'git unavailable')
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceEntry', () => {
|
||||
it('replaces an entry path and metadata in place', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
act(() => {
|
||||
result.current.replaceEntry('/vault/note/hello.md', {
|
||||
path: '/vault/note/renamed.md',
|
||||
filename: 'renamed.md',
|
||||
title: 'Renamed',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.entries[0]).toEqual(expect.objectContaining({
|
||||
path: '/vault/note/renamed.md',
|
||||
filename: 'renamed.md',
|
||||
title: 'Renamed',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadVault', () => {
|
||||
it('refreshes entries from reload_vault and reloads modified files', async () => {
|
||||
const reloadedEntry = {
|
||||
...mockEntries[0],
|
||||
path: '/vault/note/reloaded.md',
|
||||
filename: 'reloaded.md',
|
||||
title: 'Reloaded',
|
||||
}
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'reload_vault') return Promise.resolve([reloadedEntry])
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let entries: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
entries = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(entries.map((entry) => entry.title)).toEqual(['Reloaded'])
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Reloaded'])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty list when reloading the vault fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'reload_vault') return Promise.reject(new Error('reload failed'))
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
let entries: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
entries = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(entries).toEqual([])
|
||||
expect(result.current.entries).toEqual(mockEntries)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadViews', () => {
|
||||
it('refreshes views and falls back to an empty array when they are unavailable', async () => {
|
||||
const initialViews = [{
|
||||
filename: 'work.view',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}]
|
||||
const updatedViews = [{
|
||||
filename: 'projects.view',
|
||||
definition: {
|
||||
name: 'Projects',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}]
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([])
|
||||
if (cmd === 'list_views') return Promise.resolve(initialViews)
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
expect(result.current.views).toEqual(initialViews)
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_views') return Promise.resolve(updatedViews)
|
||||
return defaultMockInvoke(cmd)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
await act(async () => {
|
||||
const views = await result.current.reloadViews()
|
||||
expect(views).toEqual(updatedViews)
|
||||
})
|
||||
expect(result.current.views).toEqual(updatedViews)
|
||||
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_views') return Promise.reject(new Error('views unavailable'))
|
||||
return defaultMockInvoke(cmd)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
await act(async () => {
|
||||
const views = await result.current.reloadViews()
|
||||
expect(views).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -508,6 +820,10 @@ describe('resolveNoteStatus', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified')
|
||||
})
|
||||
|
||||
it('returns clean for unsupported git statuses', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'renamed')])).toBe('clean')
|
||||
})
|
||||
|
||||
it('newPaths takes priority over git modified', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
|
||||
@@ -8,13 +8,58 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
}
|
||||
|
||||
function hasVaultPath(vaultPath: string): boolean {
|
||||
return vaultPath.trim().length > 0
|
||||
}
|
||||
|
||||
function loadVaultEntries(vaultPath: string): Promise<VaultEntry[]> {
|
||||
const command = isTauri() ? 'reload_vault' : 'list_vault'
|
||||
return tauriCall<VaultEntry[]>(command, { path: vaultPath })
|
||||
}
|
||||
|
||||
async function loadVaultData(vaultPath: string) {
|
||||
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
|
||||
const entries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
const entries = await loadVaultEntries(vaultPath)
|
||||
console.log(`Vault scan complete: ${entries.length} entries found`)
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function loadVaultFolders(vaultPath: string): Promise<FolderNode[]> {
|
||||
return tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
}
|
||||
|
||||
function loadVaultViews(vaultPath: string): Promise<ViewFile[]> {
|
||||
return tauriCall<ViewFile[]>('list_views', { vaultPath })
|
||||
}
|
||||
|
||||
function resetVaultState(options: {
|
||||
clearNewPaths: () => void
|
||||
clearUnsaved: () => void
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
options.setEntries([])
|
||||
options.setFolders([])
|
||||
options.setViews([])
|
||||
options.setModifiedFiles([])
|
||||
options.setModifiedFilesError(null)
|
||||
options.clearNewPaths()
|
||||
options.clearUnsaved()
|
||||
}
|
||||
|
||||
function useCurrentVaultPathGuard(vaultPath: string) {
|
||||
const currentPathRef = useRef(vaultPath)
|
||||
|
||||
useEffect(() => {
|
||||
currentPathRef.current = vaultPath
|
||||
}, [vaultPath])
|
||||
|
||||
return useCallback((path: string) => currentPathRef.current === path, [])
|
||||
}
|
||||
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { message })
|
||||
@@ -96,32 +141,65 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const tracker = useNewNoteTracker()
|
||||
const pendingSave = usePendingSaveTracker()
|
||||
const unsaved = useUnsavedTracker()
|
||||
const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setFolders([]); setViews([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e }) => { setEntries(e) })
|
||||
const path = vaultPath
|
||||
resetVaultState({
|
||||
clearNewPaths: tracker.clear,
|
||||
clearUnsaved: unsaved.clearAll,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
})
|
||||
|
||||
if (!hasVaultPath(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
loadVaultData(path)
|
||||
.then(({ entries: e }) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setEntries(e)
|
||||
})
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
.then((f) => { setFolders(f ?? []) })
|
||||
loadVaultFolders(path)
|
||||
.then((f) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setFolders(f ?? [])
|
||||
})
|
||||
.catch(() => { /* folders are optional — ignore errors */ })
|
||||
tauriCall<ViewFile[]>('list_views', { vaultPath })
|
||||
.then((v) => { setViews(v ?? []) })
|
||||
loadVaultViews(path)
|
||||
.then((v) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setViews(v ?? [])
|
||||
})
|
||||
.catch(() => { /* views are optional — ignore errors */ })
|
||||
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
|
||||
}, [vaultPath, tracker.clear, unsaved.clearAll, isCurrentVaultPath])
|
||||
|
||||
const loadModifiedFiles = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
setModifiedFilesError(null)
|
||||
|
||||
if (!hasVaultPath(path)) {
|
||||
setModifiedFiles([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setModifiedFilesError(null)
|
||||
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
|
||||
const files = await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath: path }, {})
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setModifiedFiles(files)
|
||||
} catch (err) {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
const message = typeof err === 'string' ? err : 'Failed to load changes'
|
||||
console.warn('Failed to load modified files:', err)
|
||||
setModifiedFilesError(message)
|
||||
setModifiedFiles([])
|
||||
}
|
||||
}, [vaultPath])
|
||||
}, [vaultPath, isCurrentVaultPath])
|
||||
|
||||
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
|
||||
|
||||
@@ -176,27 +254,47 @@ export function useVaultLoader(vaultPath: string) {
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
|
||||
const reloadFolders = useCallback(
|
||||
() => tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
.then((f) => { setFolders(f ?? []) })
|
||||
.catch(() => { /* folders are optional — ignore errors */ }),
|
||||
[vaultPath],
|
||||
() => {
|
||||
const path = vaultPath
|
||||
return loadVaultFolders(path)
|
||||
.then((f) => {
|
||||
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
|
||||
const nextFolders = f ?? []
|
||||
setFolders(nextFolders)
|
||||
return nextFolders
|
||||
})
|
||||
.catch(() => [] as FolderNode[])
|
||||
},
|
||||
[vaultPath, isCurrentVaultPath],
|
||||
)
|
||||
|
||||
const reloadVault = useCallback(
|
||||
() => {
|
||||
const path = vaultPath
|
||||
clearPrefetchCache()
|
||||
return tauriCall<VaultEntry[]>('reload_vault', { path: vaultPath })
|
||||
.then((entries) => { setEntries(entries); loadModifiedFiles(); return entries })
|
||||
return tauriCall<VaultEntry[]>('reload_vault', { path })
|
||||
.then((entries) => {
|
||||
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
|
||||
setEntries(entries)
|
||||
void loadModifiedFiles()
|
||||
return entries
|
||||
})
|
||||
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] })
|
||||
},
|
||||
[vaultPath, loadModifiedFiles],
|
||||
[vaultPath, loadModifiedFiles, isCurrentVaultPath],
|
||||
)
|
||||
|
||||
const reloadViews = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
try {
|
||||
setViews(await tauriCall<ViewFile[]>('list_views', { vaultPath }) ?? [])
|
||||
const nextViews = await loadVaultViews(path)
|
||||
if (!isCurrentVaultPath(path)) return []
|
||||
const resolvedViews = nextViews ?? []
|
||||
setViews(resolvedViews)
|
||||
return resolvedViews
|
||||
} catch { /* views are optional */ }
|
||||
}, [vaultPath])
|
||||
return []
|
||||
}, [vaultPath, isCurrentVaultPath])
|
||||
|
||||
return {
|
||||
entries, folders, views, modifiedFiles, modifiedFilesError,
|
||||
|
||||
@@ -195,6 +195,55 @@ describe('useVaultSwitcher', () => {
|
||||
expect(result.current.isGettingStartedHidden).toBe(false)
|
||||
})
|
||||
|
||||
it('drops stale canonical Getting Started entries when the starter path is missing', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [
|
||||
{ label: 'Getting Started', path: expectedDefaultVaultPath },
|
||||
{ label: 'Work', path: '/work/vault' },
|
||||
],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: ({ path }) => path === '/work/vault',
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
|
||||
expect(result.current.vaultPath).toBe('/work/vault')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVaultListStore).toEqual({
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale canonical Getting Started selection when the starter path is missing', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Getting Started', path: expectedDefaultVaultPath }],
|
||||
active_vault: expectedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
setMockInvokeBehavior({ checkVaultExists: false })
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
expect(result.current.allVaults).toEqual([])
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVaultListStore).toEqual({
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user