Compare commits

...

19 Commits

Author SHA1 Message Date
lucaronin
35b035643e fix: adapt save reload callback 2026-04-21 15:09:27 +02:00
lucaronin
d3d663af0b fix: reload vault entries on tauri startup 2026-04-21 15:02:42 +02:00
lucaronin
8f624350d8 test: cover journal vault scan 2026-04-21 12:37:46 +02:00
lucaronin
0e54173834 test: cover explicit journal visibility 2026-04-21 12:27:06 +02:00
lucaronin
a6f1524a78 ci: reject placeholder telemetry secrets 2026-04-21 11:06:16 +02:00
lucaronin
4cd5baeb55 fix: sync sidebar types with live vault state 2026-04-21 10:45:39 +02:00
lucaronin
fb1265e088 test: stabilize note list search timing 2026-04-21 10:14:18 +02:00
lucaronin
ca89662231 fix: sync raw editor frontmatter state 2026-04-21 10:06:54 +02:00
lucaronin
97f285e3cb ci: upload coverage to codecov 2026-04-21 10:06:54 +02:00
Luca Rossi
4bd4ef43dc Added release badge in README 2026-04-21 09:43:40 +02:00
Luca Rossi
be29880a3e Added walkthroughs to the README 2026-04-21 09:22:51 +02:00
lucaronin
7be5519f61 fix: sort alpha releases chronologically 2026-04-21 09:10:16 +02:00
lucaronin
8cefbe949a fix: refine derived inverse relationship labels 2026-04-21 09:08:25 +02:00
lucaronin
dd0a55b8e8 docs: add ADR for external vault refresh after pulls and agent edits (guard — from commit 0020ade6) 2026-04-21 08:03:02 +02:00
lucaronin
610276b1bc docs: ratchet codescene thresholds 2026-04-21 00:56:59 +02:00
lucaronin
e6c968e37e fix: satisfy note list keyboard build 2026-04-21 00:50:44 +02:00
lucaronin
367a66f32a fix: debounce note list search 2026-04-21 00:47:45 +02:00
lucaronin
6a4046915c fix: harden startup telemetry urls 2026-04-20 23:38:41 +02:00
lucaronin
ec8b637c84 fix: hide uncloned getting started vault 2026-04-20 23:04:34 +02:00
53 changed files with 2202 additions and 458 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.89
AVERAGE_THRESHOLD=9.75
HOTSPOT_THRESHOLD=9.9
AVERAGE_THRESHOLD=9.77

View File

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

View File

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

View File

@@ -136,9 +136,22 @@ jobs:
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
@@ -150,9 +163,28 @@ jobs:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
@@ -167,13 +199,13 @@ jobs:
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if not value:
errors.append(f"{name} must be set for release builds")
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL")
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if not values["VITE_POSTHOG_KEY"]:
errors.append("VITE_POSTHOG_KEY must be set for release builds")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)

View File

@@ -42,6 +42,21 @@ jobs:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$")
def parse_alpha_tag(tag: str) -> tuple[str, int] | None:
match = alpha_pattern.fullmatch(tag)
if not match:
return None
calendar_version, sequence = match.groups()
return calendar_version, int(sequence)
def alpha_version(calendar_version: str, sequence: int) -> str:
return f"{calendar_version}-alpha.{sequence}"
def alpha_tag(calendar_version: str, sequence: int) -> str:
return f"alpha-v{calendar_version}-alpha.{sequence:04d}"
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
@@ -49,7 +64,8 @@ jobs:
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
parsed = parse_alpha_tag(tag)
version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v")
else:
today = datetime.now(timezone.utc).date()
stable_date = None
@@ -71,8 +87,8 @@ jobs:
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
version = alpha_version(calendar_version, sequence)
tag = alpha_tag(calendar_version, sequence)
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
@@ -179,9 +195,22 @@ jobs:
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
@@ -193,9 +222,28 @@ jobs:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
@@ -210,13 +258,13 @@ jobs:
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if not value:
errors.append(f"{name} must be set for release builds")
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL")
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if not values["VITE_POSTHOG_KEY"]:
errors.append("VITE_POSTHOG_KEY must be set for release builds")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
@@ -275,7 +323,27 @@ jobs:
- name: Generate release notes
run: |
PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "")
PREV_TAG=$(python3 <<'PY'
import re
import subprocess
current_tag = '${{ needs.version.outputs.tag }}'
pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$')
output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip()
tags = [line for line in output.splitlines() if line and line != current_tag]
parsed_tags = []
for tag in tags:
match = pattern.fullmatch(tag)
if not match:
continue
year, month, day, sequence = map(int, match.groups())
parsed_tags.append(((year, month, day, sequence), tag))
print(max(parsed_tags)[1] if parsed_tags else '')
PY
)
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
else

View File

@@ -1,4 +1,4 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Build](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](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.

View File

@@ -510,6 +510,7 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
### Raw Editor Mode
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
## Styling
@@ -669,6 +670,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -199,7 +199,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
- Secondary windows are sized 800×700 with overlay title bar
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
@@ -782,6 +782,7 @@ Every push to `main` triggers `.github/workflows/release.yml`:
```
push to main
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
@@ -789,7 +790,7 @@ push to main
→ upload signed .app.tar.gz + .sig updater artifacts
→ release job:
→ generate alpha-latest.json
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
→ pages job:
→ build static HTML release history page
→ publish alpha/latest.json
@@ -819,7 +820,7 @@ push stable-vYYYY.M.D tag
### Versioning
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. The GitHub release tag zero-pads the sequence as `alpha-vYYYY.M.D-alpha.NNNN` so GitHub release ordering remains chronological.
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.

View File

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

View File

@@ -126,3 +126,4 @@ proposed → active → superseded
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | active |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent } 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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { getTypeColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { PROPERTY_PANEL_LABEL_CLASS_NAME } from '../propertyPanelLayout'
import { Separator } from '../ui/separator'
import { ActionTooltip } from '../ui/action-tooltip'
import { LinkButton } from './LinkButton'
export interface ReferencedByItem {
@@ -16,57 +20,65 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
onNavigate: (target: string) => void
}) {
const grouped = useMemo(() => {
const map = new Map<string, Map<string, VaultEntry>>()
const map = new Map<string, { entriesByPath: Map<string, VaultEntry>; inverseKeys: Set<string> }>()
for (const item of items) {
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
const entriesByPath = map.get(label) ?? new Map<string, VaultEntry>()
entriesByPath.set(item.entry.path, item.entry)
map.set(label, entriesByPath)
const group = map.get(label) ?? { entriesByPath: new Map<string, VaultEntry>(), inverseKeys: new Set<string>() }
group.entriesByPath.set(item.entry.path, item.entry)
group.inverseKeys.add(humanizePropertyKey(item.viaKey))
map.set(label, group)
}
return orderInverseRelationshipLabels(map.keys()).map((label) => [
label,
[...(map.get(label)?.values() ?? [])],
] as const)
return orderInverseRelationshipLabels(map.keys())
.map((label) => {
const group = map.get(label)
if (!group) return null
return {
entries: [...group.entriesByPath.values()],
inverseKeys: [...group.inverseKeys].sort((left, right) => left.localeCompare(right)),
label,
}
})
.filter((group): group is { entries: VaultEntry[]; inverseKeys: string[]; label: string } => group !== null && group.entries.length > 0)
}, [items])
if (items.length === 0) return null
if (grouped.length === 0) return null
return (
<div className="referenced-by-panel">
<div className="mb-2 flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
Derived relationships
</span>
<span className="text-[10px] text-muted-foreground/80">
Read-only groups sourced from other notes.
</span>
</div>
<div className="flex flex-col gap-2.5">
{grouped.map(([label, groupEntries]) => (
<div key={label}>
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
{label}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
noteIcon={e.icon}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
onClick={() => onNavigate(e.title)}
title={e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
<div className="referenced-by-panel flex flex-col gap-3">
<Separator data-testid="derived-relationships-separator" />
<div className="flex flex-col gap-3">
{grouped.map(({ label, entries: groupEntries, inverseKeys }) => {
const tooltip = `Derived inverse relationship, inverse of ${inverseKeys.join(' and ')}.`
return (
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
<ActionTooltip copy={{ label: tooltip }} side="top" align="start">
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} tabIndex={0} data-testid="derived-relationship-label">
{label}
</span>
</ActionTooltip>
<div className="min-w-0 flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
noteIcon={e.icon}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
onClick={() => onNavigate(e.title)}
title={e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)

View File

@@ -1,9 +1,12 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render as rtlRender, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DynamicRelationshipsPanel } from './RelationshipsPanel'
import { ReferencedByPanel, type ReferencedByItem } from './ReferencedByPanel'
import { TooltipProvider } from '../ui/tooltip'
import type { VaultEntry } from '../../types'
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -105,7 +108,7 @@ describe('relationship display labels', () => {
expect(relationshipRow).toHaveStyle({ gridColumn: '1 / -1' })
})
it('humanizes snake_case keys in the referenced-by panel', () => {
it('humanizes snake_case keys in the referenced-by panel', async () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
]
@@ -113,6 +116,8 @@ describe('relationship display labels', () => {
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
expect(screen.getByText('Children')).toBeInTheDocument()
fireEvent.focus(screen.getByText('Children'))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
})
@@ -133,4 +138,16 @@ describe('relationship display labels', () => {
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
})
it('does not show empty preferred inverse groups', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
]
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
})

View File

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

View File

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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,62 @@
import type { VaultEntry } from '../types'
import { parseFrontmatter } from '../utils/frontmatter'
import { frontmatterToEntryPatch } from './frontmatterOps'
function createRawEditorEntryState(): Partial<VaultEntry> {
return {
aliases: [],
archived: false,
belongsTo: [],
color: null,
favorite: false,
favoriteIndex: null,
icon: null,
isA: null,
listPropertiesDisplay: [],
order: null,
organized: false,
properties: {},
relatedTo: [],
relationships: {},
sidebarLabel: null,
sort: null,
status: null,
template: null,
view: null,
visible: null,
}
}
function mergeRelationships(target: Record<string, string[]>, source: Record<string, string[] | null> | null): void {
if (!source) return
for (const [key, value] of Object.entries(source)) {
if (Array.isArray(value) && value.length > 0) target[key] = value
}
}
function mergeProperties(
target: Record<string, string | number | boolean | null>,
source: Record<string, string | number | boolean | null> | null,
): void {
if (!source) return
for (const [key, value] of Object.entries(source)) {
if (value !== null) target[key] = value
}
}
export function deriveRawEditorEntryState(content: string): Partial<VaultEntry> {
const derived = createRawEditorEntryState()
const properties: Record<string, string | number | boolean | null> = {}
const relationships: Record<string, string[]> = {}
for (const [key, value] of Object.entries(parseFrontmatter(content))) {
const { patch, relationshipPatch, propertiesPatch } = frontmatterToEntryPatch('update', key, value)
Object.assign(derived, patch)
mergeRelationships(relationships, relationshipPatch)
mergeProperties(properties, propertiesPatch)
}
derived.properties = properties
derived.relationships = relationships
return derived
}

View File

@@ -376,14 +376,23 @@ describe('extractVaultTypes', () => {
expect(extractVaultTypes(entries)).toEqual(['Note', 'Project'])
})
it('omits the legacy Journal type from extracted command-palette types', () => {
it('omits the legacy Journal type when no Type document defines it', () => {
const entries = [
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
{ path: '/note.md', title: 'General Note', isA: 'Note' },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Note'])
})
it('includes Journal when a real Type document defines it', () => {
const entries = [
{ path: '/journal.md', title: 'Journal', isA: 'Type' },
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
{ path: '/note.md', title: 'General Note', isA: 'Note' },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Note'])
expect(extractVaultTypes(entries)).toEqual(['Journal', 'Note'])
})
it('omits hidden types from extracted command-palette types', () => {

View File

@@ -157,10 +157,10 @@ describe('useEditorSaveWithLinks', () => {
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
isA: 'Project',
status: 'Active',
})
}))
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
@@ -185,9 +185,9 @@ describe('useEditorSaveWithLinks', () => {
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
isA: 'Essay',
})
}))
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
@@ -196,15 +196,66 @@ describe('useEditorSaveWithLinks', () => {
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
isA: 'Note',
})
}))
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
})
it('syncs custom relationships and properties from raw frontmatter immediately', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', '---\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
properties: { Owner: '[[person/alice]]', custom: 'value' },
relationships: { Owner: ['[[person/alice]]'] },
}))
})
it('clears stale note-list and inspector metadata when raw frontmatter is removed', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\ncustom: value\n---\nBody')
})
updateEntry.mockClear()
act(() => {
result.current.handleContentChange('/note.md', 'Body without frontmatter')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', expect.objectContaining({
belongsTo: [],
properties: {},
relationships: {},
relatedTo: [],
status: null,
}))
})
it('keeps the last derived entry state while the raw frontmatter is temporarily incomplete', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\n---\nBody')
})
updateEntry.mockClear()
act(() => {
result.current.handleContentChange('/note.md', '---\nstatus: Active\nOwner: [[person/alice]]\nBody')
})
expect(updateEntry).not.toHaveBeenCalled()
})
it.each([
['/old-title.md', '# Renamed Note\n\nBody', { title: 'Renamed Note', hasH1: true }],
['/renamed-note.md', 'Body without a heading', { title: 'Renamed Note', hasH1: false }],

View File

@@ -1,10 +1,19 @@
import { startTransition, useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { contentToEntryPatch } from './frontmatterOps'
import { deriveRawEditorEntryState } from './rawEditorEntryState'
import { deriveDisplayTitleState } from '../utils/noteTitle'
import { detectFrontmatterState } from '../utils/frontmatter'
import type { VaultEntry } from '../types'
const EMPTY_DERIVED_ENTRY_STATE_KEY = JSON.stringify(deriveRawEditorEntryState(''))
function shouldSyncFrontmatterState(content: string): boolean {
const frontmatterState = detectFrontmatterState(content)
if (frontmatterState === 'invalid') return false
return !(frontmatterState === 'none' && content.startsWith('---\n'))
}
export function useEditorSaveWithLinks(config: {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
@@ -34,7 +43,7 @@ export function useEditorSaveWithLinks(config: {
})
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmKeyRef = useRef('')
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)
const prevTitleKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
@@ -44,19 +53,23 @@ export function useEditorSaveWithLinks(config: {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
const frontmatterPatch = contentToEntryPatch(content)
const frontmatterPatch = shouldSyncFrontmatterState(content)
? deriveRawEditorEntryState(content)
: null
const filename = path.split('/').pop() ?? path
const titlePatch = deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
frontmatterTitle: typeof frontmatterPatch?.title === 'string' ? frontmatterPatch.title : null,
})
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
if (frontmatterPatch) {
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
updateEntry(path, fmPatch)
}
}
const titleKey = JSON.stringify(titlePatch)
if (titleKey !== prevTitleKeyRef.current) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
@@ -8,13 +8,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,

View File

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

View File

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

View File

@@ -70,6 +70,12 @@ describe('parseFrontmatter', () => {
expect(fm['Archived']).toBe(false)
})
})
it('preserves single wikilinks as scalar strings instead of inline arrays', () => {
const fm = parseFrontmatter('---\nOwner: [[person/alice]]\nBelongs to: [[project/alpha]]\n---\nBody')
expect(fm['Owner']).toBe('[[person/alice]]')
expect(fm['Belongs to']).toBe('[[project/alpha]]')
})
})
describe('detectFrontmatterState', () => {

View File

@@ -16,6 +16,10 @@ function isBlockScalar(value: string): boolean {
return value === '' || value === '|' || value === '>'
}
function isInlineArrayLiteral(value: string): boolean {
return value.startsWith('[') && value.endsWith(']') && !value.startsWith('[[')
}
function parseInlineArray(value: string): FrontmatterValue {
const items = value.slice(1, -1).split(',').map(s => unquote(s.trim()))
return collapseList(items)
@@ -44,41 +48,71 @@ export function detectFrontmatterState(content: string | null): FrontmatterState
return hasValidLine ? 'valid' : 'invalid'
}
function extractFrontmatterBody(content: string | null): string | null {
if (!content) return null
const match = content.match(/^---\n([\s\S]*?)\n---/)
return match ? match[1] : null
}
function parseListItem(line: string): string | null {
const match = line.match(/^ {2}- (.*)$/)
return match ? unquote(match[1]) : null
}
function parseKeyValueLine(line: string): { key: string, value: string } | null {
const match = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
if (!match) return null
return {
key: match[1].trim(),
value: match[2].trim(),
}
}
function parseFrontmatterValue(value: string): FrontmatterValue | undefined {
if (isBlockScalar(value)) return undefined
if (isInlineArrayLiteral(value)) return parseInlineArray(value)
return parseScalar(value)
}
function flushList(
result: ParsedFrontmatter,
currentKey: string | null,
currentList: string[],
): string[] {
if (currentKey && currentList.length > 0) {
result[currentKey] = collapseList(currentList)
}
return []
}
/** Parse YAML frontmatter from content */
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
if (!content) return {}
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return {}
const frontmatterBody = extractFrontmatterBody(content)
if (frontmatterBody === null) return {}
const result: ParsedFrontmatter = {}
let currentKey: string | null = null
let currentList: string[] = []
let inList = false
for (const line of match[1].split('\n')) {
const listMatch = line.match(/^ {2}- (.*)$/)
if (listMatch && currentKey) {
inList = true
currentList.push(unquote(listMatch[1]))
for (const line of frontmatterBody.split('\n')) {
const listItem = parseListItem(line)
if (listItem !== null && currentKey) {
currentList.push(listItem)
continue
}
if (inList && currentKey) {
result[currentKey] = collapseList(currentList)
currentList = []
inList = false
currentList = flushList(result, currentKey, currentList)
const keyValue = parseKeyValueLine(line)
if (!keyValue) continue
currentKey = keyValue.key
const parsedValue = parseFrontmatterValue(keyValue.value)
if (parsedValue !== undefined) {
result[currentKey] = parsedValue
}
const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
if (!kvMatch) continue
currentKey = kvMatch[1].trim()
const value = kvMatch[2].trim()
if (isBlockScalar(value)) continue
if (value.startsWith('[') && value.endsWith(']')) { result[currentKey] = parseInlineArray(value); continue }
result[currentKey] = parseScalar(value)
}
if (inList && currentKey) result[currentKey] = collapseList(currentList)
flushList(result, currentKey, currentList)
return result
}

View File

@@ -25,9 +25,11 @@ export function resolveInverseRelationshipLabel(
}
export function orderInverseRelationshipLabels(labels: Iterable<string>): string[] {
const customLabels = [...labels]
const presentLabels = [...labels]
const preferredLabels = PREFERRED_INVERSE_RELATIONSHIP_LABELS.filter((label) => presentLabels.includes(label))
const customLabels = presentLabels
.filter((label) => !PREFERRED_INVERSE_RELATIONSHIP_LABELS.includes(label as typeof PREFERRED_INVERSE_RELATIONSHIP_LABELS[number]))
.sort((left, right) => left.localeCompare(right))
return [...PREFERRED_INVERSE_RELATIONSHIP_LABELS, ...customLabels]
return [...preferredLabels, ...customLabels]
}

View File

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

View File

@@ -60,6 +60,35 @@ describe('buildReleaseHistoryPage', () => {
expect(html).toContain('<p>First paragraph<br>with a line break.</p><p>Second paragraph</p>')
})
it('sorts releases within each channel by published date descending even when the payload order is wrong', () => {
const html = buildReleaseHistoryPage([
{
body: 'Older alpha release',
name: 'Tolaria Alpha 2026.4.20.9',
prerelease: true,
published_at: '2026-04-20T09:44:02Z',
tag_name: 'alpha-v2026.4.20-alpha.9',
},
{
body: 'Newest alpha release',
name: 'Tolaria Alpha 2026.4.20.12',
prerelease: true,
published_at: '2026-04-20T16:53:41Z',
tag_name: 'alpha-v2026.4.20-alpha.12',
},
{
body: 'Middle alpha release',
name: 'Tolaria Alpha 2026.4.20.10',
prerelease: true,
published_at: '2026-04-20T10:32:01Z',
tag_name: 'alpha-v2026.4.20-alpha.10',
},
])
expect(html.indexOf('Tolaria Alpha 2026.4.20.12')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.10'))
expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9'))
})
it('filters draft releases and shows an empty state for channels without published builds', () => {
const html = buildReleaseHistoryPage([
{

View File

@@ -27,6 +27,7 @@ type ReleaseEntry = {
githubUrl: string | null
notesHtml: string
publishedLabel: string
publishedTimestamp: number
tagName: string
title: string
}
@@ -419,6 +420,15 @@ function formatPublishedLabel(value: unknown): string {
})
}
function parsePublishedTimestamp(value: unknown): number {
const text = normalizeText(value)
if (text === null) return Number.NEGATIVE_INFINITY
const publishedAt = new Date(text)
const timestamp = publishedAt.getTime()
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp
}
function isDownloadableAsset(name: string): boolean {
return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip')
}
@@ -471,6 +481,7 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel,
githubUrl: normalizeUrl(release.html_url),
notesHtml: resolveReleaseNotesHtml(release.body_html, release.body),
publishedLabel: formatPublishedLabel(release.published_at),
publishedTimestamp: parsePublishedTimestamp(release.published_at),
tagName,
title,
}]
@@ -490,6 +501,10 @@ function collectReleaseSections(payload: unknown): ReleaseSections {
sections[channel].push(release)
}
for (const channel of ['stable', 'alpha'] as const) {
sections[channel].sort((left, right) => right.publishedTimestamp - left.publishedTimestamp)
}
return sections
}

View File

@@ -8,6 +8,7 @@ import type { SectionGroup } from '../components/SidebarParts'
import { resolveIcon } from './iconRegistry'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { isLegacyJournalingType } from './legacyTypes'
import { canonicalizeTypeName } from './vaultTypes'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, StackSimple,
@@ -29,17 +30,44 @@ const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type,
const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
const isActive = (e: VaultEntry) => !e.archived
const isSupportedSectionType = (type: string) => !isLegacyJournalingType(type)
function shouldCollectActiveType(entry: VaultEntry): boolean {
if (!isActive(entry) || !isMarkdown(entry)) return false
if (!entry.isA) return false
return isSupportedSectionType(entry.isA)
return Boolean(entry.isA)
}
function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
if (name !== entry.title || !isActive(entry)) return false
return isSupportedSectionType(name)
return name === entry.title && isActive(entry)
}
function resolveTypeEntry(type: string, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
return typeEntryMap[type] ?? typeEntryMap[type.toLowerCase()]
}
function hasExplicitTypeDefinition(type: string, typeEntryMap: Record<string, VaultEntry>): boolean {
const typeEntry = resolveTypeEntry(type, typeEntryMap)
return Boolean(typeEntry && typeEntry.title.trim().toLowerCase() === type.trim().toLowerCase() && isActive(typeEntry))
}
function shouldIncludeSectionType(type: string, typeEntryMap: Record<string, VaultEntry>): boolean {
if (!isLegacyJournalingType(type)) return true
return hasExplicitTypeDefinition(type, typeEntryMap)
}
function canonicalizeSectionType(type: string, typeEntryMap: Record<string, VaultEntry>): string | null {
const trimmedType = type.trim()
if (!trimmedType) return null
return resolveTypeEntry(trimmedType, typeEntryMap)?.title ?? canonicalizeTypeName(trimmedType)
}
function addSectionType(typeMap: Map<string, string>, rawType: string, typeEntryMap: Record<string, VaultEntry>): void {
const canonicalType = canonicalizeSectionType(rawType, typeEntryMap)
if (!canonicalType) return
const typeKey = canonicalType.toLowerCase()
if (!typeMap.has(typeKey)) {
typeMap.set(typeKey, canonicalType)
}
}
/** Collect unique explicit isA values from active (non-archived) markdown entries. */
@@ -71,12 +99,17 @@ export function buildSectionGroup(type: string, typeEntryMap: Record<string, Vau
/** Build sections dynamically from vault entries and defined types — types with 0 notes still appear */
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
const activeTypes = new Map<string, string>()
for (const type of collectActiveTypes(entries)) {
addSectionType(activeTypes, type, typeEntryMap)
}
for (const [name, entry] of Object.entries(typeEntryMap)) {
if (!shouldIncludeTypeDefinition(name, entry)) continue
activeTypes.add(name)
addSectionType(activeTypes, name, typeEntryMap)
}
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
return Array.from(activeTypes.values())
.filter((type) => shouldIncludeSectionType(type, typeEntryMap))
.map((type) => buildSectionGroup(type, typeEntryMap))
}
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {

View File

@@ -45,9 +45,16 @@ function collectHiddenTypeKeys(entries: VaultEntry[]): Set<string> {
return hiddenTypeKeys
}
function shouldIncludeCommandPaletteType(type: string, hiddenTypeKeys: Set<string>): boolean {
function hasExplicitTypeDefinition(entries: VaultEntry[], type: string): boolean {
const typeKey = type.trim().toLowerCase()
return entries.some((entry) => entry.isA === 'Type' && !entry.archived && entry.title.trim().toLowerCase() === typeKey)
}
function shouldIncludeCommandPaletteType(type: string, hiddenTypeKeys: Set<string>, entries: VaultEntry[]): boolean {
const typeKey = type.toLowerCase()
return !hiddenTypeKeys.has(typeKey) && !isLegacyJournalingType(type)
if (hiddenTypeKeys.has(typeKey)) return false
if (!isLegacyJournalingType(type)) return true
return hasExplicitTypeDefinition(entries, type)
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
@@ -60,5 +67,5 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] {
const hiddenTypeKeys = collectHiddenTypeKeys(entries)
const sourceTypes = typeMap.size === 0 ? DEFAULT_TYPES : Array.from(typeMap.values()).sort()
return sourceTypes.filter((type) => shouldIncludeCommandPaletteType(type, hiddenTypeKeys))
return sourceTypes.filter((type) => shouldIncludeCommandPaletteType(type, hiddenTypeKeys, entries))
}

View File

@@ -10,7 +10,7 @@ import { openCommandPalette } from './helpers'
let tempVaultDir: string
function seedLegacyJournalVault(vaultPath: string): void {
function seedExplicitJournalTypeVault(vaultPath: string): void {
fs.writeFileSync(path.join(vaultPath, 'journal.md'), `---
type: Type
order: 12
@@ -30,25 +30,25 @@ type: Journal
`)
}
test.describe('command palette hides the legacy Journal type', () => {
test.describe('explicit Journal type stays visible across navigation surfaces', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
seedLegacyJournalVault(tempVaultDir)
seedExplicitJournalTypeVault(tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('legacy Journal does not appear in the sidebar or command palette', async ({ page }) => {
test('explicit Journal type appears in the sidebar and command palette', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await expect(page.locator('nav').getByText('Journals', { exact: true })).toHaveCount(0)
await expect(page.locator('nav').getByText('Journals', { exact: true })).toBeVisible()
await openCommandPalette(page)
await page.locator('input[placeholder="Type a command..."]').fill('journal')
await expect(page.getByText('No matching commands', { exact: true })).toBeVisible()
await expect(page.locator('div.mx-1.flex.cursor-pointer')).toHaveCount(0)
await expect(page.getByText('List Journals', { exact: true })).toBeVisible()
await expect(page.getByText('New Journal', { exact: true })).toBeVisible()
})
})

View File

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

View File

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

View File

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

View File

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