Compare commits
19 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35b035643e | ||
|
|
d3d663af0b | ||
|
|
8f624350d8 | ||
|
|
0e54173834 | ||
|
|
a6f1524a78 | ||
|
|
4cd5baeb55 | ||
|
|
fb1265e088 | ||
|
|
ca89662231 | ||
|
|
97f285e3cb | ||
|
|
4bd4ef43dc | ||
|
|
be29880a3e | ||
|
|
7be5519f61 | ||
|
|
8cefbe949a | ||
|
|
dd0a55b8e8 | ||
|
|
610276b1bc | ||
|
|
e6c968e37e | ||
|
|
367a66f32a | ||
|
|
6a4046915c | ||
|
|
ec8b637c84 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.89
|
||||
AVERAGE_THRESHOLD=9.75
|
||||
HOTSPOT_THRESHOLD=9.9
|
||||
AVERAGE_THRESHOLD=9.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
|
||||
|
||||
@@ -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://codescene.io/projects/76865) [](https://codescene.io/projects/76865) [](https://codecov.io/gh/refactoringhq/tolaria)
|
||||
|
||||
# 💧 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.
|
||||
|
||||
@@ -510,6 +510,7 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
|
||||
### Raw Editor Mode
|
||||
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
|
||||
|
||||
## Styling
|
||||
|
||||
@@ -669,6 +670,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -199,7 +199,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
@@ -782,6 +782,7 @@ Every push to `main` triggers `.github/workflows/release.yml`:
|
||||
```
|
||||
push to main
|
||||
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
|
||||
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
|
||||
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
|
||||
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
|
||||
→ build job:
|
||||
@@ -789,7 +790,7 @@ push to main
|
||||
→ upload signed .app.tar.gz + .sig updater artifacts
|
||||
→ release job:
|
||||
→ generate alpha-latest.json
|
||||
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
|
||||
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
|
||||
→ pages job:
|
||||
→ build static HTML release history page
|
||||
→ publish alpha/latest.json
|
||||
@@ -819,7 +820,7 @@ push stable-vYYYY.M.D tag
|
||||
### Versioning
|
||||
|
||||
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
|
||||
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
|
||||
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. The GitHub release tag zero-pads the sequence as `alpha-vYYYY.M.D-alpha.NNNN` so GitHub release ordering remains chronological.
|
||||
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
|
||||
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
|
||||
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.
|
||||
|
||||
48
docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md
Normal file
48
docs/adr/0071-external-vault-refresh-and-clean-tab-reopen.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0071"
|
||||
title: "External vault updates reload derived state and reopen the clean active note"
|
||||
status: active
|
||||
date: 2026-04-21
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0002 makes the filesystem the source of truth, and ADR-0043 keeps locally edited frontmatter reactive inside the running UI. But external vault mutations still had a gap. A `git pull` or an AI agent edit could change notes on disk while the app kept showing stale note-list state, stale derived relationships, or an editor surface that still rendered the pre-refresh BlockNote document.
|
||||
|
||||
The fix needed to satisfy a few constraints at once:
|
||||
|
||||
- refresh all vault-derived UI, not just the main note list
|
||||
- preserve unsaved local edits instead of clobbering them with disk state
|
||||
- reopen the active note from disk when it is safe, even if another file changed, because backlinks, inverse relationships, and other derived surfaces can depend on the whole vault
|
||||
- handle the native editor case where an in-place file update requires a full tab reopen to show the fresh document reliably
|
||||
|
||||
## Decision
|
||||
|
||||
**All external vault mutations now reconcile through one shared refresh path that reloads vault-derived state and then conditionally reopens the active note from disk.**
|
||||
|
||||
Tolaria now routes post-pull refreshes and AI-agent file modifications through the same `refreshPulledVaultState()` helper.
|
||||
|
||||
That shared path does the following:
|
||||
|
||||
1. Reload `vault.entries`, folders, and saved views together.
|
||||
2. If there is no active note, stop after the reload.
|
||||
3. If the active note has unsaved local edits, keep the current editor buffer and do not replace it from disk.
|
||||
4. Otherwise, find the refreshed `VaultEntry` for the active note and replace the active tab with freshly loaded disk content.
|
||||
5. If the active file itself changed in place during the external update, close the tab before reopening it so BlockNote fully remounts onto the new document.
|
||||
6. If the active file no longer exists after the reload, close the open tab state instead of leaving a stale editor behind.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Shared external-refresh reconciler** (chosen): one policy for pulls and agent edits, consistent vault-derived UI, and explicit protection for unsaved local edits. Cons: more coupling between sync flows and tab management.
|
||||
- **Patch only the changed surfaces ad hoc**: smaller individual fixes, but high risk of drift between pull handling, agent handling, and future external-write paths.
|
||||
- **Always force a full app-level reload**: simplest correctness story, but too disruptive and more likely to throw away user context unnecessarily.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Any workflow that mutates the vault externally, such as git pulls or agent writes, should go through the shared refresh reconciler rather than reloading a single surface in isolation.
|
||||
- Clean active notes now converge back to on-disk truth automatically after external updates.
|
||||
- Unsaved local edits remain protected from external refreshes, even when the rest of the vault reloads.
|
||||
- Folder, saved-view, backlink, and inverse-relationship surfaces stay aligned with the refreshed vault, not just the editor tab.
|
||||
- Tolaria now treats "refresh after external mutation" as a first-class synchronization concern rather than a per-feature fix.
|
||||
- Re-evaluate if the editor gains a reliable in-place document reset API, because that could remove the need for the close-and-reopen step when the active file itself changed.
|
||||
@@ -126,3 +126,4 @@ proposed → active → superseded
|
||||
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
|
||||
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
|
||||
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
|
||||
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | active |
|
||||
|
||||
@@ -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()]),
|
||||
);
|
||||
}
|
||||
@@ -642,7 +642,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,
|
||||
|
||||
@@ -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 } 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',
|
||||
@@ -548,11 +551,15 @@ describe('ReferencedByPanel', () => {
|
||||
]
|
||||
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByTestId('derived-relationships-separator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Write Essays')).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Derived relationships')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Read-only groups sourced from other notes.')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking a referenced-by entry', () => {
|
||||
@@ -572,6 +579,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', () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters large datasets by search query', () => {
|
||||
it('filters large datasets by search query', async () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
|
||||
@@ -67,9 +67,11 @@ describe('NoteList virtualized datasets', () => {
|
||||
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 waitFor(() => {
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('sorts large datasets correctly', () => {
|
||||
|
||||
@@ -84,10 +84,16 @@ function renderManagedViewNoteList({
|
||||
}
|
||||
}
|
||||
|
||||
function searchNoteList(query: string) {
|
||||
async function searchNoteList(query: string) {
|
||||
const searchInput = screen.queryByPlaceholderText('Search notes...')
|
||||
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
function renderBookNoteList({
|
||||
@@ -117,11 +123,11 @@ function renderBookNoteList({
|
||||
})
|
||||
}
|
||||
|
||||
function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
searchNoteList(matchingQuery)
|
||||
async function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
await searchNoteList(matchingQuery)
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
|
||||
searchNoteList(hiddenQuery)
|
||||
await searchNoteList(hiddenQuery)
|
||||
expect(screen.queryByText(title)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
}
|
||||
@@ -191,14 +197,14 @@ describe('NoteList rendering', () => {
|
||||
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by a case-insensitive search query', () => {
|
||||
it('filters by a case-insensitive search query', async () => {
|
||||
renderNoteList()
|
||||
searchNoteList('facebook')
|
||||
await searchNoteList('facebook')
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by snippet text when the title does not match', () => {
|
||||
it('filters by snippet text when the title does not match', async () => {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
||||
@@ -206,13 +212,13 @@ describe('NoteList rendering', () => {
|
||||
],
|
||||
})
|
||||
|
||||
searchNoteList('nebula-only')
|
||||
await searchNoteList('nebula-only')
|
||||
|
||||
expect(screen.getByText('Beta Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by visible property values and ignores hidden properties', () => {
|
||||
it('filters by visible property values and ignores hidden properties', async () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Property Search Note',
|
||||
@@ -221,10 +227,10 @@ describe('NoteList rendering', () => {
|
||||
allNotesNoteListProperties: null,
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
await expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
})
|
||||
|
||||
it('uses the active all-notes columns when filtering by visible property values', () => {
|
||||
it('uses the active all-notes columns when filtering by visible property values', async () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Override Search Note',
|
||||
@@ -233,7 +239,7 @@ describe('NoteList rendering', () => {
|
||||
allNotesNoteListProperties: ['Owner'],
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
await expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
})
|
||||
|
||||
it('sorts entries by last modified descending by default', () => {
|
||||
@@ -322,7 +328,7 @@ describe('NoteList rendering', () => {
|
||||
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps existing neighborhood groups visible at zero after search filters them out', () => {
|
||||
it('keeps existing neighborhood groups visible at zero after search filters them out', async () => {
|
||||
const parent = makeEntry({
|
||||
path: '/vault/parent.md',
|
||||
filename: 'parent.md',
|
||||
@@ -344,7 +350,7 @@ describe('NoteList rendering', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
|
||||
|
||||
searchNoteList('missing-neighborhood-match')
|
||||
await searchNoteList('missing-neighborhood-match')
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
||||
|
||||
@@ -113,6 +113,16 @@ describe('buildDynamicSections', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Daily Log', isA: 'Journal' },
|
||||
]
|
||||
|
||||
const sections = buildDynamicSections(entries, {})
|
||||
|
||||
expect(sections.map((section) => section.type)).not.toContain('Journal')
|
||||
})
|
||||
|
||||
it('includes Journal when a real Journal type definition exists', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Daily Log', isA: 'journal' },
|
||||
]
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Journal: { ...baseEntry, title: 'Journal', isA: 'Type' },
|
||||
journal: { ...baseEntry, title: 'Journal', isA: 'Type' },
|
||||
@@ -120,7 +130,7 @@ describe('buildDynamicSections', () => {
|
||||
|
||||
const sections = buildDynamicSections(entries, typeEntryMap)
|
||||
|
||||
expect(sections.map((section) => section.type)).not.toContain('Journal')
|
||||
expect(sections.filter((section) => section.type === 'Journal')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -517,11 +517,17 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
describe('type visibility via visible property', () => {
|
||||
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
|
||||
path: `/vault/${title.toLowerCase()}.md`,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Type',
|
||||
const makeSidebarEntry = (overrides: {
|
||||
path: string
|
||||
filename: string
|
||||
title: string
|
||||
isA: string
|
||||
visible?: boolean | null
|
||||
}): VaultEntry => ({
|
||||
path: overrides.path,
|
||||
filename: overrides.filename,
|
||||
title: overrides.title,
|
||||
isA: overrides.isA,
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
@@ -542,11 +548,19 @@ describe('Sidebar', () => {
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible,
|
||||
visible: overrides.visible ?? null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
})
|
||||
|
||||
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => makeSidebarEntry({
|
||||
path: `/vault/${title.toLowerCase()}.md`,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Type',
|
||||
visible,
|
||||
})
|
||||
|
||||
it('hides a section when its Type entry has visible: false', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
@@ -620,6 +634,34 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the sidebar type picker when Journal becomes a real type while the app stays open', () => {
|
||||
const journalEntries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeSidebarEntry({
|
||||
path: '/vault/april-21.md',
|
||||
filename: 'april-21.md',
|
||||
title: 'April 21',
|
||||
isA: 'journal',
|
||||
}),
|
||||
]
|
||||
const { rerender } = render(<Sidebar entries={journalEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Customize sections'))
|
||||
expect(screen.queryByLabelText('Toggle Journals')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Sidebar
|
||||
entries={[...journalEntries, makeTypeEntry('Journal', null)]}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByLabelText('Toggle Journals')).toBeInTheDocument()
|
||||
|
||||
rerender(<Sidebar entries={journalEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByLabelText('Toggle Journals')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves custom section colors in the customize popover', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
|
||||
import { humanizePropertyKey } from '../../utils/propertyLabels'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { PROPERTY_PANEL_LABEL_CLASS_NAME } from '../propertyPanelLayout'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { ActionTooltip } from '../ui/action-tooltip'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
export interface ReferencedByItem {
|
||||
@@ -16,57 +20,65 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Map<string, VaultEntry>>()
|
||||
const map = new Map<string, { entriesByPath: Map<string, VaultEntry>; inverseKeys: Set<string> }>()
|
||||
for (const item of items) {
|
||||
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
|
||||
const entriesByPath = map.get(label) ?? new Map<string, VaultEntry>()
|
||||
entriesByPath.set(item.entry.path, item.entry)
|
||||
map.set(label, entriesByPath)
|
||||
const group = map.get(label) ?? { entriesByPath: new Map<string, VaultEntry>(), inverseKeys: new Set<string>() }
|
||||
group.entriesByPath.set(item.entry.path, item.entry)
|
||||
group.inverseKeys.add(humanizePropertyKey(item.viaKey))
|
||||
map.set(label, group)
|
||||
}
|
||||
|
||||
return orderInverseRelationshipLabels(map.keys()).map((label) => [
|
||||
label,
|
||||
[...(map.get(label)?.values() ?? [])],
|
||||
] as const)
|
||||
return orderInverseRelationshipLabels(map.keys())
|
||||
.map((label) => {
|
||||
const group = map.get(label)
|
||||
if (!group) return null
|
||||
|
||||
return {
|
||||
entries: [...group.entriesByPath.values()],
|
||||
inverseKeys: [...group.inverseKeys].sort((left, right) => left.localeCompare(right)),
|
||||
label,
|
||||
}
|
||||
})
|
||||
.filter((group): group is { entries: VaultEntry[]; inverseKeys: string[]; label: string } => group !== null && group.entries.length > 0)
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
if (grouped.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="referenced-by-panel">
|
||||
<div className="mb-2 flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
|
||||
Derived relationships
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
Read-only groups sourced from other notes.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([label, groupEntries]) => (
|
||||
<div key={label}>
|
||||
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
noteIcon={e.icon}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="referenced-by-panel flex flex-col gap-3">
|
||||
<Separator data-testid="derived-relationships-separator" />
|
||||
<div className="flex flex-col gap-3">
|
||||
{grouped.map(({ label, entries: groupEntries, inverseKeys }) => {
|
||||
const tooltip = `Derived inverse relationship, inverse of ${inverseKeys.join(' and ')}.`
|
||||
|
||||
return (
|
||||
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
|
||||
<ActionTooltip copy={{ label: tooltip }} side="top" align="start">
|
||||
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} tabIndex={0} data-testid="derived-relationship-label">
|
||||
{label}
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
noteIcon={e.icon}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render as rtlRender, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DynamicRelationshipsPanel } from './RelationshipsPanel'
|
||||
import { ReferencedByPanel, type ReferencedByItem } from './ReferencedByPanel'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
@@ -105,7 +108,7 @@ describe('relationship display labels', () => {
|
||||
expect(relationshipRow).toHaveStyle({ gridColumn: '1 / -1' })
|
||||
})
|
||||
|
||||
it('humanizes snake_case keys in the referenced-by panel', () => {
|
||||
it('humanizes snake_case keys in the referenced-by panel', async () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
|
||||
]
|
||||
@@ -113,6 +116,8 @@ describe('relationship display labels', () => {
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
fireEvent.focus(screen.getByText('Children'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -133,4 +138,16 @@ describe('relationship display labels', () => {
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show empty preferred inverse groups', () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,7 +10,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr
|
||||
|
||||
const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
@@ -19,12 +20,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
sidebarCollapsed?: boolean
|
||||
searchVisible: boolean
|
||||
search: string
|
||||
isSearching: boolean
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
propertyPicker?: ListPropertiesPopoverProps | null
|
||||
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
onCreateNote: () => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -51,7 +55,24 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
</div>
|
||||
{searchVisible && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search notes..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
className="h-8 text-[13px]"
|
||||
/>
|
||||
<div className="min-w-[92px] text-[12px] text-muted-foreground" aria-live="polite">
|
||||
{isSearching && (
|
||||
<span className="flex items-center gap-1" data-testid="note-list-search-loading">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Searching...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -376,14 +376,23 @@ describe('extractVaultTypes', () => {
|
||||
expect(extractVaultTypes(entries)).toEqual(['Note', 'Project'])
|
||||
})
|
||||
|
||||
it('omits the legacy Journal type from extracted command-palette types', () => {
|
||||
it('omits the legacy Journal type when no Type document defines it', () => {
|
||||
const entries = [
|
||||
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
{ path: '/note.md', title: 'General Note', isA: 'Note' },
|
||||
] as never[]
|
||||
|
||||
expect(extractVaultTypes(entries)).toEqual(['Note'])
|
||||
})
|
||||
|
||||
it('includes Journal when a real Type document defines it', () => {
|
||||
const entries = [
|
||||
{ path: '/journal.md', title: 'Journal', isA: 'Type' },
|
||||
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
||||
{ path: '/note.md', title: 'General Note', isA: 'Note' },
|
||||
] as never[]
|
||||
|
||||
expect(extractVaultTypes(entries)).toEqual(['Note'])
|
||||
expect(extractVaultTypes(entries)).toEqual(['Journal', 'Note'])
|
||||
})
|
||||
|
||||
it('omits hidden types from extracted command-palette types', () => {
|
||||
|
||||
@@ -157,10 +157,10 @@ describe('useEditorSaveWithLinks', () => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Project',
|
||||
status: 'Active',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
@@ -185,9 +185,9 @@ describe('useEditorSaveWithLinks', () => {
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Essay',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
@@ -196,15 +196,66 @@ describe('useEditorSaveWithLinks', () => {
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
isA: 'Note',
|
||||
})
|
||||
}))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs custom relationships and properties from raw frontmatter immediately', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
properties: { Owner: '[[person/alice]]', custom: 'value' },
|
||||
relationships: { Owner: ['[[person/alice]]'] },
|
||||
}))
|
||||
})
|
||||
|
||||
it('clears stale note-list and inspector metadata when raw frontmatter is removed', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
|
||||
})
|
||||
|
||||
updateEntry.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'Body without frontmatter')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
|
||||
belongsTo: [],
|
||||
properties: {},
|
||||
relationships: {},
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps the last derived entry state while the raw frontmatter is temporarily incomplete', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\n---\nBody')
|
||||
})
|
||||
|
||||
updateEntry.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/old-title.md', '# Renamed Note\n\nBody', { title: 'Renamed Note', hasH1: true }],
|
||||
['/renamed-note.md', 'Body without a heading', { title: 'Renamed Note', hasH1: false }],
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { startTransition, useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import { contentToEntryPatch } from './frontmatterOps'
|
||||
import { deriveRawEditorEntryState } from './rawEditorEntryState'
|
||||
import { deriveDisplayTitleState } from '../utils/noteTitle'
|
||||
import { detectFrontmatterState } from '../utils/frontmatter'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const EMPTY_DERIVED_ENTRY_STATE_KEY = JSON.stringify(deriveRawEditorEntryState(''))
|
||||
|
||||
function shouldSyncFrontmatterState(content: string): boolean {
|
||||
const frontmatterState = detectFrontmatterState(content)
|
||||
if (frontmatterState === 'invalid') return false
|
||||
return !(frontmatterState === 'none' && content.startsWith('---\n'))
|
||||
}
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
@@ -34,7 +43,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
})
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)
|
||||
const prevTitleKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
@@ -44,19 +53,23 @@ export function useEditorSaveWithLinks(config: {
|
||||
prevLinksKeyRef.current = key
|
||||
updateEntry(path, { outgoingLinks: links })
|
||||
}
|
||||
const frontmatterPatch = contentToEntryPatch(content)
|
||||
const frontmatterPatch = shouldSyncFrontmatterState(content)
|
||||
? deriveRawEditorEntryState(content)
|
||||
: null
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const titlePatch = deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
frontmatterTitle: typeof frontmatterPatch?.title === 'string' ? frontmatterPatch.title : null,
|
||||
})
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
|
||||
if (frontmatterPatch) {
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
updateEntry(path, fmPatch)
|
||||
}
|
||||
}
|
||||
const titleKey = JSON.stringify(titlePatch)
|
||||
if (titleKey !== prevTitleKeyRef.current) {
|
||||
|
||||
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchMenuEvent, useMenuEvents, type MenuEventHandlers } from './useMenuEvents'
|
||||
|
||||
const isTauriMock = vi.fn(() => false)
|
||||
const listenMock = vi.fn()
|
||||
const invokeMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: (...args: unknown[]) => listenMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}))
|
||||
|
||||
function makeHandlers(): MenuEventHandlers {
|
||||
return {
|
||||
onSetViewMode: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onCreateType: vi.fn(),
|
||||
onQuickOpen: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onToggleInspector: vi.fn(),
|
||||
onCommandPalette: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
onToggleRawEditor: vi.fn(),
|
||||
onToggleDiff: vi.fn(),
|
||||
onToggleAIChat: vi.fn(),
|
||||
onGoBack: vi.fn(),
|
||||
onGoForward: vi.fn(),
|
||||
onCheckForUpdates: vi.fn(),
|
||||
onSelectFilter: vi.fn(),
|
||||
onOpenVault: vi.fn(),
|
||||
onRemoveActiveVault: vi.fn(),
|
||||
onRestoreGettingStarted: vi.fn(),
|
||||
onAddRemote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
onRestoreDeletedNote: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
multiSelectionCommandRef: { current: null },
|
||||
activeTabPath: '/vault/test.md',
|
||||
hasRestorableDeletedNote: false,
|
||||
hasNoRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMenuEvents note-list search bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('dispatches the note-list search toggle event for the native Cmd+F menu item', () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('laputa:toggle-note-list-search', listener)
|
||||
|
||||
dispatchMenuEvent('edit-toggle-note-list-search', makeHandlers())
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
window.removeEventListener('laputa:toggle-note-list-search', listener)
|
||||
})
|
||||
|
||||
it('syncs note-list search availability into the native menu state', async () => {
|
||||
isTauriMock.mockReturnValue(true)
|
||||
listenMock.mockResolvedValue(vi.fn())
|
||||
|
||||
renderHook(() => useMenuEvents(makeHandlers()))
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:note-list-search-availability', {
|
||||
detail: { enabled: true },
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenLastCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: true }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
isAppCommandId,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
import {
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
dispatchNoteListSearchToggle,
|
||||
readNoteListSearchAvailability,
|
||||
} from '../utils/noteListSearchEvents'
|
||||
|
||||
const NOTE_LIST_SEARCH_MENU_ID = 'edit-toggle-note-list-search'
|
||||
|
||||
export interface MenuEventHandlers extends AppCommandHandlers {
|
||||
activeTabPath: string | null
|
||||
@@ -21,6 +28,7 @@ interface MenuStatePayload {
|
||||
hasConflicts?: boolean
|
||||
hasRestorableDeletedNote?: boolean
|
||||
hasNoRemote?: boolean
|
||||
noteListSearchEnabled?: boolean
|
||||
}
|
||||
|
||||
function readCustomEventDetail(event: Event): string | null {
|
||||
@@ -118,8 +126,28 @@ function useNativeMenuStateSync(state: MenuStatePayload) {
|
||||
}, [state])
|
||||
}
|
||||
|
||||
function useNoteListSearchMenuState() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleAvailabilityEvent = (event: Event) => {
|
||||
const nextEnabled = readNoteListSearchAvailability(event)
|
||||
if (nextEnabled !== null) setEnabled(nextEnabled)
|
||||
}
|
||||
|
||||
window.addEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
}, [])
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
|
||||
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
if (id === NOTE_LIST_SEARCH_MENU_ID) {
|
||||
dispatchNoteListSearchToggle()
|
||||
return
|
||||
}
|
||||
if (!isAppCommandId(id)) return
|
||||
executeAppCommand(id, h, 'native-menu')
|
||||
}
|
||||
@@ -127,6 +155,7 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
|
||||
export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
const ref = useRef(handlers)
|
||||
const noteListSearchEnabled = useNoteListSearchMenuState()
|
||||
const hasActiveNote = handlers.activeTabPath !== null
|
||||
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
|
||||
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
|
||||
@@ -146,5 +175,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
hasConflicts,
|
||||
hasRestorableDeletedNote,
|
||||
hasNoRemote,
|
||||
noteListSearchEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,39 +26,113 @@ 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 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 +144,88 @@ 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('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(((cmd: string, args?: Record<string, unknown>) => {
|
||||
const path = typeof args?.path === 'string' ? args.path : undefined
|
||||
|
||||
if (cmd === 'reload_vault') {
|
||||
if (path === '/vault-a') return firstLoad.promise
|
||||
if (path === '/vault-b') return secondLoad.promise
|
||||
}
|
||||
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)
|
||||
|
||||
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 +323,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 +423,11 @@ 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)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
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 +439,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 +460,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 +473,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 +486,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 +496,42 @@ 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.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('rejected')
|
||||
expect(response.message).toContain('Pull first')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
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 +542,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)
|
||||
@@ -466,11 +555,7 @@ describe('useVaultLoader', () => {
|
||||
|
||||
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()
|
||||
|
||||
@@ -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,54 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
}
|
||||
|
||||
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 +137,54 @@ 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,
|
||||
})
|
||||
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
|
||||
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 +239,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,
|
||||
|
||||
@@ -5,6 +5,19 @@ import {
|
||||
sanitizeTelemetryEnvValue,
|
||||
} from './telemetryConfig'
|
||||
|
||||
function resolveConfig(overrides: {
|
||||
VITE_SENTRY_DSN?: string
|
||||
VITE_POSTHOG_KEY?: string
|
||||
VITE_POSTHOG_HOST?: string
|
||||
} = {}) {
|
||||
return resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('sanitizeTelemetryEnvValue', () => {
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
|
||||
@@ -22,50 +35,77 @@ describe('sanitizeTelemetryEnvValue', () => {
|
||||
})
|
||||
|
||||
describe('resolveFrontendTelemetryConfig', () => {
|
||||
it('keeps valid telemetry values after sanitizing them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
|
||||
VITE_POSTHOG_KEY: " 'phc_test_key' ",
|
||||
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
it.each([
|
||||
{
|
||||
name: 'keeps valid telemetry values after sanitizing them',
|
||||
overrides: {
|
||||
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
|
||||
VITE_POSTHOG_KEY: " 'phc_test_key' ",
|
||||
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
|
||||
},
|
||||
expected: {
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'adds https to scheme-less DSNs and PostHog hosts',
|
||||
overrides: {
|
||||
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
|
||||
},
|
||||
expected: {
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
},
|
||||
},
|
||||
])('$name', ({ overrides, expected }) => {
|
||||
expect(resolveConfig(overrides)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('uses the default PostHog host when one is not configured', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
}).posthogHost).toBe(defaultPostHogHost)
|
||||
})
|
||||
|
||||
it('adds https to scheme-less DSNs and PostHog hosts', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
expect(resolveConfig({ VITE_POSTHOG_HOST: undefined }).posthogHost).toBe(defaultPostHogHost)
|
||||
})
|
||||
|
||||
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'not a dsn',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
|
||||
}).sentryDsn).toBe('')
|
||||
expect(resolveConfig({ VITE_SENTRY_DSN: 'not a dsn' }).sentryDsn).toBe('')
|
||||
})
|
||||
|
||||
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'not a url',
|
||||
}).posthogHost).toBeNull()
|
||||
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
|
||||
})
|
||||
|
||||
it('drops placeholder telemetry hosts that would create broken startup requests', () => {
|
||||
expect(resolveConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@false/123456',
|
||||
VITE_POSTHOG_HOST: 'false',
|
||||
})).toEqual({
|
||||
sentryDsn: '',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('drops single-label telemetry hosts but keeps localhost for dev', () => {
|
||||
expect(resolveConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@le/123456',
|
||||
VITE_POSTHOG_HOST: 'https://le',
|
||||
})).toEqual({
|
||||
sentryDsn: '',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: null,
|
||||
})
|
||||
|
||||
expect(resolveConfig({
|
||||
VITE_SENTRY_DSN: 'http://public@localhost:9000/123456',
|
||||
VITE_POSTHOG_HOST: 'http://localhost:8010',
|
||||
})).toEqual({
|
||||
sentryDsn: 'http://public@localhost:9000/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'http://localhost:8010',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
|
||||
const DISALLOWED_TELEMETRY_HOSTS = new Set([
|
||||
'false',
|
||||
'true',
|
||||
'null',
|
||||
'undefined',
|
||||
'none',
|
||||
'disabled',
|
||||
])
|
||||
|
||||
type TelemetryEnv = {
|
||||
VITE_SENTRY_DSN?: string
|
||||
@@ -35,12 +43,36 @@ export function sanitizeTelemetryEnvValue(value: string | undefined): string {
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
&& isAllowedTelemetryHostname(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
const normalized = hostname.trim().replace(/\.$/, '').toLowerCase()
|
||||
if (normalized.startsWith('[') && normalized.endsWith(']')) {
|
||||
return normalized.slice(1, -1)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isIpAddress(hostname: string): boolean {
|
||||
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
|
||||
return hostname.split('.').every((segment) => Number(segment) <= 255)
|
||||
}
|
||||
|
||||
return hostname.includes(':') && /^[\da-f:]+$/i.test(hostname)
|
||||
}
|
||||
|
||||
function isAllowedTelemetryHostname(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname)
|
||||
if (!normalized || DISALLOWED_TELEMETRY_HOSTS.has(normalized)) return false
|
||||
if (normalized === 'localhost') return true
|
||||
return normalized.includes('.') || isIpAddress(normalized)
|
||||
}
|
||||
|
||||
function normalizeHttpLikeValue(value: string): string {
|
||||
if (!value) return ''
|
||||
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
|
||||
|
||||
@@ -70,6 +70,12 @@ describe('parseFrontmatter', () => {
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves single wikilinks as scalar strings instead of inline arrays', () => {
|
||||
const fm = parseFrontmatter('---\nOwner: [[person/alice]]\nBelongs to: [[project/alpha]]\n---\nBody')
|
||||
expect(fm['Owner']).toBe('[[person/alice]]')
|
||||
expect(fm['Belongs to']).toBe('[[project/alpha]]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectFrontmatterState', () => {
|
||||
|
||||
@@ -16,6 +16,10 @@ function isBlockScalar(value: string): boolean {
|
||||
return value === '' || value === '|' || value === '>'
|
||||
}
|
||||
|
||||
function isInlineArrayLiteral(value: string): boolean {
|
||||
return value.startsWith('[') && value.endsWith(']') && !value.startsWith('[[')
|
||||
}
|
||||
|
||||
function parseInlineArray(value: string): FrontmatterValue {
|
||||
const items = value.slice(1, -1).split(',').map(s => unquote(s.trim()))
|
||||
return collapseList(items)
|
||||
@@ -44,41 +48,71 @@ export function detectFrontmatterState(content: string | null): FrontmatterState
|
||||
return hasValidLine ? 'valid' : 'invalid'
|
||||
}
|
||||
|
||||
function extractFrontmatterBody(content: string | null): string | null {
|
||||
if (!content) return null
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function parseListItem(line: string): string | null {
|
||||
const match = line.match(/^ {2}- (.*)$/)
|
||||
return match ? unquote(match[1]) : null
|
||||
}
|
||||
|
||||
function parseKeyValueLine(line: string): { key: string, value: string } | null {
|
||||
const match = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
key: match[1].trim(),
|
||||
value: match[2].trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(value: string): FrontmatterValue | undefined {
|
||||
if (isBlockScalar(value)) return undefined
|
||||
if (isInlineArrayLiteral(value)) return parseInlineArray(value)
|
||||
return parseScalar(value)
|
||||
}
|
||||
|
||||
function flushList(
|
||||
result: ParsedFrontmatter,
|
||||
currentKey: string | null,
|
||||
currentList: string[],
|
||||
): string[] {
|
||||
if (currentKey && currentList.length > 0) {
|
||||
result[currentKey] = collapseList(currentList)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Parse YAML frontmatter from content */
|
||||
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
||||
if (!content) return {}
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
if (!match) return {}
|
||||
const frontmatterBody = extractFrontmatterBody(content)
|
||||
if (frontmatterBody === null) return {}
|
||||
|
||||
const result: ParsedFrontmatter = {}
|
||||
let currentKey: string | null = null
|
||||
let currentList: string[] = []
|
||||
let inList = false
|
||||
|
||||
for (const line of match[1].split('\n')) {
|
||||
const listMatch = line.match(/^ {2}- (.*)$/)
|
||||
if (listMatch && currentKey) {
|
||||
inList = true
|
||||
currentList.push(unquote(listMatch[1]))
|
||||
for (const line of frontmatterBody.split('\n')) {
|
||||
const listItem = parseListItem(line)
|
||||
if (listItem !== null && currentKey) {
|
||||
currentList.push(listItem)
|
||||
continue
|
||||
}
|
||||
|
||||
if (inList && currentKey) {
|
||||
result[currentKey] = collapseList(currentList)
|
||||
currentList = []
|
||||
inList = false
|
||||
currentList = flushList(result, currentKey, currentList)
|
||||
|
||||
const keyValue = parseKeyValueLine(line)
|
||||
if (!keyValue) continue
|
||||
currentKey = keyValue.key
|
||||
|
||||
const parsedValue = parseFrontmatterValue(keyValue.value)
|
||||
if (parsedValue !== undefined) {
|
||||
result[currentKey] = parsedValue
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
|
||||
if (!kvMatch) continue
|
||||
currentKey = kvMatch[1].trim()
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
if (isBlockScalar(value)) continue
|
||||
if (value.startsWith('[') && value.endsWith(']')) { result[currentKey] = parseInlineArray(value); continue }
|
||||
result[currentKey] = parseScalar(value)
|
||||
}
|
||||
|
||||
if (inList && currentKey) result[currentKey] = collapseList(currentList)
|
||||
flushList(result, currentKey, currentList)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ export function resolveInverseRelationshipLabel(
|
||||
}
|
||||
|
||||
export function orderInverseRelationshipLabels(labels: Iterable<string>): string[] {
|
||||
const customLabels = [...labels]
|
||||
const presentLabels = [...labels]
|
||||
const preferredLabels = PREFERRED_INVERSE_RELATIONSHIP_LABELS.filter((label) => presentLabels.includes(label))
|
||||
const customLabels = presentLabels
|
||||
.filter((label) => !PREFERRED_INVERSE_RELATIONSHIP_LABELS.includes(label as typeof PREFERRED_INVERSE_RELATIONSHIP_LABELS[number]))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
|
||||
return [...PREFERRED_INVERSE_RELATIONSHIP_LABELS, ...customLabels]
|
||||
return [...preferredLabels, ...customLabels]
|
||||
}
|
||||
|
||||
34
src/utils/noteListSearchEvents.ts
Normal file
34
src/utils/noteListSearchEvents.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const NOTE_LIST_SEARCH_AVAILABILITY_EVENT = 'laputa:note-list-search-availability'
|
||||
export const NOTE_LIST_SEARCH_TOGGLE_EVENT = 'laputa:toggle-note-list-search'
|
||||
|
||||
interface NoteListSearchAvailabilityDetail {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function isAvailabilityDetail(detail: unknown): detail is NoteListSearchAvailabilityDetail {
|
||||
return typeof detail === 'object'
|
||||
&& detail !== null
|
||||
&& 'enabled' in detail
|
||||
&& typeof (detail as { enabled?: unknown }).enabled === 'boolean'
|
||||
}
|
||||
|
||||
export function dispatchNoteListSearchAvailability(enabled: boolean) {
|
||||
window.dispatchEvent(new CustomEvent<NoteListSearchAvailabilityDetail>(
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
{ detail: { enabled } },
|
||||
))
|
||||
}
|
||||
|
||||
export function readNoteListSearchAvailability(event: Event): boolean | null {
|
||||
if (!(event instanceof CustomEvent) || !isAvailabilityDetail(event.detail)) return null
|
||||
return event.detail.enabled
|
||||
}
|
||||
|
||||
export function dispatchNoteListSearchToggle() {
|
||||
window.dispatchEvent(new Event(NOTE_LIST_SEARCH_TOGGLE_EVENT))
|
||||
}
|
||||
|
||||
export function addNoteListSearchToggleListener(listener: () => void): () => void {
|
||||
window.addEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener)
|
||||
}
|
||||
@@ -60,6 +60,35 @@ describe('buildReleaseHistoryPage', () => {
|
||||
expect(html).toContain('<p>First paragraph<br>with a line break.</p><p>Second paragraph</p>')
|
||||
})
|
||||
|
||||
it('sorts releases within each channel by published date descending even when the payload order is wrong', () => {
|
||||
const html = buildReleaseHistoryPage([
|
||||
{
|
||||
body: 'Older alpha release',
|
||||
name: 'Tolaria Alpha 2026.4.20.9',
|
||||
prerelease: true,
|
||||
published_at: '2026-04-20T09:44:02Z',
|
||||
tag_name: 'alpha-v2026.4.20-alpha.9',
|
||||
},
|
||||
{
|
||||
body: 'Newest alpha release',
|
||||
name: 'Tolaria Alpha 2026.4.20.12',
|
||||
prerelease: true,
|
||||
published_at: '2026-04-20T16:53:41Z',
|
||||
tag_name: 'alpha-v2026.4.20-alpha.12',
|
||||
},
|
||||
{
|
||||
body: 'Middle alpha release',
|
||||
name: 'Tolaria Alpha 2026.4.20.10',
|
||||
prerelease: true,
|
||||
published_at: '2026-04-20T10:32:01Z',
|
||||
tag_name: 'alpha-v2026.4.20-alpha.10',
|
||||
},
|
||||
])
|
||||
|
||||
expect(html.indexOf('Tolaria Alpha 2026.4.20.12')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.10'))
|
||||
expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9'))
|
||||
})
|
||||
|
||||
it('filters draft releases and shows an empty state for channels without published builds', () => {
|
||||
const html = buildReleaseHistoryPage([
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ type ReleaseEntry = {
|
||||
githubUrl: string | null
|
||||
notesHtml: string
|
||||
publishedLabel: string
|
||||
publishedTimestamp: number
|
||||
tagName: string
|
||||
title: string
|
||||
}
|
||||
@@ -419,6 +420,15 @@ function formatPublishedLabel(value: unknown): string {
|
||||
})
|
||||
}
|
||||
|
||||
function parsePublishedTimestamp(value: unknown): number {
|
||||
const text = normalizeText(value)
|
||||
if (text === null) return Number.NEGATIVE_INFINITY
|
||||
|
||||
const publishedAt = new Date(text)
|
||||
const timestamp = publishedAt.getTime()
|
||||
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp
|
||||
}
|
||||
|
||||
function isDownloadableAsset(name: string): boolean {
|
||||
return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip')
|
||||
}
|
||||
@@ -471,6 +481,7 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel,
|
||||
githubUrl: normalizeUrl(release.html_url),
|
||||
notesHtml: resolveReleaseNotesHtml(release.body_html, release.body),
|
||||
publishedLabel: formatPublishedLabel(release.published_at),
|
||||
publishedTimestamp: parsePublishedTimestamp(release.published_at),
|
||||
tagName,
|
||||
title,
|
||||
}]
|
||||
@@ -490,6 +501,10 @@ function collectReleaseSections(payload: unknown): ReleaseSections {
|
||||
sections[channel].push(release)
|
||||
}
|
||||
|
||||
for (const channel of ['stable', 'alpha'] as const) {
|
||||
sections[channel].sort((left, right) => right.publishedTimestamp - left.publishedTimestamp)
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SectionGroup } from '../components/SidebarParts'
|
||||
import { resolveIcon } from './iconRegistry'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import { isLegacyJournalingType } from './legacyTypes'
|
||||
import { canonicalizeTypeName } from './vaultTypes'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, StackSimple,
|
||||
@@ -29,17 +30,44 @@ const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type,
|
||||
|
||||
const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
|
||||
const isActive = (e: VaultEntry) => !e.archived
|
||||
const isSupportedSectionType = (type: string) => !isLegacyJournalingType(type)
|
||||
|
||||
function shouldCollectActiveType(entry: VaultEntry): boolean {
|
||||
if (!isActive(entry) || !isMarkdown(entry)) return false
|
||||
if (!entry.isA) return false
|
||||
return isSupportedSectionType(entry.isA)
|
||||
return Boolean(entry.isA)
|
||||
}
|
||||
|
||||
function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
|
||||
if (name !== entry.title || !isActive(entry)) return false
|
||||
return isSupportedSectionType(name)
|
||||
return name === entry.title && isActive(entry)
|
||||
}
|
||||
|
||||
function resolveTypeEntry(type: string, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
|
||||
return typeEntryMap[type] ?? typeEntryMap[type.toLowerCase()]
|
||||
}
|
||||
|
||||
function hasExplicitTypeDefinition(type: string, typeEntryMap: Record<string, VaultEntry>): boolean {
|
||||
const typeEntry = resolveTypeEntry(type, typeEntryMap)
|
||||
return Boolean(typeEntry && typeEntry.title.trim().toLowerCase() === type.trim().toLowerCase() && isActive(typeEntry))
|
||||
}
|
||||
|
||||
function shouldIncludeSectionType(type: string, typeEntryMap: Record<string, VaultEntry>): boolean {
|
||||
if (!isLegacyJournalingType(type)) return true
|
||||
return hasExplicitTypeDefinition(type, typeEntryMap)
|
||||
}
|
||||
|
||||
function canonicalizeSectionType(type: string, typeEntryMap: Record<string, VaultEntry>): string | null {
|
||||
const trimmedType = type.trim()
|
||||
if (!trimmedType) return null
|
||||
return resolveTypeEntry(trimmedType, typeEntryMap)?.title ?? canonicalizeTypeName(trimmedType)
|
||||
}
|
||||
|
||||
function addSectionType(typeMap: Map<string, string>, rawType: string, typeEntryMap: Record<string, VaultEntry>): void {
|
||||
const canonicalType = canonicalizeSectionType(rawType, typeEntryMap)
|
||||
if (!canonicalType) return
|
||||
|
||||
const typeKey = canonicalType.toLowerCase()
|
||||
if (!typeMap.has(typeKey)) {
|
||||
typeMap.set(typeKey, canonicalType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect unique explicit isA values from active (non-archived) markdown entries. */
|
||||
@@ -71,12 +99,17 @@ export function buildSectionGroup(type: string, typeEntryMap: Record<string, Vau
|
||||
|
||||
/** Build sections dynamically from vault entries and defined types — types with 0 notes still appear */
|
||||
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
const activeTypes = new Map<string, string>()
|
||||
for (const type of collectActiveTypes(entries)) {
|
||||
addSectionType(activeTypes, type, typeEntryMap)
|
||||
}
|
||||
for (const [name, entry] of Object.entries(typeEntryMap)) {
|
||||
if (!shouldIncludeTypeDefinition(name, entry)) continue
|
||||
activeTypes.add(name)
|
||||
addSectionType(activeTypes, name, typeEntryMap)
|
||||
}
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
return Array.from(activeTypes.values())
|
||||
.filter((type) => shouldIncludeSectionType(type, typeEntryMap))
|
||||
.map((type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
|
||||
@@ -45,9 +45,16 @@ function collectHiddenTypeKeys(entries: VaultEntry[]): Set<string> {
|
||||
return hiddenTypeKeys
|
||||
}
|
||||
|
||||
function shouldIncludeCommandPaletteType(type: string, hiddenTypeKeys: Set<string>): boolean {
|
||||
function hasExplicitTypeDefinition(entries: VaultEntry[], type: string): boolean {
|
||||
const typeKey = type.trim().toLowerCase()
|
||||
return entries.some((entry) => entry.isA === 'Type' && !entry.archived && entry.title.trim().toLowerCase() === typeKey)
|
||||
}
|
||||
|
||||
function shouldIncludeCommandPaletteType(type: string, hiddenTypeKeys: Set<string>, entries: VaultEntry[]): boolean {
|
||||
const typeKey = type.toLowerCase()
|
||||
return !hiddenTypeKeys.has(typeKey) && !isLegacyJournalingType(type)
|
||||
if (hiddenTypeKeys.has(typeKey)) return false
|
||||
if (!isLegacyJournalingType(type)) return true
|
||||
return hasExplicitTypeDefinition(entries, type)
|
||||
}
|
||||
|
||||
export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
@@ -60,5 +67,5 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
const hiddenTypeKeys = collectHiddenTypeKeys(entries)
|
||||
const sourceTypes = typeMap.size === 0 ? DEFAULT_TYPES : Array.from(typeMap.values()).sort()
|
||||
|
||||
return sourceTypes.filter((type) => shouldIncludeCommandPaletteType(type, hiddenTypeKeys))
|
||||
return sourceTypes.filter((type) => shouldIncludeCommandPaletteType(type, hiddenTypeKeys, entries))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function seedLegacyJournalVault(vaultPath: string): void {
|
||||
function seedExplicitJournalTypeVault(vaultPath: string): void {
|
||||
fs.writeFileSync(path.join(vaultPath, 'journal.md'), `---
|
||||
type: Type
|
||||
order: 12
|
||||
@@ -30,25 +30,25 @@ type: Journal
|
||||
`)
|
||||
}
|
||||
|
||||
test.describe('command palette hides the legacy Journal type', () => {
|
||||
test.describe('explicit Journal type stays visible across navigation surfaces', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
seedLegacyJournalVault(tempVaultDir)
|
||||
seedExplicitJournalTypeVault(tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('legacy Journal does not appear in the sidebar or command palette', async ({ page }) => {
|
||||
test('explicit Journal type appears in the sidebar and command palette', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
|
||||
await expect(page.locator('nav').getByText('Journals', { exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('nav').getByText('Journals', { exact: true })).toBeVisible()
|
||||
|
||||
await openCommandPalette(page)
|
||||
await page.locator('input[placeholder="Type a command..."]').fill('journal')
|
||||
|
||||
await expect(page.getByText('No matching commands', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('div.mx-1.flex.cursor-pointer')).toHaveCount(0)
|
||||
await expect(page.getByText('List Journals', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('New Journal', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
41
tests/smoke/note-list-search-keyboard-toggle.spec.ts
Normal file
41
tests/smoke/note-list-search-keyboard-toggle.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
|
||||
const FIND_SHORTCUT = process.platform === 'darwin' ? 'Meta+F' : 'Control+F'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.describe('Note-list search keyboard toggle', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('Cmd+F toggles note-list search and debounces filtering @smoke', async ({ page }) => {
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await noteList.focus()
|
||||
await page.keyboard.press(FIND_SHORTCUT)
|
||||
|
||||
const searchInput = page.getByPlaceholder('Search notes...')
|
||||
await expect(searchInput).toBeFocused()
|
||||
|
||||
await page.keyboard.type('Team')
|
||||
await expect(page.getByTestId('note-list-search-loading')).toBeVisible()
|
||||
await expect(noteList.getByText('Team Meeting', { exact: true })).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.keyboard.press(FIND_SHORTCUT)
|
||||
await expect(searchInput).toHaveCount(0)
|
||||
await expect(noteList).toBeFocused()
|
||||
})
|
||||
})
|
||||
@@ -31,11 +31,14 @@ async function searchAndOpenByKeyboard(
|
||||
expectedTitle: string,
|
||||
expectedFilenameStem: string,
|
||||
) {
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
const searchInput = page.getByPlaceholder('Search notes...')
|
||||
await searchInput.focus()
|
||||
await page.keyboard.press(SELECT_ALL_SHORTCUT)
|
||||
await page.keyboard.type(query)
|
||||
await expect(page.getByTestId('note-list-container').getByText(expectedTitle, { exact: true })).toBeVisible()
|
||||
await expect(page.getByTestId('note-list-search-loading')).toBeVisible()
|
||||
await expect(page.getByTestId('note-list-search-loading')).toHaveCount(0)
|
||||
await expect(noteList.getByText(expectedTitle, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
117
tests/smoke/raw-editor-frontmatter-propagation.spec.ts
Normal file
117
tests/smoke/raw-editor-frontmatter-propagation.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
|
||||
const PROJECT_NOTE_PATH = path.join('project', 'alpha-project.md')
|
||||
const PROJECT_NOTE_CONTENT = `---
|
||||
Is A: Project
|
||||
Status: Done
|
||||
Owner:
|
||||
- "[[Note B]]"
|
||||
Related to:
|
||||
- "[[Note B]]"
|
||||
- "[[Note C]]"
|
||||
---
|
||||
|
||||
# Alpha Project
|
||||
|
||||
This is a test project that references other notes.
|
||||
|
||||
## Notes
|
||||
|
||||
See [[Note B]] for details and [[Note C]] for additional context.
|
||||
`
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function openAlphaProject(page: Page, notePath: string): Promise<void> {
|
||||
const row = page.locator(`[data-note-path="${notePath}"]`)
|
||||
await expect(row).toBeVisible({ timeout: 5_000 })
|
||||
await row.click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function showStatusChipInInbox(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:open-note-list-properties', {
|
||||
detail: { scope: 'inbox' },
|
||||
}))
|
||||
})
|
||||
await expect(page.getByTestId('list-properties-popover')).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole('checkbox', { name: 'status' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
async function openRawEditor(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function replaceRawEditorContent(page: Page, nextContent: string): Promise<void> {
|
||||
await page.evaluate((content) => {
|
||||
const host = document.querySelector('.cm-content')
|
||||
if (!host) {
|
||||
throw new Error('CodeMirror content element is missing')
|
||||
}
|
||||
|
||||
type CodeMirrorHost = Element & {
|
||||
cmTile?: {
|
||||
view?: {
|
||||
state: { doc: { length: number } }
|
||||
dispatch(transaction: { changes: { from: number; to: number; insert: string } }): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const view = (host as CodeMirrorHost).cmTile?.view
|
||||
if (!view) {
|
||||
throw new Error('CodeMirror view is missing')
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: content,
|
||||
},
|
||||
})
|
||||
}, nextContent)
|
||||
}
|
||||
|
||||
test.describe('Raw editor frontmatter propagation', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('raw frontmatter edits immediately update inspector relationships and note-list chips @smoke', async ({ page }) => {
|
||||
const notePath = path.join(tempVaultDir, PROJECT_NOTE_PATH)
|
||||
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await showStatusChipInInbox(page)
|
||||
|
||||
const noteRow = page.locator(`[data-note-path="${notePath}"]`)
|
||||
await expect(noteRow.getByTestId('property-chip-status-0')).toHaveText('• Active')
|
||||
|
||||
await openAlphaProject(page, notePath)
|
||||
await openRawEditor(page)
|
||||
await replaceRawEditorContent(page, PROJECT_NOTE_CONTENT)
|
||||
await page.waitForTimeout(800)
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="editable-property"]').filter({ hasText: 'Owner' }),
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByTestId('relationships-panel-grid').getByText('Owner', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(noteRow.getByTestId('property-chip-status-0')).toHaveText('• Done')
|
||||
})
|
||||
})
|
||||
@@ -526,7 +526,7 @@ export default defineConfig({
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
// Keep coverage temp files off the mounted workspace to avoid flaky
|
||||
// read-after-write races when Vitest re-reads its own coverage shards.
|
||||
reportsDirectory: vitestCoverageDirectory,
|
||||
|
||||
Reference in New Issue
Block a user