Compare commits

...

53 Commits

Author SHA1 Message Date
lucaronin
e22b4047ed refactor(test): improve app test harness health 2026-05-06 11:17:46 +02:00
lucaronin
691f5eec7c fix: expand editor whitespace selection hit area 2026-05-06 10:49:06 +02:00
lucaronin
7cf2a077ed test: cover editor flush when returning to switched note 2026-05-06 04:08:58 +02:00
lucaronin
b40c1051af feat(properties): speed up distant date edits 2026-05-05 20:44:27 +02:00
lucaronin
7a274d3a83 fix(editor): guard stale whiteboard block writes 2026-05-05 19:54:09 +02:00
lucaronin
634975e505 fix(vault): coalesce background file activity 2026-05-05 19:23:12 +02:00
lucaronin
88f5d04b8c fix(editor): preserve exact copied text 2026-05-05 18:18:56 +02:00
lucaronin
59408bbebd fix: raise CI Node heap for release builds 2026-05-05 17:36:29 +02:00
lucaronin
bc97ade133 fix(editor): support whitespace drag selection 2026-05-05 16:51:56 +02:00
lucaronin
eb6b58eb36 fix: refine breadcrumb overflow actions 2026-05-05 15:46:18 +02:00
lucaronin
520b2c9799 feat(settings): follow system theme 2026-05-05 15:00:24 +02:00
lucaronin
d5ecd567d0 fix(editor): guard stale wikilink click targets 2026-05-05 13:43:48 +02:00
lucaronin
f88b359bb5 fix(editor): render wikilinks inside tables 2026-05-05 13:02:56 +02:00
lucaronin
67ada08e05 fix(editor): ignore stale history diff responses 2026-05-05 12:29:46 +02:00
lucaronin
6ce04eead6 fix(ai): avoid Codex Windows batch shims 2026-05-05 11:45:56 +02:00
lucaronin
b8e3d8193c fix(editor): guard stale table handle state 2026-05-05 11:20:43 +02:00
lucaronin
9c09b5ad78 fix(editor): guard composing enter in list items 2026-05-05 10:15:59 +02:00
lucaronin
a6caa2b069 fix(linux): enable fcitx input for AppImage Wayland 2026-05-05 09:54:39 +02:00
lucaronin
0f231048cf fix(vault): retry transient access denied saves 2026-05-05 09:32:08 +02:00
lucaronin
8a3d00e8c6 docs: add/update ADR for media previews and vault refresh policy (guard — from commit d347cda, 92c0b89) 2026-05-05 09:32:08 +02:00
lucaronin
bfd175224a ci: split frontend and rust quality lanes 2026-05-05 04:06:28 +02:00
lucaronin
0df9c1d707 fix(types): normalize built-in notes type creation 2026-05-05 03:41:41 +02:00
lucaronin
ccfa148014 fix(nav): guard stale entity backlink metadata 2026-05-05 03:19:51 +02:00
lucaronin
dda4943425 test(editor): cover mermaid property refresh 2026-05-05 03:00:47 +02:00
lucaronin
be87c3864c fix(ai): remount composer after native input 2026-05-05 02:43:49 +02:00
lucaronin
982d6476d7 fix(editor): guard stale suggestion query cleanup 2026-05-05 02:19:49 +02:00
lucaronin
92c0b895dc fix(editor): preserve focus during vault refresh 2026-05-05 02:00:28 +02:00
lucaronin
75a8444b41 fix(window): preserve windows properties geometry 2026-05-05 01:43:13 +02:00
lucaronin
d347cda9e3 fix(preview): allow native media playback 2026-05-05 01:08:33 +02:00
lucaronin
f5adbf9cf7 feat(settings): add note display preferences 2026-05-04 22:07:27 +02:00
lucaronin
19059fbf81 fix(frontmatter): preserve crlf inbox organization 2026-05-04 20:35:01 +02:00
lucaronin
1694e3ad07 fix(editor): route clipboard images to attachments 2026-05-04 19:12:14 +02:00
lucaronin
e46613b82b fix: stabilize tldraw whiteboard assets 2026-05-04 18:34:07 +02:00
김덕환
38ea51627d fix(i18n): correct Korean locale mistranslations (#524)
- Inbox: '받은 편지함' → 'Inbox' (이메일 용어 오역. 다른 언어 전부 Inbox 유지)
- Note: '메모' → '노트' (앱 전체 용어 불일치 수정)
- Vault: '보관소/보관함' → 'Vault' (다른 언어 전부 Vault 유지)
- Views: '조회수' → '뷰' (YouTube 조회수 뉘앙스 제거)
- Guidance: '가이드ANCE' → '가이던스' (오타 수정)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-04 18:05:19 +02:00
lucaronin
e5fa40b826 docs: add release notes for v2026-05-04 2026-05-04 16:52:41 +02:00
lucaronin
ff4a78f964 feat: load readable release notes dynamically 2026-05-04 15:07:09 +02:00
lucaronin
9973d8d4e6 fix: bundle tldraw whiteboard assets 2026-05-04 14:57:09 +02:00
lucaronin
e7e33c479d fix: make AI target selection exclusive 2026-05-04 13:30:42 +02:00
lucaronin
69b9da6da6 feat: add readable stable release notes 2026-05-04 13:01:06 +02:00
lucaronin
71ef8325a0 feat: add lazy table of contents panel 2026-05-04 12:48:18 +02:00
lucaronin
582d1d25d6 fix: stabilize embedded diagrams 2026-05-04 11:28:53 +02:00
lucaronin
ebf4545d46 fix: show type-derived instance property placeholders 2026-05-04 10:55:04 +02:00
lucaronin
3c483ba0e6 feat(editor): add table of contents panel 2026-05-04 06:08:09 +02:00
lucaronin
71629763ee chore: ignore local codacy runtime 2026-05-04 05:25:43 +02:00
lucaronin
417e37f1d1 refactor(paths): share note path identity 2026-05-04 04:59:50 +02:00
lucaronin
a3e3192b66 refactor(editor): share durable block codecs 2026-05-04 04:09:14 +02:00
lucaronin
86c503c1f9 refactor(ai): share model provider catalog 2026-05-04 02:59:20 +02:00
lucaronin
21a47b4a77 fix(editor): avoid modern array copy in rich export 2026-05-04 02:07:32 +02:00
lucaronin
64d961bd98 fix(editor): type ime cancel listener 2026-05-04 01:18:28 +02:00
lucaronin
1b218f5250 fix(editor): keep toolbar hidden through ime settle 2026-05-04 01:02:07 +02:00
lucaronin
dfb9f98b7a fix(types): block same-path type collisions 2026-05-03 23:44:24 +02:00
lucaronin
6a033cd2db fix(status): keep git popup above panels 2026-05-03 20:25:50 +02:00
lucaronin
b872b6148c fix(menu): route windows menu actions 2026-05-03 20:00:37 +02:00
200 changed files with 10283 additions and 1743 deletions

View File

@@ -14,10 +14,12 @@ permissions:
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# Keep large production frontend builds below CI runner memory limits.
NODE_OPTIONS: --max-old-space-size=4096
jobs:
test:
name: Tests & Quality Checks
frontend-quality:
name: Frontend Tests & Quality Checks
runs-on: macos-15
steps:
@@ -36,28 +38,11 @@ jobs:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy, llvm-tools-preview
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Keep frontend and Rust quality gates in separate macOS jobs so the
# expensive Rust target cache restore no longer blocks the frontend lane.
# ── 0. Build check (catches type errors and bundler failures) ─────────
- name: TypeScript type check
run: pnpm exec tsc --noEmit
@@ -66,8 +51,7 @@ jobs:
run: pnpm build
# ── 1. Coverage-backed tests ──────────────────────────────────────────
# The coverage commands run the same frontend and Rust test suites, so keep
# them as the canonical test lane instead of running every suite twice.
# The coverage command runs the canonical frontend test suite.
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
@@ -76,25 +60,15 @@ jobs:
run: pnpm test:coverage
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
- name: Rust tests + coverage (≥85% lines)
run: |
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
- name: Upload frontend 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
files: ./coverage/lcov.info
flags: frontend
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
@@ -158,6 +132,56 @@ jobs:
- name: Lint frontend
run: pnpm lint
rust-quality:
name: Rust Tests & Quality Checks
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy, llvm-tools-preview
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Rust tests + coverage (≥85% lines)
run: |
mkdir -p coverage
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 Rust 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/rust.lcov
flags: rust
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
- name: Clippy (Rust)
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings

View File

@@ -4,10 +4,14 @@ on:
push:
tags:
- 'stable-v*'
- 'v20*'
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
concurrency:
group: release-stable-${{ github.ref }}
@@ -34,14 +38,24 @@ jobs:
from datetime import date
tag = os.environ["GITHUB_REF_NAME"]
version = tag.removeprefix("stable-v")
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
if not match:
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
legacy_match = re.fullmatch(r"stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})", tag)
date_match = re.fullmatch(r"v(\d{4})-(\d{2})-(\d{2})", tag)
if date_match:
year, month, day = map(int, date_match.groups())
date(year, month, day)
version = f"{year}.{month}.{day}"
display_version = tag
elif legacy_match:
year, month, day = map(int, legacy_match.groups())
date(year, month, day)
version = f"{year}.{month}.{day}"
display_version = version
else:
raise SystemExit(f"Stable tags must use vYYYY-MM-DD or stable-vYYYY.M.D, got {tag}")
date(*map(int, match.groups()))
print(f"version={version}")
print(f"display_version={version}")
print(f"display_version={display_version}")
print(f"tag={tag}")
PY
@@ -565,16 +579,23 @@ jobs:
- name: Generate release notes
run: |
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
NOTES_FILE="release-notes/${{ needs.version.outputs.tag }}.md"
if [ -f "$NOTES_FILE" ]; then
cat "$NOTES_FILE" > release_notes.md
else
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
PREV_TAG=$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags/v20* refs/tags/stable-v* | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
else
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
fi
{
echo "## What's Changed"
echo ""
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
} > release_notes.md
fi
{
echo "## What's Changed"
echo ""
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
echo ""
echo "---"
echo "**Stable release — manually promoted from \`main\`**"
@@ -582,7 +603,7 @@ jobs:
echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
} >> release_notes.md
- name: Build stable-latest.json
run: |
@@ -727,7 +748,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
mkdir -p _site/alpha _site/stable _site/release-notes
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"

View File

@@ -8,6 +8,9 @@ on:
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
concurrency:
group: release-alpha-${{ github.ref }}
@@ -69,10 +72,18 @@ jobs:
else:
today = datetime.now(timezone.utc).date()
stable_date = None
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
stable_patterns = (
re.compile(r"^v(\d{4})-(\d{2})-(\d{2})$"),
re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$"),
)
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
match = stable_pattern.fullmatch(stable_tag)
stable_tags = lines([
"git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)",
"refs/tags/v20*", "refs/tags/stable-v*",
])
for stable_tag in stable_tags:
match = next((pattern.fullmatch(stable_tag) for pattern in stable_patterns if pattern.fullmatch(stable_tag)), None)
if not match:
continue
@@ -778,7 +789,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
mkdir -p _site/alpha _site/stable _site/release-notes
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"

3
.gitignore vendored
View File

@@ -73,3 +73,6 @@ CODE-HEALTH-REPORT.md
.env
.env.local
.env.*.local
# Local Codacy CLI runtime/config generated by the MCP server
.codacy/

View File

@@ -160,16 +160,20 @@ interface VaultEntry {
|---|---|---|
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
| `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery. Background note prefetch is bounded to a small number of concurrent native reads, and a note opened while queued is promoted to foreground instead of waiting behind the prefetch backlog.
`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state.
### Table of Contents Outline
The editor Table of Contents is derived from the live BlockNote document, not from saved Markdown text. `src/utils/tableOfContents.ts` reads structural `heading` blocks with stable ids and levels, extracts inline text from nested BlockNote content, and nests headings by level while preserving document order. `TableOfContentsPanel` receives a document revision from `Editor`, so rich-editor edits refresh the outline immediately without waiting for autosave or a vault reload. Selecting a heading focuses BlockNote and moves the cursor to that block id, while nested headings can be collapsed independently in panel-local UI state.
### Entity Types (isA / type)
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
@@ -200,6 +204,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
- Define instance schema/defaults through ordinary custom frontmatter properties and relationship fields
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
- Serve as the "definition" for their type category
@@ -218,6 +223,8 @@ Each entity type can have a corresponding **type document**: any markdown note w
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
**UI behavior**:
- Clicking a section group header pins the type document at the top of the NoteList if it exists
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
@@ -278,8 +285,9 @@ Tolaria separates **display title** from the file identifier:
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
- **Path identity rules** (`src/utils/notePathIdentity.ts`, `vault/path_identity.rs`): note creation, tab selection, rename bookkeeping, pull refresh, git history, and vault cache updates normalize path separators and macOS `/private/tmp` aliases through one owner. Case folding is reserved for collision/deduplication checks; active-note identity remains case-sensitive.
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while transient access-denied writes are retried briefly before surfacing failure. The editor keeps the unsaved buffer intact for another attempt.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -336,7 +344,7 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
## Command Surface
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
@@ -477,7 +485,7 @@ interface PulseCommit {
### External Vault Refresh
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note only when the changed-path list includes that note, and closes the active tab if the file disappeared. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note only when the changed-path list includes that note, and closes the active tab if the file disappeared. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
@@ -546,21 +554,23 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
### Mermaid Diagrams
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
- `serializeDurableEditorBlocks()` wraps the math-aware serializer so math, wikilinks, Mermaid diagrams, and whiteboards share the same Markdown-first save path.
- The `/mermaid` slash command inserts a placeholder rectangle diagram using the same schema-backed Markdown storage path, avoiding an invalid empty diagram state.
### Tldraw Whiteboards
Defined in `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body.
- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note.
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block.
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.
### Formatting Surface Policy
@@ -568,9 +578,11 @@ Defined in `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`,
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- `SingleEditorView` owns a whitespace mouse-selection bridge around BlockNote and its rich-editor scroll area: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useEditorComposing` tracks `compositionstart` and `compositionend` events that target the BlockNote editor and closes the floating formatting toolbar while IME text is being composed, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
- `createImeCompositionKeyGuardExtension()` intercepts composing `Enter` keydown events before BlockNote's list shortcuts see them, so Korean/Japanese/Chinese IMEs can commit text at the start of list items without Tolaria splitting the current bullet. It stops editor shortcut propagation only; it does not prevent the browser/IME default composition action.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
@@ -585,16 +597,15 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
```mermaid
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessTldrawMarkdown(body)\ntldraw fence → token"]
C --> D["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
D --> E["preProcessWikilinks(body)\n[[target]]token"]
E --> F["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
F --> G["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
G --> H["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks + injectTldrawInBlocks\n tokens → schema nodes"]
H --> I["editor.replaceBlocks()\n→ rendered editor"]
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
C --> D["preProcessWikilinks(body)\n[[target]]token"]
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
F --> G["injectWikilinks + injectMathInBlocks + injectDurableEditorMarkdownBlocks\n tokens → schema nodes"]
G --> H["editor.replaceBlocks()\n→ rendered editor"]
style A fill:#f8f9fa,stroke:#6c757d,color:#000
style I fill:#d4edda,stroke:#28a745,color:#000
style H fill:#d4edda,stroke:#28a745,color:#000
```
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
@@ -604,7 +615,7 @@ flowchart LR
```mermaid
flowchart LR
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
B --> C["restoreWikilinks + serializeDurableEditorBlocks()\nschema nodes → Markdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]
@@ -649,12 +660,12 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes:
## Styling
The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). The previous vault-authored theming system remains removed; theme mode is an installation-local app preference.
The app uses internal light and dark themes owned by Tolaria, with System as an installation-local preference that follows the OS appearance (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). The previous vault-authored theming system remains removed.
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
4. **Theme mode commands**: Command-palette actions for light and dark mode call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
3. **Runtime theme bridge**: Resolves the selected preference to `light` / `dark`, applies `data-theme` and `.dark` for shadcn/ui, and subscribes to `prefers-color-scheme` while System is selected
4. **Theme mode commands**: Command-palette actions for Light, Dark, and System call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
## Localization
@@ -676,10 +687,11 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged. For typed instances, missing relationship fields declared on the type document render as gray editable placeholders without copying any default relationship targets into existing notes.
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
@@ -764,6 +776,7 @@ Tolaria delegates remote auth to the user's system git setup:
- `CloneVaultModal` captures a remote URL and local destination
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
- On Linux AppImage launches, every system-git command removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning `git`, so helpers like `git-remote-https` bind against the host git/library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices.
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
- No provider-specific token or username is stored in app settings
@@ -783,9 +796,10 @@ interface Settings {
analytics_enabled: boolean | null
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
theme_mode: 'light' | 'dark' | 'system' | null
ui_language: AppLocale | null
note_width_mode: 'normal' | 'wide' | null
sidebar_type_pluralization_enabled: boolean | null // null = default true
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
ai_model_providers: AiModelProvider[] | null
@@ -796,7 +810,7 @@ interface Settings {
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -27,6 +27,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Per-note `_width` rich-editor width override | Default rich-editor note width |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility |
| Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
@@ -38,6 +39,7 @@ Examples:
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
- ✅ App settings: `sidebar_type_pluralization_enabled: false` (installation-specific sidebar label preference)
- ✅ App settings: `all_notes_show_images: true` (installation-specific All Notes file-category visibility)
### No hardcoded exceptions
@@ -142,7 +144,7 @@ flowchart TD
SB["Sidebar\n(navigation + filters + types)"]
NL["NoteList / PulseView\n(filtered list / activity)"]
ED["Editor\n(BlockNote + diff + raw)"]
IN["Inspector\n(metadata + relationships)"]
IN["Right Panel\n(Inspector + TOC)"]
AIP["AiPanel\n(selected CLI agent + tools)"]
SP["SearchPanel\n(keyword search)"]
ST["StatusBar\n(vault picker + sync + version)"]
@@ -185,10 +187,10 @@ flowchart TD
```
┌────────┬─────────────┬─────────────────────────┬────────────┐
│Sidebar │ Note List │ Editor │ Inspector
│Sidebar │ Note List │ Editor │ Right Panel
│(250px) │ (300px) │ (flex-1) │ (280px) │
│ │ OR │ │ OR │
│ All │ Pulse View │ [Breadcrumb Bar] │ AI Chat
│ All │ Pulse View │ [Breadcrumb Bar] │ TOC
│ Changes│ │ │ OR │
│ Pulse │ [Search] │ # My Note │ AI Agent │
│ Inbox │ [Sort/Filt] │ │ │
@@ -205,20 +207,20 @@ flowchart TD
```
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled so opening or closing the Properties panel does not unmaximize or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. If an AppImage Wayland session advertises fcitx through common input-method environment hints and the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts so GTK/WebKit input contexts reach fcitx5 on compositors that do not reliably provide the Wayland input method frontend. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, the fcitx GTK fallback preserves Chinese/Japanese/Korean IME composition for affected AppImage Wayland users, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
## Multi-Window (Note Windows)
@@ -311,7 +313,7 @@ Large active notes are compacted into a head/tail body snapshot before they ente
### Direct Model Targets
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Local endpoints can omit authentication.
@@ -428,7 +430,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
### Cache File
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
@@ -450,11 +452,11 @@ flowchart TD
## Styling
The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app.
The app uses internal app-owned light and dark themes with an optional System preference (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app.
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value.
3. **Theme runtime**: Applies resolved `light` / `dark` values to `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode or System-on-dark is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value; `system` subscribes to `prefers-color-scheme` changes at runtime while explicit Light/Dark remain overrides.
## Localization
@@ -579,9 +581,10 @@ sequenceDiagram
A->>T: invoke('get_note_content')
T-->>A: raw markdown
A->>A: splitFrontmatter → [yaml, body]
A->>A: preProcessDurableEditorMarkdown(body)
A->>A: preProcessWikilinks(body)
A->>A: tryParseMarkdownToBlocks()
A->>A: injectWikilinks(blocks)
A->>A: injectWikilinks + injectDurableEditorMarkdownBlocks(blocks)
A-->>U: Editor renders note
```
@@ -869,7 +872,7 @@ Shortcut routing is explicit:
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`

View File

@@ -316,19 +316,19 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes. |
| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes; System mode resolves to one of these at runtime. |
| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). |
### Settings & Config
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, default AI agent). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
## Architecture Patterns
@@ -364,7 +364,7 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.

View File

@@ -2,8 +2,9 @@
type: ADR
id: "0071"
title: "External vault updates reload derived state and reopen the clean active note"
status: active
status: superseded
date: 2026-04-21
superseded_by: "0111"
---
## Context

View File

@@ -2,9 +2,10 @@
type: ADR
id: "0098"
title: "In-app image and PDF previews for binary vault files"
status: active
status: superseded
date: 2026-04-29
supersedes: "0086"
superseded_by: "0110"
---
## Context

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0109"
title: "Debounced worker-derived editor indexes"
status: active
date: 2026-05-04
---
## Context
Right side panels can need derived indexes of the active note, such as the Table of Contents hierarchy. These indexes are useful while editing, but rebuilding them synchronously during note opening or on every keystroke competes with the editor's main-thread work and violates ADR-0105's responsiveness contract.
The Table of Contents also needs live BlockNote block IDs for navigation, while the fastest and most stable source for the outline itself is the active note's Markdown content. Binding the outline rebuild directly to BlockNote document mutations makes typing and note swaps more expensive than necessary.
## Decision
**Derived editor indexes that are not required for the editor surface itself must be lazy, debounced, and built off the main thread when they can be derived from Markdown.** The Table of Contents does not build while its panel is closed; once opened, it uses a Web Worker to build its Markdown-derived H1/H2/H3 tree after a debounce, while live BlockNote block IDs are resolved only at click time for navigation.
## Options considered
- **Lazy debounced Web Worker for Markdown-derived indexes** (chosen): avoids any TOC work while the panel is closed, keeps outline parsing away from typing and note-opening work once opened, cancels stale panel updates, and lets the rendered editor remain the only editor surface. Cons: adds a small worker/client path and a title-only interim state.
- **Main-thread deferred rebuild with `setTimeout`**: avoids blocking the first render, but still runs on the UI thread and can still rebuild too often during active edits.
- **Synchronous rebuild from the BlockNote document**: simplest and gives immediate block IDs, but makes every BlockNote document update a potential side-panel rebuild.
- **Never update the TOC while editing**: safest for typing performance, but stale outlines make the panel misleading for active authoring.
## Consequences
- TOC tree state is driven by note identity plus debounced Markdown content, not by BlockNote document churn.
- Closing the TOC panel unmounts the panel and cancels pending debounce callbacks; no worker request is scheduled while the panel is closed.
- The TOC may briefly show only the note title after a note switch or edit burst; the full tree appears when the debounced worker result returns.
- Navigation remains tied to the live editor: block IDs are resolved from the current BlockNote document at click time and scrolled/focused then.
- Future derived side-panel indexes should follow the same pattern when they parse or scan note content and are not needed to render the editor itself.

View File

@@ -0,0 +1,43 @@
---
type: ADR
id: "0110"
title: "In-app media and PDF previews for binary vault files"
status: active
date: 2026-05-05
supersedes: "0098"
---
## Context
ADR-0098 extended Tolaria's file-first preview model from images to PDFs while keeping binary files as ordinary `VaultEntry` records. In practice, vaults also carry voice notes, interview recordings, screen captures, and short clips that users need to inspect in context without round-tripping through another app.
The existing binary preview architecture already had the important constraints in place:
- previewability should stay a renderer concern inferred from filename extension rather than a persisted schema field
- preview access should stay inside Tauri's scoped asset protocol instead of broad filesystem reads
- external-open actions must still re-enter the active-vault command boundary before delegating to the OS
Audio and video support should extend that same model rather than introducing a separate asset or media subsystem.
## Decision
**Tolaria previews supported image, audio, video, and PDF files in the editor pane while keeping them as ordinary binary vault files.**
- The scanner keeps the coarse `fileKind: "binary"` representation; `src/utils/filePreview.ts` infers preview support from safe extension allow-lists.
- `FilePreview` remains the single renderer-owned preview surface for supported binary files.
- Images continue to render through `<img>`, PDFs through the webview PDF object renderer, and audio/video through native HTML media controls, all backed by Tauri asset URLs from `convertFileSrc`.
- The Tauri CSP must allow scoped asset URLs in `media-src` for audio/video and in `object-src` for PDFs without broadening script or network permissions.
- Note-list rows for previewable media stay clickable and use file-specific affordances; unsupported binaries remain ordinary files with explicit fallback/open-external paths.
## Alternatives considered
- **Extend the existing FilePreview model to media** (chosen): keeps one binary-preview surface, reuses scoped asset access, and avoids new persisted file categories. Cons: native media controls are intentionally minimal.
- **Open audio and video only in the default app**: simpler implementation, but breaks in-context review for media-heavy vaults.
- **Introduce dedicated persisted media file kinds or a separate media library**: could support richer metadata later, but adds schema and scanner complexity for files that should remain normal vault entries.
## Consequences
- Audio and video do not become notes and do not get special persistence semantics.
- Tolaria's binary preview surface now covers the common safe media formats without changing cache shape, scanner output, or the filesystem-first model.
- Scoped runtime asset access and active-vault command validation remain the security boundary for binary previews and external-open actions.
- Re-evaluate this decision if Tolaria later needs editing, waveform/timeline tooling, subtitles, or transcoding, because those would justify a richer media-specific subsystem.

View File

@@ -0,0 +1,49 @@
---
type: ADR
id: "0111"
title: "Path-aware external vault refresh with focused-editor preservation"
status: active
date: 2026-05-05
supersedes: "0071"
---
## Context
ADR-0071 established a shared reconciliation path for external vault mutations so git pulls, AI-agent writes, and other non-local edits would reload vault-derived state and protect unsaved local changes. That policy was directionally right, but the original "reopen the clean active note" rule was too broad once Tolaria added a native filesystem watcher and more editor-mounted integrations.
Two problems emerged:
- unrelated external updates could remount a clean active editor even when the active file itself did not change
- remounting while focus was inside the rich or raw editor surface could drop cursor/focus state and disrupt native input flows even when the vault refresh itself was otherwise safe
Tolaria still needs refreshed entries, folders, views, backlinks, and other derived state after external writes. But it should only pay the cost of an active-editor remount when the changed-path batch actually requires one and the user is not actively focused inside the editor.
## Decision
**External vault refreshes now reload shared vault-derived state eagerly, but only remount the active editor when the active file itself changed and the editor is clean and unfocused.**
The shared `refreshPulledVaultState()` path now applies these rules:
1. Reload vault entries, folders, and saved views together for every external change batch.
2. If there is no active note, stop after the shared reload.
3. If the active note changed during the async reload, stop rather than reopening stale context.
4. If the active note has unsaved local edits, keep the current editor buffer mounted.
5. If focus is currently inside the rich or raw editor surface, keep that editor mounted even for otherwise clean notes.
6. If the active file disappeared, close the tab instead of leaving a stale editor behind.
7. Only close and reopen the active tab when the changed-path batch includes that active file and the previous guards did not apply.
Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches should continue to converge through this single reconciliation helper instead of inventing separate reload policies.
## Alternatives considered
- **Path-aware refresh with focused-editor preservation** (chosen): keeps derived vault state fresh while avoiding unnecessary remounts and focus loss. Cons: a focused clean editor can temporarily lag on-disk content until a later safe remount.
- **Always reopen the clean active note after every external refresh**: strongest immediate convergence, but causes visible churn and drops editor focus for unrelated changes.
- **Skip shared reloads whenever the editor is focused**: preserves focus, but leaves folders, views, backlinks, and other derived state stale.
## Consequences
- Unrelated external vault updates no longer remount the active editor just because the note is clean.
- Focused rich/raw editor sessions preserve cursor and native input state across watcher- or agent-driven vault refreshes.
- The changed-path batch is now part of the external-refresh contract; callers should pass the best available file list instead of treating all refreshes as full active-note invalidations.
- A focused clean editor may intentionally continue showing pre-refresh content until a later safe reopen, trading immediate active-note convergence for editing continuity.
- `refreshPulledVaultState()` remains the single place to evolve external-refresh policy; future features should extend that helper rather than layering ad hoc editor reload behavior.

View File

@@ -0,0 +1,41 @@
---
type: ADR
id: "0112"
title: "System theme mode"
status: active
date: 2026-05-05
---
## Context
ADR-0081 introduced Tolaria's internal app-owned light and dark theme runtime and deliberately deferred system-follow mode. That kept the first dark-mode release small, but users now need Tolaria to match the operating system appearance automatically, including scheduled macOS light/dark changes.
The previous constraints still apply: themes are app-owned, not vault-authored; the renderer must avoid startup flashes; shadcn/ui, Tailwind variables, editor chrome, and secondary windows must keep sharing the same resolved light/dark contract.
## Decision
**Tolaria now treats `system` as a persisted theme preference that resolves to the current OS light/dark appearance at runtime.**
The selected preference can be `light`, `dark`, or `system`:
1. `settings.theme_mode` remains the source of truth for the installation-local preference.
2. The localStorage mirror stores the selected preference, including `system`, so the `index.html` prepaint script can resolve the correct appearance before React mounts.
3. `data-theme` and the shadcn `.dark` class always receive the resolved app theme, `light` or `dark`; they never receive `system`.
4. When `system` is selected, the renderer subscribes to `prefers-color-scheme` changes and reapplies the resolved theme without reopening the app.
5. Explicit `light` and `dark` choices remain overrides and do not follow OS changes.
Command-palette theme actions and the Settings panel both save the same preference path. Product analytics record preference changes with the selected mode only, without sending vault or note content.
## Alternatives considered
- **Persist `system` and resolve it into the existing light/dark runtime** (chosen): keeps ADR-0081's small app-owned theme surface while adding OS-follow behavior.
- **Store the resolved OS theme in settings**: avoids a third stored value, but silently converts System users into explicit Light/Dark users after every save.
- **Set `data-theme="system"` and branch in CSS**: would require every theme consumer to understand a third state and would break existing Tailwind/shadcn dark-mode assumptions.
- **Rely only on CSS `prefers-color-scheme` media queries**: helps static CSS, but does not update JavaScript consumers, command state, editor integrations, or the localStorage startup mirror consistently.
## Consequences
- Startup still avoids a light flash when the stored preference is `system` and the OS is dark.
- Secondary windows that mount the shared theme hook receive the same resolved appearance and update on OS changes.
- Code that reads `document.documentElement.dataset.theme` must treat it as a resolved `light` or `dark` value, not as the stored user preference.
- Future theme variants should preserve this split between selected preference and resolved app theme rather than widening `data-theme` to non-renderable preference values.

View File

@@ -126,7 +126,7 @@ 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 |
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | superseded → [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) |
| [0072](0072-confirmed-vault-paths-gate-startup-state.md) | Confirmed vault paths gate startup state | active |
| [0073](0073-persistent-linkify-protocol-registry-across-editor-remounts.md) | Persistent linkify protocol registry across editor remounts | active |
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
@@ -150,7 +150,7 @@ proposed → active → superseded
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
| [0096](0096-root-created-type-documents.md) | Root-created type documents | active |
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | superseded → [0110](0110-in-app-media-and-pdf-file-previews.md) |
| [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active |
| [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active |
| [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active |
@@ -162,3 +162,7 @@ proposed → active → superseded
| [0107](0107-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active |
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | active |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
| [0112](0112-system-theme-mode.md) | System theme mode | active |

View File

@@ -30,11 +30,23 @@
(function () {
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';
var systemDarkQuery = '(prefers-color-scheme: dark)';
function normalizeTheme(value) {
return value === 'dark' || value === 'light' ? value : null;
return value === 'dark' || value === 'light' || value === 'system' ? value : null;
}
function prefersDarkTheme() {
try {
return typeof window.matchMedia === 'function' && window.matchMedia(systemDarkQuery).matches;
} catch (err) {
return false;
}
}
function resolveTheme(value) {
var mode = normalizeTheme(value) || 'light';
return mode === 'system' ? (prefersDarkTheme() ? 'dark' : 'light') : mode;
}
function applyTheme(value) {
var mode = normalizeTheme(value) || 'light';
var mode = resolveTheme(value);
document.documentElement.setAttribute('data-theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
}

View File

@@ -93,6 +93,7 @@ files:
command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a
command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166
command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f
command.settings.useSystemTheme: 6d8c69d916d3df8ceed8dfc154196d07
command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23
command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
@@ -114,6 +115,7 @@ files:
settings.theme.label: d721757161f7f70c5b0949fdb6ec2c30
settings.theme.light: 9914a0ce04a7b7b6a8e39bec55064b82
settings.theme.dark: a18366b217ebf811ad1886e4f4f865b2
settings.theme.system: a45da96d0bf6575970f2d27af22be28a
settings.language.title: 4994a8ffeba4ac3140beb89e8d41f174
settings.language.description: e2bb9b523067fdc32a494b1d6fd97906
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
@@ -134,6 +136,12 @@ files:
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
settings.noteWidth.default: 66be0f882801fd19468181f31f58dc20
settings.noteWidth.defaultDescription: 2b5fb6d7dfa2b1da1e47bcac30f58e8d
settings.noteWidth.normal: 960b44c579bc2f6818d2daaf9e4c16f0
settings.noteWidth.wide: e7c770a61dbdf81ca922ae0260e327c1
settings.sidebarTypePluralization.label: 1e5bd615b1abbf39129ef241f5cf2249
settings.sidebarTypePluralization.description: 8d7bb0649fd7e637552429ea91298de8
settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
settings.allNotesVisibility.title: 0e07c3d48b91e1a4c42c1ff3e5751ec3
@@ -416,11 +424,15 @@ files:
editor.toolbar.addFavorite: 910885435dbc44a22b68cb45c79e4a7e
editor.toolbar.markUnorganized: 83fb1b23a3609fab493737fe7af0a198
editor.toolbar.markOrganized: 9d170c916afae3d7a82456838728ffa5
editor.toolbar.openNeighborhood: 80f659c99954ab7681266b5a1e5da6df
editor.toolbar.noDiff: b2c4189f90648aaeb5cd2e81f9bd8d94
editor.toolbar.loadingDiff: 430386d6aae3570fd399a133e41c7372
editor.toolbar.gitDiff: 51b46b6eac27ba484479246b875f76f5
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
editor.toolbar.openTableOfContents: b733f053ea3fde4a9acd589ec7f58c10
editor.toolbar.closeTableOfContents: d3292972a10edd1a06a627f3552f760a
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
@@ -428,6 +440,14 @@ files:
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
tableOfContents.empty: e4f02ad69cbbce1ec65d14215e3d5871
tableOfContents.emptyNoNote: 046d95682b747a30ed8a7b0b1d581629
tableOfContents.navLabel: 598b8ae10dfce818e73d3218daddbdae
tableOfContents.untitledHeading: 93c2dde207965b383b7b22af005ebe4f
tableOfContents.expandHeading: 6353ab44fe75f5dd935789bdab8551a0
tableOfContents.collapseHeading: 040b4c102283028831ea78713235e478
editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
editor.filename.rename: c31c2b468229232ad6287e734fe67d96

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",
@@ -55,6 +55,7 @@
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tldraw/assets": "4.5.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,9 @@
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3f9f0ea59 100644
index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf6319df91544 100644
--- a/dist/blocknote-react.js
+++ b/dist/blocknote-react.js
@@ -155,8 +155,26 @@ var Rt = (e) => {
@@ -154,8 +154,26 @@ const co = (e) => {
};
function so(e) {
let t = new DOMRect();
- const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
@@ -17,7 +18,7 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
+ t = n();
+ return t;
+ };
}
+}
+function __bnSafeDomAtPos(e, t) {
+ const n = e.prosemirrorView;
+ if (!n || n.isDestroyed)
@@ -27,11 +28,10 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
+ } catch {
+ return null;
+ }
+}
}
const z = (e) => {
var h, b, p;
const { refs: t, floatingStyles: n, context: o } = Ze({
@@ -216,9 +234,7 @@ const Lt = (e) => {
@@ -216,9 +234,7 @@ const z = (e) => {
const s = Ue(t, c.doc);
if (!s)
return;
@@ -42,7 +42,39 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
if (a instanceof Element)
return {
element: a
@@ -3306,14 +3322,15 @@ const pi = (e) => {
@@ -2499,7 +2515,14 @@ function Kr(e) {
columns: d
} = e, m = H(
(C) => {
- a(), s(), u == null || u(C);
+ a();
+ try {
+ s();
+ } catch (x) {
+ console.warn("Ignored stale suggestion menu query cleanup:", x);
+ return;
+ }
+ u == null || u(C);
},
[u, a, s]
), { items: g, usedQuery: f, loadingState: h } = Yt(
@@ -2734,7 +2757,14 @@ function ti(e) {
onItemClick: u
} = e, d = H(
(p) => {
- a(), s(), u == null || u(p);
+ a();
+ try {
+ s();
+ } catch (C) {
+ console.warn("Ignored stale suggestion menu query cleanup:", C);
+ return;
+ }
+ u == null || u(p);
},
[u, a, s]
), { items: m, usedQuery: g, loadingState: f } = Yt(
@@ -3306,14 +3336,15 @@ const ii = (e, t = 0.3) => {
);
if (!m)
return {};
@@ -61,7 +93,7 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
element: f,
getBoundingClientRect: () => {
@@ -4371,7 +4388,7 @@ const El = (e) => {
@@ -4371,7 +4402,7 @@ const zi = (e) => {
const a = Ue(t, c.prosemirrorState.doc);
if (!a)
return;

34
pnpm-lock.yaml generated
View File

@@ -16,10 +16,10 @@ overrides:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4
hash: e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76
hash: 9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08
path: patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0':
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
@@ -37,16 +37,16 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@blocknote/react':
specifier: ^0.46.2
version: 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@codemirror/commands':
specifier: ^6.10.2
version: 6.10.2
@@ -125,6 +125,9 @@ importers:
'@tauri-apps/plugin-updater':
specifier: ^2.10.0
version: 2.10.0
'@tldraw/assets':
specifier: 4.5.10
version: 4.5.10
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -2499,6 +2502,9 @@ packages:
'@tiptap/starter-kit@3.22.5':
resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==}
'@tldraw/assets@4.5.10':
resolution: {integrity: sha512-nJZ+dZgLcmeEtv4aZv/kEkeY4IU2qrFC8Ayftxql1B+2hXc9ZsjNMKOHyB/W2BsVWdUplZm2qjujKeGNs1VWrw==}
'@tldraw/editor@4.5.10':
resolution: {integrity: sha512-kM11sDK1ADXATE/wthBDY3ZOriT5Nzpieq1EoPz/HwSMVFuLq5NVmaOPKF+Pt/n43T1S5eLOfMOc7i3zojNc3g==}
peerDependencies:
@@ -5608,9 +5614,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -5618,7 +5624,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -5670,8 +5676,8 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
'@mantine/utils': 6.0.22(react@19.2.4)
@@ -5690,9 +5696,9 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/react@0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@blocknote/react@0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
@@ -7757,6 +7763,10 @@ snapshots:
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)
'@tiptap/pm': 3.22.5
'@tldraw/assets@4.5.10':
dependencies:
'@tldraw/utils': 4.5.10
'@tldraw/editor@4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)

View File

@@ -0,0 +1,18 @@
## New Features
- 📋 **Paste Without Formatting** — Paste copied text as plain content without bringing unwanted styling into a note.
- 🇵🇱 **Polish Language Support** — Use Tolaria with a new Polish interface translation.
- 🧭 **Refined Titlebar Navigation** — Navigate with clearer titlebar controls that feel more native on desktop.
## Improvements
-**Faster Note Loading** — Switch between notes more smoothly by reusing cached note content and parsed editor blocks.
- 🗂️ **Cleaner Sidebar and Menus** — Scan folders, sidebar sections, slash commands, icon choices, and release tabs with cleaner spacing and styling.
- 🖥️ **Better Cross-Platform Window Controls** — Use macOS and Linux window controls that better match each platform.
- ☀️ **Improved Light Mode Editing** — Read code blocks more comfortably when Tolaria is using the light theme.
## Stability and Fixes
- This release also improves editor reliability around block dragging, table handles, pasted Markdown, stale side-menu actions, and unrelated vault refreshes.
- Vault, type, and frontmatter handling were hardened to prevent freezes, filename collisions, parse failures, and stale saved-view deletes.
- Startup, Linux AppImage, release CI, and AI/Codex integration fixes are included in the full commit list.

View File

@@ -0,0 +1,20 @@
## New Features
- 🤖 **Direct AI Model Providers** — Use OpenAI, Anthropic, Gemini, OpenRouter, Ollama, LM Studio, or a custom OpenAI-compatible endpoint directly from Tolaria.
- 🖊️ **Markdown Whiteboards** — Create and edit tldraw-powered whiteboards that live as durable Markdown files in your vault.
- 🧭 **Table of Contents Panel** — Navigate long notes with a lazy, outline-style panel generated from the current document headings.
- 📄 **Readable Release Notes** — View stable releases through curated notes written for users instead of raw commit lists.
## Improvements
- 🧩 **Cleaner AI Settings** — Configure provider secrets, local models, and AI targets through a more focused settings experience.
- 💬 **Better Agent Context Handling** — Large AI agent contexts are compacted more safely, and Codex MCP tools are exposed more reliably.
- 🧱 **Improved Editor Blocks** — Code blocks can be copied more easily, block controls feel steadier, and rich exports avoid unsupported browser APIs.
- 🗂️ **Smarter Note and Type Handling** — Renames, type changes, frontmatter placeholders, and path identity now behave more consistently across vault flows.
- 🌍 **Better International Editing** — RTL text direction, IME composition, Korean sidebar copy, and unicode git paths all received focused polish.
## Stability and Fixes
- Embedded diagrams, tldraw whiteboard assets, stale checklist toggles, and stale note open or rename flows were hardened to avoid broken editor states.
- Vault recovery, missing active vault handling, fresh-note heading paste, and same-path type collisions now fail more gracefully.
- Linux AppImage startup, MCP resource discovery, release CI, Codacy security findings, and AI provider runtime paths all received stability fixes.

View File

@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -70,11 +71,35 @@ pub struct AiModelProviderTestRequest {
pub api_key_override: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct AiModelProviderCatalogEntry {
kind: AiModelProviderKind,
runtime_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
struct AiProviderSecrets {
provider_api_keys: BTreeMap<String, String>,
}
static AI_MODEL_PROVIDER_CATALOG: OnceLock<Vec<AiModelProviderCatalogEntry>> = OnceLock::new();
fn provider_catalog() -> &'static [AiModelProviderCatalogEntry] {
AI_MODEL_PROVIDER_CATALOG
.get_or_init(|| {
serde_json::from_str(include_str!("../../src/shared/aiModelProviderCatalog.json"))
.expect("bundled AI model provider catalog must be valid JSON")
})
.as_slice()
}
fn provider_default_base_url(kind: &AiModelProviderKind) -> Option<&'static str> {
provider_catalog()
.iter()
.find(|entry| entry.kind == *kind)
.and_then(|entry| entry.runtime_base_url.as_deref())
}
pub fn normalize_ai_model_providers(
providers: Option<Vec<AiModelProvider>>,
) -> Option<Vec<AiModelProvider>> {
@@ -211,15 +236,7 @@ fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
}
fn normalized_base_url(request: &AiModelStreamRequest) -> Result<String, String> {
let fallback = match request.provider.kind {
AiModelProviderKind::OpenAi => "https://api.openai.com/v1",
AiModelProviderKind::Anthropic => "https://api.anthropic.com/v1",
AiModelProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
AiModelProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai",
AiModelProviderKind::Ollama => "http://localhost:11434/v1",
AiModelProviderKind::LmStudio => "http://127.0.0.1:1234/v1",
AiModelProviderKind::OpenAiCompatible => "",
};
let fallback = provider_default_base_url(&request.provider.kind).unwrap_or("");
let base = request
.provider
.base_url
@@ -588,6 +605,22 @@ mod tests {
assert_eq!(headers, vec![("X-Demo", "demo")]);
}
#[test]
fn shared_provider_catalog_supplies_runtime_base_urls() {
assert_eq!(
provider_default_base_url(&AiModelProviderKind::OpenAi),
Some("https://api.openai.com/v1")
);
assert_eq!(
provider_default_base_url(&AiModelProviderKind::Ollama),
Some("http://localhost:11434/v1")
);
assert_eq!(
provider_default_base_url(&AiModelProviderKind::OpenAiCompatible),
None
);
}
#[test]
fn custom_provider_requires_base_url() {
let mut provider = provider(AiModelProviderKind::OpenAiCompatible);

View File

@@ -178,7 +178,7 @@ where
let last_message_path = last_message_dir.path().join("last-message.txt");
let args = build_codex_args(&request, Some(&last_message_path))?;
let prompt = build_codex_prompt(&request);
let command = build_codex_command(binary, args, prompt, &request.vault_path);
let command = build_codex_command(binary, args, prompt, &request.vault_path)?;
let emit = with_codex_last_message_fallback(emit, last_message_path);
crate::cli_agent_runtime::run_ai_agent_json_stream(
@@ -223,9 +223,13 @@ fn build_codex_command(
args: Vec<String>,
prompt: String,
vault_path: &str,
) -> std::process::Command {
let mut command = crate::hidden_command(binary);
) -> Result<std::process::Command, String> {
let target = codex_command_target(binary)?;
let mut command = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
if let Some(first_arg) = target.first_arg {
command.arg(first_arg);
}
command
.args(args)
.arg(prompt)
@@ -233,7 +237,65 @@ fn build_codex_command(
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
Ok(command)
}
struct CodexCommandTarget {
program: PathBuf,
first_arg: Option<PathBuf>,
}
fn codex_command_target(binary: &Path) -> Result<CodexCommandTarget, String> {
if is_windows_batch_shim(binary) {
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
return Ok(CodexCommandTarget {
program: crate::mcp::find_node()?,
first_arg: Some(script),
});
}
}
Ok(CodexCommandTarget {
program: binary.to_path_buf(),
first_arg: None,
})
}
fn is_windows_batch_shim(binary: &Path) -> bool {
binary
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
})
}
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(binary).ok()?;
contents
.split('"')
.skip(1)
.step_by(2)
.filter(|token| is_node_script_token(token))
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
}
fn is_node_script_token(token: &str) -> bool {
let lower = token.to_ascii_lowercase();
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
}
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
let relative = token
.strip_prefix("%dp0%")
.or_else(|| token.strip_prefix("%~dp0"))?
.trim_start_matches(['\\', '/']);
let mut script = binary.parent()?.to_path_buf();
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
script.push(part);
}
script.is_file().then_some(script)
}
fn build_codex_args(
@@ -621,7 +683,7 @@ mod tests {
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
let args = vec!["exec".to_string(), "--json".to_string()];
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault").unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
assert_eq!(command.get_program(), OsStr::new("codex"));
@@ -644,7 +706,8 @@ mod tests {
vec!["exec".to_string(), "--json".to_string()],
"Summarize".into(),
"/tmp/vault",
);
)
.unwrap();
let path_value = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("PATH"))
@@ -658,6 +721,51 @@ mod tests {
);
}
#[test]
fn build_codex_command_avoids_windows_cmd_shim_for_complex_args() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("codex.cmd");
let script = dir
.path()
.join("node_modules")
.join("@openai")
.join("codex")
.join("bin")
.join("codex.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, "console.log('codex')\n").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
"%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %*
"#,
)
.unwrap();
let command = build_codex_command(
&shim,
vec![
"exec".to_string(),
"-c".to_string(),
r#"mcp_servers.tolaria.command="C:\\Program Files\\node.exe""#.to_string(),
],
"Summarize".into(),
"/tmp/vault",
)
.unwrap();
assert_ne!(
command.get_program(),
shim.as_os_str(),
"Codex npm .cmd shims cannot safely receive quoted -c args directly"
);
let actual_args = command.get_args().collect::<Vec<_>>();
assert_eq!(actual_args.first().copied(), Some(script.as_os_str()));
assert!(actual_args
.iter()
.any(|arg| *arg == OsStr::new("Summarize")));
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {

View File

@@ -22,6 +22,13 @@ struct FrontmatterLine<'a>(&'a str);
#[derive(Clone, Copy)]
struct PropertyKey<'a>(&'a str);
#[derive(Clone, Copy)]
struct FrontmatterBlock<'a> {
body: &'a str,
rest: &'a str,
line_ending: &'static str,
}
impl<'a> PropertyKey<'a> {
fn as_str(self) -> &'a str {
self.0
@@ -59,6 +66,42 @@ fn quoted_yaml_key(raw: &str, quote: char) -> Option<&str> {
rest.trim_start().starts_with(':').then_some(key)
}
fn frontmatter_open(content: &str) -> Option<(&str, &'static str)> {
content
.strip_prefix("---\n")
.map(|after| (after, "\n"))
.or_else(|| content.strip_prefix("---\r\n").map(|after| (after, "\r\n")))
}
fn close_marker(line_ending: &str) -> String {
format!("{line_ending}---")
}
fn split_frontmatter_block(content: &str) -> Result<Option<FrontmatterBlock<'_>>, String> {
let Some((after_open, line_ending)) = frontmatter_open(content) else {
return Ok(None);
};
if let Some(rest) = after_open.strip_prefix("---") {
return Ok(Some(FrontmatterBlock {
body: "",
rest,
line_ending,
}));
}
let marker = close_marker(line_ending);
let close_start = after_open
.find(&marker)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
let rest_start = close_start + marker.len();
Ok(Some(FrontmatterBlock {
body: &after_open[..close_start],
rest: &after_open[rest_start..],
line_ending,
}))
}
#[derive(Clone, Copy)]
struct FieldUpdate<'a> {
key: PropertyKey<'a>,
@@ -108,26 +151,19 @@ impl<'a> FieldUpdate<'a> {
}
fn apply_to_content(self, content: DocumentText<'_>) -> Result<String, String> {
if !content.0.starts_with("---\n") {
let Some(block) = split_frontmatter_block(content.0)? else {
return match self.value {
Some(_) => Ok(self.prepend_to(content)),
None => Ok(content.0.to_string()),
};
}
let after_open = &content.0[4..];
let (fm_content, rest) = if let Some(stripped) = after_open.strip_prefix("---") {
("", stripped)
} else {
let fm_end = after_open
.find("\n---")
.map(|i| i + 4)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
(&content.0[4..fm_end], &content.0[fm_end + 4..])
};
let lines: Vec<FrontmatterLine<'_>> = fm_content.lines().map(FrontmatterLine).collect();
let new_fm = self.apply_to_lines(&lines).join("\n");
Ok(format!("---\n{}\n---{}", new_fm, rest))
let lines: Vec<FrontmatterLine<'_>> = block.body.lines().map(FrontmatterLine).collect();
let new_fm = self.apply_to_lines(&lines).join(block.line_ending);
Ok(format!(
"---{}{}{}---{}",
block.line_ending, new_fm, block.line_ending, block.rest
))
}
}
@@ -162,3 +198,70 @@ pub fn update_frontmatter_content(
}
.apply_to_content(DocumentText(&updated))
}
#[cfg(test)]
mod tests {
use super::*;
fn bool_value(value: bool) -> FrontmatterValue {
FrontmatterValue::Bool(value)
}
fn string_value(value: &str) -> FrontmatterValue {
FrontmatterValue::String(value.to_string())
}
fn frontmatter_delimiter_lines(content: &str) -> usize {
content.lines().filter(|line| *line == "---").count()
}
#[test]
fn updates_existing_crlf_frontmatter_without_creating_a_second_block() {
let content = concat!(
"---\r\n",
"type: Note\r\n",
"related_to:\r\n",
" - \"[[tolaria]]\"\r\n",
"---\r\n",
"# Properties Panel\r\n",
);
let updated = update_frontmatter_content(content, "_organized", Some(bool_value(true)))
.expect("frontmatter update should succeed");
assert_eq!(frontmatter_delimiter_lines(&updated), 2);
assert_eq!(
updated,
concat!(
"---\r\n",
"type: Note\r\n",
"related_to:\r\n",
" - \"[[tolaria]]\"\r\n",
"_organized: true\r\n",
"---\r\n",
"# Properties Panel\r\n",
)
);
}
#[test]
fn repeated_crlf_updates_stay_in_the_original_frontmatter_block() {
let content = concat!(
"---\r\n",
"type: Note\r\n",
"related_to: \"[[tolaria]]\"\r\n",
"---\r\n",
"# Properties Panel\r\n",
);
let widened = update_frontmatter_content(content, "_width", Some(string_value("wide")))
.expect("width update should succeed");
let organized = update_frontmatter_content(&widened, "_organized", Some(bool_value(true)))
.expect("organized update should succeed");
assert_eq!(frontmatter_delimiter_lines(&organized), 2);
assert!(organized.contains("type: Note\r\n"));
assert!(organized.contains("_width: wide\r\n"));
assert!(organized.contains("_organized: true\r\n"));
}
}

View File

@@ -1,4 +1,5 @@
use super::git_command;
use crate::vault::path_identity::vault_relative_path_string;
use std::path::Path;
use super::GitCommit;
@@ -7,14 +8,7 @@ use super::GitCommit;
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let relative_str = vault_relative_path_string(vault, file)?;
let output = git_command()
.args([
@@ -23,7 +17,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
"-n",
"20",
"--",
relative_str,
&relative_str,
])
.current_dir(vault)
.output()
@@ -70,18 +64,11 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let relative_str = vault_relative_path_string(vault, file)?;
// First try tracked file diff
let output = git_command()
.args(["diff", "--", relative_str])
.args(["diff", "--", &relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
@@ -91,7 +78,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = git_command()
.args(["diff", "--cached", "--", relative_str])
.args(["diff", "--cached", "--", &relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
@@ -103,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
// Try showing untracked file as all-new
let status = git_command()
.args(["status", "--porcelain", "--", relative_str])
.args(["status", "--porcelain", "--", &relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
@@ -134,14 +121,7 @@ pub fn get_file_diff_at_commit(
) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let relative_str = vault_relative_path_string(vault, file)?;
// Show diff between commit^ and commit for this file
let output = git_command()
@@ -150,7 +130,7 @@ pub fn get_file_diff_at_commit(
&format!("{}^", commit_hash),
commit_hash,
"--",
relative_str,
&relative_str,
])
.current_dir(vault)
.output()

View File

@@ -15,6 +15,16 @@ const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
},
];
const FCITX_GTK_IM_MODULE_OVERRIDE: StartupEnvOverride = StartupEnvOverride {
key: "GTK_IM_MODULE",
value: "fcitx",
};
const FCITX_ENV_HINT_KEYS: [&str; 4] = [
"XMODIFIERS",
"INPUT_METHOD",
"QT_IM_MODULE",
"SDL_IM_MODULE",
];
const COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf";
#[cfg(all(desktop, target_os = "linux"))]
const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf";
@@ -51,6 +61,13 @@ where
get_var(key).is_some_and(|value| !value.trim().is_empty())
}
fn has_env<F>(get_var: &mut F, key: &str) -> bool
where
F: FnMut(&str) -> Option<String>,
{
get_var(key).is_some()
}
fn has_explicit_fontconfig_env<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,
@@ -71,6 +88,39 @@ where
!has_explicit_fontconfig_env(get_var)
}
fn env_mentions_fcitx(value: &str) -> bool {
value.to_ascii_lowercase().contains("fcitx")
}
fn has_fcitx_env_hint<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
FCITX_ENV_HINT_KEYS
.into_iter()
.any(|key| get_var(key).is_some_and(|value| env_mentions_fcitx(&value)))
}
fn fcitx_gtk_im_module_override_with<F>(get_var: &mut F) -> Option<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut *get_var) {
return None;
}
if !is_wayland_session(&mut *get_var) {
return None;
}
if has_env(get_var, "GTK_IM_MODULE") {
return None;
}
if !has_fcitx_env_hint(get_var) {
return None;
}
Some(FCITX_GTK_IM_MODULE_OVERRIDE)
}
fn is_wayland_session<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
@@ -179,10 +229,16 @@ where
return Vec::new();
}
LINUX_APPIMAGE_WEBKIT_OVERRIDES
let mut overrides: Vec<_> = LINUX_APPIMAGE_WEBKIT_OVERRIDES
.into_iter()
.filter(|env_override| !has_non_empty_env(&mut get_var, env_override.key))
.collect()
.collect();
if let Some(env_override) = fcitx_gtk_im_module_override_with(&mut get_var) {
overrides.push(env_override);
}
overrides
}
#[cfg(all(desktop, target_os = "linux"))]
@@ -289,6 +345,38 @@ mod tests {
startup_env_overrides_with, wayland_client_preload_path_with, StartupEnvOverride,
};
fn default_webkit_overrides() -> Vec<StartupEnvOverride> {
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
},
]
}
fn default_webkit_and_fcitx_overrides() -> Vec<StartupEnvOverride> {
let mut overrides = default_webkit_overrides();
overrides.push(StartupEnvOverride {
key: "GTK_IM_MODULE",
value: "fcitx",
});
overrides
}
fn appimage_wayland_fcitx_env(key: &str, gtk_im_module: Option<&str>) -> Option<String> {
match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-1".to_string()),
"XMODIFIERS" => Some("@im=fcitx".to_string()),
"GTK_IM_MODULE" => gtk_im_module.map(ToOwned::to_owned),
_ => None,
}
}
#[test]
fn startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = startup_env_overrides_with(|_| None);
@@ -303,19 +391,7 @@ mod tests {
_ => None,
});
assert_eq!(
overrides,
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}
]
);
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
@@ -335,6 +411,32 @@ mod tests {
);
}
#[test]
fn startup_env_overrides_enable_fcitx_gtk_module_for_wayland_appimage() {
let overrides = startup_env_overrides_with(|key| appimage_wayland_fcitx_env(key, None));
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
}
#[test]
fn startup_env_overrides_preserve_explicit_gtk_im_module() {
let overrides =
startup_env_overrides_with(|key| appimage_wayland_fcitx_env(key, Some("wayland")));
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn startup_env_overrides_leave_non_fcitx_wayland_appimage_input_unchanged() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-1".to_string()),
_ => None,
});
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn colrv1_font_guard_targets_reported_appimage_emoji_font() {
let font_path = colrv1_emoji_font_path_with(

View File

@@ -1,9 +1,13 @@
#[cfg(not(target_os = "macos"))]
use crate::window_state::MAIN_WINDOW_LABEL;
use serde::{Deserialize, Deserializer};
use std::{
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
};
#[cfg(not(target_os = "macos"))]
use tauri::{menu::MenuEvent, Manager};
use tauri::{
menu::{MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
App, AppHandle, Emitter,
@@ -234,6 +238,10 @@ fn app_menu_includes_services(target_os: &str) -> bool {
target_os == "macos"
}
fn window_menu_event_handler_required(target_os: &str) -> bool {
target_os != "macos"
}
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
@@ -383,6 +391,28 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn Error>> {
let _ = emit_custom_menu_event(app_handle, id);
});
register_window_menu_event_handler(app)?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn register_window_menu_event_handler(app: &App) -> Result<(), Box<dyn Error>> {
debug_assert!(window_menu_event_handler_required(std::env::consts::OS));
let window = app.get_webview_window(MAIN_WINDOW_LABEL).ok_or_else(|| {
format!("setup_menu: window '{MAIN_WINDOW_LABEL}' not found; menu events will not fire")
})?;
let app_handle = app.handle().clone();
window.on_menu_event(move |_window, event: MenuEvent| {
let id = event.id().0.as_str();
let _ = emit_custom_menu_event(&app_handle, id);
});
Ok(())
}
#[cfg(target_os = "macos")]
fn register_window_menu_event_handler(_app: &App) -> Result<(), Box<dyn Error>> {
debug_assert!(!window_menu_event_handler_required(std::env::consts::OS));
Ok(())
}
@@ -564,4 +594,11 @@ mod tests {
assert!(!app_menu_includes_services("windows"));
assert!(!app_menu_includes_services("linux"));
}
#[test]
fn window_menu_event_handler_is_required_off_macos() {
assert!(!window_menu_event_handler_required("macos"));
assert!(window_menu_event_handler_required("windows"));
assert!(window_menu_event_handler_required("linux"));
}
}

View File

@@ -78,6 +78,7 @@ pub struct Settings {
pub theme_mode: Option<String>,
pub ui_language: Option<String>,
pub note_width_mode: Option<String>,
pub sidebar_type_pluralization_enabled: Option<bool>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub default_ai_target: Option<String>,
@@ -122,7 +123,7 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(mode) if mode == "light" || mode == "dark" => Some(mode),
Some(mode) if mode == "light" || mode == "dark" || mode == "system" => Some(mode),
_ => None,
}
}
@@ -181,6 +182,7 @@ fn normalize_settings(settings: Settings) -> Settings {
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
sidebar_type_pluralization_enabled: settings.sidebar_type_pluralization_enabled,
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
default_ai_target: normalize_optional_string(settings.default_ai_target),
@@ -330,6 +332,7 @@ mod tests {
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
note_width_mode: Some("wide".to_string()),
sidebar_type_pluralization_enabled: Some(false),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
default_ai_target: Some("agent:codex".to_string()),
@@ -364,6 +367,7 @@ mod tests {
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
note_width_mode: Some("wide".to_string()),
sidebar_type_pluralization_enabled: Some(false),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
@@ -381,6 +385,7 @@ mod tests {
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-CN"));
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
assert_eq!(loaded.sidebar_type_pluralization_enabled, Some(false));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.hide_gitignored_files, Some(false));
@@ -487,11 +492,20 @@ mod tests {
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
fn test_system_theme_mode_is_preserved() {
let loaded = save_and_reload(Settings {
theme_mode: Some("system".to_string()),
..Default::default()
});
assert_eq!(loaded.theme_mode.as_deref(), Some("system"));
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
let loaded = save_and_reload(Settings {
theme_mode: Some("sepia".to_string()),
..Default::default()
});
assert!(loaded.theme_mode.is_none());
}

View File

@@ -9,6 +9,10 @@ use uuid::Uuid;
use crate::git::{get_all_file_dates, GitDates};
use std::collections::HashMap;
use super::path_identity::{
normalize_path_for_identity, push_unique_relative_path, relative_path_key,
vault_relative_path_string,
};
use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
@@ -83,7 +87,7 @@ fn default_cache_version() -> u32 {
/// Compute a deterministic hex hash of the vault path for use as cache filename.
fn vault_path_hash(vault: &Path) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
vault.to_string_lossy().as_ref().hash(&mut hasher);
normalize_path_for_identity(&vault.to_string_lossy()).hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
@@ -148,28 +152,22 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
/// Includes all non-hidden files (not just .md) so the cache picks up
/// view files (.yml), binary assets, etc.
fn collect_paths_from_diff(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter(|line| !line.is_empty() && !has_hidden_segment(line))
.map(|line| line.to_string())
.collect()
let mut paths = Vec::new();
for line in stdout.lines() {
push_unique_relative_path(&mut paths, line);
}
paths
}
/// Extract file paths from git status --porcelain output.
/// Includes all non-hidden files so incremental cache updates cover
/// every file type the vault scanner recognises.
fn collect_paths_from_porcelain(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter_map(parse_porcelain_line)
.filter(|(_, path)| !has_hidden_segment(path))
.map(|(_, path)| path)
.collect()
}
/// Return true if any path segment starts with `.` (hidden file/directory).
fn has_hidden_segment(path: &str) -> bool {
path.split('/').any(|seg| seg.starts_with('.'))
let mut paths = Vec::new();
for (_, path) in stdout.lines().filter_map(parse_porcelain_line) {
push_unique_relative_path(&mut paths, path);
}
paths
}
fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String> {
@@ -182,9 +180,7 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
let uncommitted = git_uncommitted_files(vault);
for path in uncommitted.into_iter() {
if !files.contains(&path) {
files.push(path);
}
push_unique_relative_path(&mut files, path);
}
files
@@ -201,17 +197,16 @@ fn git_uncommitted_files(vault: &Path) -> Vec<String> {
// files inside — ls-files resolves them so the cache picks up all new files.
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
.map(|s| {
s.lines()
.filter(|l| !l.is_empty() && !has_hidden_segment(l))
.map(|l| l.to_string())
.collect::<Vec<_>>()
let mut paths = Vec::new();
for line in s.lines() {
push_unique_relative_path(&mut paths, line);
}
paths
})
.unwrap_or_default();
for path in untracked {
if !files.contains(&path) {
files.push(path);
}
push_unique_relative_path(&mut files, path);
}
files
@@ -447,13 +442,12 @@ fn write_cache(
/// Normalize an absolute path to a relative path for comparison with git output.
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
let vault_str = vault.to_string_lossy();
let with_slash = format!("{}/", vault_str);
abs_path
.strip_prefix(&with_slash)
.or_else(|| abs_path.strip_prefix(vault_str.as_ref()))
.unwrap_or(abs_path)
.to_string()
vault_relative_path_string(vault, Path::new(abs_path))
.unwrap_or_else(|_| normalize_path_for_identity(abs_path))
}
fn to_relative_path_key(abs_path: &str, vault: &Path) -> String {
relative_path_key(&to_relative_path(abs_path, vault))
}
/// Parse files from a list of relative paths, skipping any that don't exist.
@@ -535,7 +529,7 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
// Deduplicate by case-folded relative path
let mut seen = std::collections::HashSet::new();
entries.retain(|e| {
let rel = to_relative_path(&e.path, vault).to_lowercase();
let rel = to_relative_path_key(&e.path, vault);
seen.insert(rel)
});
entries.len() != before
@@ -587,8 +581,9 @@ fn update_same_commit(
let changed = git_uncommitted_files(vault);
let mut entries = cache.entries;
if !changed.is_empty() {
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
let changed_set: std::collections::HashSet<String> =
changed.iter().map(|path| relative_path_key(path)).collect();
entries.retain(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault)));
entries.extend(parse_files_at(vault, &changed, git_dates));
}
// Always finalize: prune_stale_entries inside finalize_and_cache removes
@@ -605,12 +600,15 @@ fn update_different_commit(
) -> Vec<VaultEntry> {
let LoadedCache { cache, fingerprint } = loaded_cache;
let changed_files = git_changed_files(vault, &cache.commit_hash, &current_hash);
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
let changed_set: std::collections::HashSet<String> = changed_files
.iter()
.map(|path| relative_path_key(path))
.collect();
let mut entries: Vec<VaultEntry> = cache
.entries
.into_iter()
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
.filter(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault)))
.collect();
entries.extend(parse_files_at(vault, &changed_files, git_dates));
@@ -618,9 +616,10 @@ fn update_different_commit(
}
fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool {
let current_vault_str = vault_path.to_string_lossy();
let current_vault_str = normalize_path_for_identity(&vault_path.to_string_lossy());
cache.version != CACHE_VERSION
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref())
|| (!cache.vault_path.is_empty()
&& normalize_path_for_identity(&cache.vault_path) != current_vault_str)
}
fn scan_and_cache_full(
@@ -808,6 +807,17 @@ mod tests {
assert_eq!(hash1, hash2);
}
#[test]
fn test_to_relative_path_normalizes_aliases_and_separators() {
assert_eq!(
to_relative_path(
"/tmp/tolaria-vault/projects\\active.md",
Path::new("/private/tmp/tolaria-vault")
),
"projects/active.md"
);
}
#[test]
fn test_different_vaults_get_different_hashes() {
let hash1 = vault_path_hash(Path::new("/Users/test/Vault1"));

View File

@@ -2,7 +2,10 @@ use std::borrow::Cow;
use std::fs;
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
use std::thread;
use std::time::{Duration, UNIX_EPOCH};
const SAVE_RETRY_DELAYS_MS: [u64; 4] = [25, 50, 100, 200];
/// Read file metadata (modified_at timestamp, created_at timestamp, file size).
/// Creation time is sourced from filesystem metadata (birthtime on macOS).
@@ -30,6 +33,25 @@ fn is_invalid_platform_path_error(error: &Error) -> bool {
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
}
fn is_retryable_save_error(error: &Error) -> bool {
error.kind() == ErrorKind::PermissionDenied
|| (cfg!(windows) && error.raw_os_error() == Some(5))
}
fn write_with_retry(
mut write_once: impl FnMut() -> Result<(), Error>,
mut wait_before_retry: impl FnMut(u64),
) -> Result<(), Error> {
for delay in SAVE_RETRY_DELAYS_MS {
match write_once() {
Ok(()) => return Ok(()),
Err(error) if is_retryable_save_error(&error) => wait_before_retry(delay),
Err(error) => return Err(error),
}
}
write_once()
}
fn read_existing_note_bytes(path: &Path) -> Result<Vec<u8>, String> {
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
@@ -141,8 +163,11 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
}
}
validate_save_path(file_path, path)?;
fs::write(file_path, content)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
write_with_retry(
|| fs::write(file_path, content),
|delay| thread::sleep(Duration::from_millis(delay)),
)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
/// Create a new note file without overwriting any existing file.
@@ -197,6 +222,28 @@ mod tests {
);
}
#[test]
fn retries_transient_access_denied_save_errors() {
let mut attempts = 0;
let mut delays = Vec::new();
write_with_retry(
|| {
attempts += 1;
if attempts == 1 {
Err(Error::new(ErrorKind::PermissionDenied, "Access is denied"))
} else {
Ok(())
}
},
|delay| delays.push(delay),
)
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(delays, vec![25]);
}
#[test]
fn note_content_matches_detects_external_edits() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -242,28 +242,53 @@ pub(crate) fn extract_relationships(
continue;
}
match value {
serde_json::Value::String(s) if contains_wikilink(s) => {
relationships.insert(key.clone(), vec![s.clone()]);
}
serde_json::Value::Array(arr) => {
let wikilinks: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| contains_wikilink(s))
.map(|s| s.to_string())
.collect();
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
_ => {}
let wikilinks = relationship_wikilinks(value);
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
relationships
}
fn relationship_wikilinks(value: &serde_json::Value) -> Vec<String> {
let mut wikilinks = Vec::new();
collect_relationship_wikilinks(value, 0, &mut wikilinks);
wikilinks
}
fn collect_relationship_wikilinks(
value: &serde_json::Value,
depth: usize,
wikilinks: &mut Vec<String>,
) {
match value {
serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()),
serde_json::Value::Array(arr) => {
if let Some(link) = nested_flow_wikilink(arr, depth) {
wikilinks.push(link);
return;
}
for item in arr {
collect_relationship_wikilinks(item, depth + 1, wikilinks);
}
}
_ => {}
}
}
fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<String> {
if depth == 0 {
return None;
}
match arr {
[serde_json::Value::String(target)] if !contains_wikilink(target) => {
Some(format!("[[{target}]]"))
}
_ => None,
}
}
/// Extract custom scalar properties from raw YAML frontmatter.
pub(crate) fn extract_properties(
data: &HashMap<String, serde_json::Value>,
@@ -276,6 +301,9 @@ pub(crate) fn extract_properties(
}
match value {
serde_json::Value::Null => {
properties.insert(key.clone(), value.clone());
}
serde_json::Value::String(s) if !contains_wikilink(s) => {
properties.insert(key.clone(), value.clone());
}

View File

@@ -114,6 +114,40 @@ fn test_single_element_array_properties_unwrap_to_scalars() {
}
}
#[test]
fn test_blank_scalar_properties_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Book\nstart date:\nrating: \n---\n# Book\n",
);
assert!(entry
.properties
.get("start date")
.is_some_and(|value| value.is_null()));
assert!(entry
.properties
.get("rating")
.is_some_and(|value| value.is_null()));
}
#[test]
fn test_unquoted_wikilink_relationships_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n",
);
assert_eq!(
entry.relationships.get("Mentor"),
Some(&vec!["[[person/alice]]".to_string()])
);
}
#[test]
fn test_alias_parser_recovers_special_alias_items() {
let cases = [

View File

@@ -10,6 +10,7 @@ mod ignored;
mod image;
mod migration;
mod parsing;
pub(crate) mod path_identity;
mod rename;
mod rename_transaction;
mod title_sync;
@@ -349,11 +350,7 @@ fn lookup_git_dates(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
) -> Option<(u64, u64)> {
let rel = path
.strip_prefix(vault_path)
.ok()?
.to_string_lossy()
.to_string();
let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?;
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
}
@@ -462,11 +459,10 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
if is_folder_tree_hidden_dir(&name) {
continue;
}
let rel_path = path
.strip_prefix(vault_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let rel_path = path_identity::vault_relative_path_string(vault_root, &path)
.unwrap_or_else(|_| {
path_identity::normalize_path_for_identity(&path.to_string_lossy())
});
let children = build_tree(&path, vault_root);
nodes.push(FolderNode {
name,

View File

@@ -0,0 +1,117 @@
use std::path::Path;
type PathText = str;
type RelativePathText = str;
fn normalize_tmp_alias(path: &PathText) -> String {
if path == "/private/tmp" {
return "/tmp".to_string();
}
if let Some(rest) = path.strip_prefix("/private/tmp/") {
return format!("/tmp/{rest}");
}
path.to_string()
}
fn trim_trailing_slashes(path: String) -> String {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() && path.starts_with('/') {
"/".to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn normalize_path_for_identity(path: &PathText) -> String {
trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/")))
}
pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String {
path.replace('\\', "/").trim_matches('/').to_string()
}
pub(crate) fn relative_path_key(path: &RelativePathText) -> String {
normalize_relative_path(path).to_lowercase()
}
pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool {
normalize_relative_path(path)
.split('/')
.any(|segment| segment.starts_with('.'))
}
pub(crate) fn push_unique_relative_path(
paths: &mut Vec<String>,
path: impl AsRef<RelativePathText>,
) {
let normalized = normalize_relative_path(path.as_ref());
if normalized.is_empty() || has_hidden_segment(&normalized) {
return;
}
let key = relative_path_key(&normalized);
if !paths
.iter()
.any(|existing| relative_path_key(existing) == key)
{
paths.push(normalized);
}
}
pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result<String, String> {
let vault_path = normalize_path_for_identity(&vault.to_string_lossy());
let file_path = normalize_path_for_identity(&file.to_string_lossy());
if file_path == vault_path {
return Ok(String::new());
}
let prefix = format!("{vault_path}/");
file_path
.strip_prefix(&prefix)
.map(normalize_relative_path)
.ok_or_else(|| {
format!(
"File {} is not inside vault {}",
file.display(),
vault.display()
)
})
}
pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String {
let relative = vault_relative_path_string(vault, path)
.unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy()));
relative
.strip_suffix(".md")
.unwrap_or(&relative)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() {
assert_eq!(
vault_relative_path_string(
Path::new("/private/tmp/tolaria-vault"),
Path::new("/tmp/tolaria-vault/projects\\active.md"),
)
.unwrap(),
"projects/active.md"
);
}
#[test]
fn test_relative_path_key_is_case_insensitive_without_changing_output_path() {
let mut paths = vec![];
push_unique_relative_path(&mut paths, "Projects\\Active.md");
push_unique_relative_path(&mut paths, "projects/active.md");
assert_eq!(paths, vec!["Projects/Active.md"]);
assert_eq!(
relative_path_key("Projects\\Active.md"),
"projects/active.md"
);
}
}

View File

@@ -7,6 +7,7 @@ use tempfile::NamedTempFile;
use walkdir::WalkDir;
use super::filename_rules::validate_filename_stem;
use super::path_identity::vault_relative_markdown_stem;
use super::rename_transaction::RenameWorkspace;
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
@@ -211,12 +212,7 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String {
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
fn to_path_stem(path: &Path, vault_root: &Path) -> String {
let relative = path.strip_prefix(vault_root).unwrap_or(path);
let normalized = relative.to_string_lossy().replace('\\', "/");
normalized
.strip_suffix(".md")
.unwrap_or(&normalized)
.to_string()
vault_relative_markdown_stem(path, vault_root)
}
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
@@ -750,6 +746,17 @@ mod tests {
assert_eq!(renames[0].new_path, "新名.md");
}
#[test]
fn test_path_stem_normalizes_tmp_aliases_and_separators() {
assert_eq!(
to_path_stem(
Path::new("/tmp/tolaria-vault/projects\\weekly-review.md"),
Path::new("/private/tmp/tolaria-vault")
),
"projects/weekly-review"
);
}
#[test]
fn test_rename_note_basic() {
let dir = TempDir::new().unwrap();

View File

@@ -7,7 +7,7 @@ use tauri::{
WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
const WINDOW_STATE_FILE: &str = "window-state.json";
const MIN_WINDOW_WIDTH: u32 = 480;
const MIN_WINDOW_HEIGHT: u32 = 400;

View File

@@ -34,13 +34,13 @@
"csp": {
"default-src": "'self' ipc: http://ipc.localhost",
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"connect-src": "'self' ipc: http://ipc.localhost data: ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"style-src-elem": "'self' 'nonce-tolaria-runtime-style' https://fonts.googleapis.com",
"style-src-attr": "'unsafe-inline'",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"media-src": "'self' asset: http://asset.localhost data: blob: https:",
"object-src": "'self' asset: http://asset.localhost"
},
"assetProtocol": {

View File

@@ -136,6 +136,18 @@ const mockVaultList = {
const mockDefaultVaultPath = '/Users/mock/Documents/Getting Started'
const expectedDefaultVaultPath = DEFAULT_VAULTS[0].path || mockDefaultVaultPath
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
auto_pull_interval_minutes: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
...overrides,
}
}
const mockCommandResults: Record<string, unknown> = {
load_vault_list: mockVaultList,
list_vault: mockEntries,
@@ -148,7 +160,7 @@ const mockCommandResults: Record<string, unknown> = {
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
sync_vault_asset_scope_for_window: null,
get_file_history: [],
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
get_settings: createSettings(),
is_git_repo: true,
init_git_repo: null,
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
@@ -291,15 +303,7 @@ function resetMockCommandResults() {
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
sync_vault_asset_scope_for_window: null,
get_file_history: [],
get_settings: {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
},
get_settings: createSettings({ auto_advance_inbox_after_organize: null }),
is_git_repo: true,
init_git_repo: null,
save_settings: null,
@@ -654,6 +658,39 @@ describe('App', () => {
})
})
it('shows immediate feedback while a menu-driven update check is pending', async () => {
let resolveUpdate: ((result: { kind: 'up-to-date' }) => void) | null = null
const checkForUpdates = vi.fn(() => new Promise<{ kind: 'up-to-date' }>((resolve) => {
resolveUpdate = resolve
}))
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(checkForUpdates))
render(<App />)
await waitFor(() => {
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
})
act(() => {
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
})
await waitFor(() => {
expect(screen.getByText('Checking for updates...')).toBeInTheDocument()
})
expect(checkForUpdates).toHaveBeenCalledOnce()
await act(async () => {
resolveUpdate?.({ kind: 'up-to-date' })
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
})
})
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
@@ -688,16 +725,10 @@ describe('App', () => {
})
it('routes right-panel AI chat messages to the selected default agent', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
mockCommandResults.get_settings = createSettings({
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
}
})
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
@@ -746,8 +777,6 @@ describe('App', () => {
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await act(async () => {
@@ -756,22 +785,18 @@ describe('App', () => {
expect(streamAiAgent).not.toHaveBeenCalled()
await act(async () => {
resolveSettings?.({
auto_pull_interval_minutes: null,
resolveSettings?.(createSettings({
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
})
}))
})
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
@@ -782,14 +807,7 @@ describe('App', () => {
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.get_settings = createSettings({ telemetry_consent: null })
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath
@@ -814,14 +832,7 @@ describe('App', () => {
const rememberedDefaultVaultPath = expectedDefaultVaultPath
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.get_settings = createSettings({ telemetry_consent: null })
mockCommandResults.load_vault_list = {
vaults: [],
active_vault: rememberedDefaultVaultPath,
@@ -1119,15 +1130,7 @@ describe('App', () => {
it('auto-advances to the next inbox item after organizing when the setting is enabled', async () => {
configureNeighborhoodVault()
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: true,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.get_settings = createSettings({ auto_advance_inbox_after_organize: true })
render(<App />)
@@ -1154,15 +1157,7 @@ describe('App', () => {
it('keeps the manually selected note after organizing finishes later', async () => {
configureNeighborhoodVault()
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: true,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.get_settings = createSettings({ auto_advance_inbox_after_organize: true })
let resolveOrganizeSave!: () => void
const organizeSave = new Promise<void>((resolve) => {
@@ -1348,4 +1343,34 @@ describe('App', () => {
})
})
})
it('does not ask Windows to grow the native window when toggling Properties', async () => {
const { invoke } = await import('@tauri-apps/api/core') as { invoke: ReturnType<typeof vi.fn> }
const originalUserAgent = navigator.userAgent
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
})
try {
render(<App />)
await waitFor(() => {
expect(screen.getByText('All Notes')).toBeInTheDocument()
})
invoke.mockClear()
fireEvent.keyDown(window, { key: 'I', metaKey: true, shiftKey: true })
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', expect.objectContaining({
growToFit: false,
}))
})
} finally {
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: originalUserAgent,
})
}
})
})

View File

@@ -88,6 +88,7 @@ import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { resolveAllNotesFileVisibility } from './utils/allNotesFileVisibility'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
@@ -101,6 +102,7 @@ import {
getBrowserLanguagePreferences,
resolveEffectiveLocale,
serializeUiLanguagePreference,
translate,
type UiLanguagePreference,
} from './lib/i18n'
import { normalizeReleaseChannel } from './lib/releaseChannel'
@@ -128,6 +130,14 @@ import {
import { requestPlainTextPaste } from './utils/plainTextPaste'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
function isActiveElementInsideEditorSurface(): boolean {
const activeElement = document.activeElement
if (!(activeElement instanceof HTMLElement)) return false
return Boolean(activeElement.closest(ACTIVE_EDITOR_SURFACE_SELECTOR))
}
// Type declarations for mock content storage and test overrides
declare global {
interface Window {
@@ -617,12 +627,18 @@ function App() {
useEffect(() => {
noteWindowActionsRef.current = { handleSelectNote, openTabWithContent }
}, [handleSelectNote, openTabWithContent])
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean } = {},
) => {
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
closeAllTabs,
getActiveTabPath: () => notes.activeTabPathRef.current,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
shouldKeepActiveEditorMounted: options.preserveFocusedEditor
? isActiveElementInsideEditorSurface
: undefined,
reloadFolders: vault.reloadFolders,
reloadVault: vault.reloadVault,
reloadViews: vault.reloadViews,
@@ -641,9 +657,17 @@ function App() {
vault.reloadViews,
vault.unsavedPaths,
])
const handlePulledVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
[handleVaultUpdate],
)
const handleFocusedVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, { preserveFocusedEditor: true }),
[handleVaultUpdate],
)
useVaultWatcher({
vaultPath: noteWindowParams ? '' : resolvedPath,
onVaultChanged: handlePulledVaultUpdate,
onVaultChanged: handleFocusedVaultUpdate,
filterChangedPaths: filterExternalVaultPaths,
})
const autoSync = useAutoSync({
@@ -772,6 +796,7 @@ function App() {
closeAllTabs,
replaceActiveTab: handleReplaceActiveTab,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
shouldKeepActiveEditorMounted: isActiveElementInsideEditorSurface,
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
getActiveTabPath: () => notes.activeTabPathRef.current,
@@ -1236,7 +1261,7 @@ function App() {
inspectorWidth: layout.inspectorWidth,
})
void applyMainWindowSizeConstraints(minWidth).catch((err) => console.warn('[window] Size constraints failed:', err))
void applyMainWindowSizeConstraints(minWidth, { growToFit: !isWindows() }).catch((err) => console.warn('[window] Size constraints failed:', err))
}, [layout.inspectorCollapsed, layout.inspectorWidth, layout.noteListWidth, layout.sidebarWidth, noteWindowParams])
const handleSetViewMode = useCallback((mode: ViewMode) => {
@@ -1280,6 +1305,7 @@ function App() {
await restartApp()
return
}
setToastMessage(translate(appLocale, 'update.checking'))
const result = await updateActions.checkForUpdates()
if (result.kind === 'up-to-date') {
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
@@ -1289,7 +1315,7 @@ function App() {
} else {
setToastMessage(result.message)
}
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
}, [appLocale, settings.release_channel, updateActions, updateStatus.state, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return
@@ -1691,7 +1717,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} pluralizeTypeLabels={settings.sidebar_type_pluralization_enabled ?? true} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1746,6 +1772,7 @@ function App() {
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand}
onEnterNeighborhood={activeDeletedFile ? undefined : handleEnterNeighborhood}
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onOpenExternalFile={fileActions.openExternalFile}

View File

@@ -200,6 +200,7 @@ export const AiPanelHeader = memo(function AiPanelHeader({
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4"
onClick={onNewChat}
aria-label={t('ai.panel.newChat')}
title={t('ai.panel.newChat')}
@@ -210,6 +211,7 @@ export const AiPanelHeader = memo(function AiPanelHeader({
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4"
onClick={onClose}
aria-label={t('ai.panel.close')}
title={t('ai.panel.close')}

View File

@@ -1,9 +1,12 @@
import { useState } from 'react'
import {
DEFAULT_MODEL_CAPABILITIES,
aiModelProviderCatalog,
aiModelProviderCatalogEntry,
configuredModelTargets,
isLocalAiProvider,
normalizeAiModelProviders,
type AiModelApiKeyStorage,
type AiModelProvider,
type AiModelProviderKind,
} from '../lib/aiTargets'
@@ -21,7 +24,6 @@ import {
type Translate = ReturnType<typeof createTranslator>
type ProviderMode = 'local' | 'api'
type ApiKeyStorage = 'none' | 'local_file' | 'env'
type TestState = 'idle' | 'testing' | 'success'
interface AiProviderSettingsProps {
@@ -36,67 +38,52 @@ interface ProviderDraft {
name: string
baseUrl: string
modelId: string
apiKeyStorage: ApiKeyStorage
apiKeyStorage: AiModelApiKeyStorage
apiKey: string
apiKeyEnvVar: string
}
const LOCAL_PROVIDER_KINDS: AiModelProviderKind[] = ['ollama', 'lm_studio']
const API_PROVIDER_KINDS: AiModelProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible']
const PROVIDER_PRESETS: Record<AiModelProviderKind, { name: string; baseUrl: string }> = {
ollama: { name: 'Ollama', baseUrl: 'http://localhost:11434/v1' },
lm_studio: { name: 'LM Studio', baseUrl: 'http://127.0.0.1:1234/v1' },
open_ai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
anthropic: { name: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1' },
gemini: { name: 'Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' },
open_router: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' },
open_ai_compatible: { name: 'Custom provider', baseUrl: 'https://api.example.com/v1' },
function providerKindsForMode(mode: ProviderMode): AiModelProviderKind[] {
return aiModelProviderCatalog()
.filter((entry) => entry.local === (mode === 'local'))
.map((entry) => entry.kind)
}
function initialDraft(mode: ProviderMode): ProviderDraft {
const kind = mode === 'local' ? 'ollama' : 'open_ai'
const [kind] = providerKindsForMode(mode)
if (!kind) throw new Error(`No AI model providers are configured for ${mode} mode`)
return draftFromProviderKind(kind)
}
function draftFromProviderKind(kind: AiModelProviderKind): ProviderDraft {
const defaults = aiModelProviderCatalogEntry(kind)
return {
kind,
name: PROVIDER_PRESETS[kind].name,
baseUrl: PROVIDER_PRESETS[kind].baseUrl,
name: defaults.name,
baseUrl: defaults.base_url,
modelId: '',
apiKeyStorage: mode === 'local' ? 'none' : 'local_file',
apiKeyStorage: defaults.api_key_storage,
apiKey: '',
apiKeyEnvVar: '',
apiKeyEnvVar: defaults.api_key_env_var ?? '',
}
}
function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> {
const kinds = mode === 'local' ? LOCAL_PROVIDER_KINDS : API_PROVIDER_KINDS
return kinds.map((kind) => ({ value: kind, label: providerKindLabel(kind, t) }))
return providerKindsForMode(mode).map((kind) => {
const defaults = aiModelProviderCatalogEntry(kind)
return { value: kind, label: t(defaults.label_key) }
})
}
function providerKindLabel(kind: AiModelProviderKind, t: Translate): string {
function providerPresetPatch(kind: AiModelProviderKind): Pick<ProviderDraft, 'kind' | 'name' | 'baseUrl' | 'apiKeyStorage' | 'apiKeyEnvVar'> {
const defaults = draftFromProviderKind(kind)
return {
ollama: t('settings.aiProviders.kind.ollama'),
lm_studio: t('settings.aiProviders.kind.lmStudio'),
open_ai: t('settings.aiProviders.kind.openAi'),
anthropic: t('settings.aiProviders.kind.anthropic'),
gemini: t('settings.aiProviders.kind.gemini'),
open_router: t('settings.aiProviders.kind.openRouter'),
open_ai_compatible: t('settings.aiProviders.kind.compatible'),
}[kind]
}
function modelPlaceholder(kind: AiModelProviderKind, mode: ProviderMode): string {
if (mode === 'local') return 'llama3.2'
if (kind === 'anthropic') return 'claude-3-5-sonnet-latest'
if (kind === 'gemini') return 'gemini-2.5-flash'
if (kind === 'open_router') return 'openai/gpt-4.1-mini'
return 'gpt-4.1-mini'
}
function apiKeyEnvPlaceholder(kind: AiModelProviderKind): string {
if (kind === 'anthropic') return 'ANTHROPIC_API_KEY'
if (kind === 'gemini') return 'GEMINI_API_KEY'
if (kind === 'open_router') return 'OPENROUTER_API_KEY'
return 'OPENAI_API_KEY'
kind,
name: defaults.name,
baseUrl: defaults.baseUrl,
apiKeyStorage: defaults.apiKeyStorage,
apiKeyEnvVar: defaults.apiKeyEnvVar,
}
}
function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider {
@@ -210,7 +197,7 @@ function ApiKeyStorageFields({
<>
<label className="space-y-1.5 text-xs font-medium text-foreground">
<span>{t('settings.aiProviders.keyStorage')}</span>
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as ApiKeyStorage })}>
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as AiModelApiKeyStorage })}>
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
<SelectValue />
</SelectTrigger>
@@ -235,7 +222,7 @@ function ApiKeyStorageFields({
label={t('settings.aiProviders.keyEnv')}
value={draft.apiKeyEnvVar}
onChange={(apiKeyEnvVar) => updateDraft({ apiKeyEnvVar })}
placeholder={apiKeyEnvPlaceholder(draft.kind)}
placeholder={aiModelProviderCatalogEntry(draft.kind).api_key_env_var ?? ''}
/>
) : null}
</>
@@ -290,7 +277,7 @@ export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderS
resetTest()
updateDraft(patch)
}
const updateKind = (kind: AiModelProviderKind) => updateForm({ kind, ...PROVIDER_PRESETS[kind] })
const updateKind = (kind: AiModelProviderKind) => updateForm(providerPresetPatch(kind))
const canSave = draft.name.trim() && draft.modelId.trim() && (draft.apiKeyStorage !== 'local_file' || draft.apiKey.trim())
const apiKeyOverride = draft.apiKeyStorage === 'local_file' ? draft.apiKey : null
@@ -302,7 +289,7 @@ export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderS
await saveAiModelProviderApiKey(providerId, draft.apiKey)
}
onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)]))
setDraft((current) => ({ ...initialDraft(mode), kind: current.kind, name: current.name, baseUrl: current.baseUrl }))
setDraft((current) => ({ ...draftFromProviderKind(current.kind), name: current.name, baseUrl: current.baseUrl }))
setTestState('idle')
} catch (error) {
setError(error instanceof Error ? error.message : String(error))
@@ -335,7 +322,7 @@ export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderS
<ProviderKindSelect mode={mode} t={t} value={draft.kind} onChange={updateKind} />
<LabeledInput label={t('settings.aiProviders.name')} value={draft.name} onChange={(name) => updateForm({ name })} />
<LabeledInput label={t('settings.aiProviders.baseUrl')} value={draft.baseUrl} onChange={(baseUrl) => updateForm({ baseUrl })} />
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={modelPlaceholder(draft.kind, mode)} />
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={aiModelProviderCatalogEntry(draft.kind).default_model_id} />
{mode === 'api' ? <ApiKeyStorageFields t={t} draft={draft} updateDraft={updateForm} /> : null}
</div>
<div className="text-xs leading-5 text-muted-foreground">

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent, act, within } from '@testing-library/react'
import { render, screen, fireEvent, act, within, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
@@ -69,6 +69,38 @@ async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
})
}
async function openOverflowMenu() {
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
button: 0,
ctrlKey: false,
})
return screen.findByRole('menu')
}
function mockCollapsedBreadcrumbOverflow() {
const requestFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 1
})
const cancelFrame = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
const rects = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
if (this.classList.contains('breadcrumb-bar__actions')) {
return DOMRect.fromRect({ x: 200, y: 0, width: 20, height: 52 })
}
return DOMRect.fromRect({ x: 0, y: 0, width: 500, height: 52 })
})
const scrollWidths = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function () {
return this.classList.contains('breadcrumb-bar__actions') ? 400 : 500
})
return () => {
requestFrame.mockRestore()
cancelFrame.mockRestore()
rects.mockRestore()
scrollWidths.mockRestore()
}
}
describe('BreadcrumbBar — drag region', () => {
it('forwards mousedown events to the shared drag-region hook', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
@@ -94,43 +126,49 @@ describe('BreadcrumbBar — drag region', () => {
})
describe('BreadcrumbBar — delete', () => {
it('shows delete button', () => {
it('shows delete in the overflow menu', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
const menu = await openOverflowMenu()
expect(within(menu).getByRole('menuitem', { name: 'Delete this note' })).toBeInTheDocument()
})
it('calls onDelete when delete button is clicked', () => {
it('calls onDelete from the overflow menu', async () => {
const onDelete = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={onDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete this note' }))
const menu = await openOverflowMenu()
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Delete this note' }))
expect(onDelete).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — archive/unarchive', () => {
it('shows archive button for non-archived note', () => {
it('shows archive in the overflow menu for non-archived note', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Archive this note' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Restore this archived note' })).not.toBeInTheDocument()
const menu = await openOverflowMenu()
expect(within(menu).getByRole('menuitem', { name: 'Archive this note' })).toBeInTheDocument()
expect(within(menu).queryByRole('menuitem', { name: 'Restore this archived note' })).not.toBeInTheDocument()
})
it('shows unarchive button for archived note', () => {
it('shows unarchive in the overflow menu for archived note', async () => {
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Restore this archived note' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Archive this note' })).not.toBeInTheDocument()
const menu = await openOverflowMenu()
expect(within(menu).getByRole('menuitem', { name: 'Restore this archived note' })).toBeInTheDocument()
expect(within(menu).queryByRole('menuitem', { name: 'Archive this note' })).not.toBeInTheDocument()
})
it('calls onArchive when archive button is clicked', () => {
it('calls onArchive from the overflow menu', async () => {
const onArchive = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
fireEvent.click(screen.getByRole('button', { name: 'Archive this note' }))
const menu = await openOverflowMenu()
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Archive this note' }))
expect(onArchive).toHaveBeenCalledOnce()
})
it('calls onUnarchive when unarchive button is clicked', () => {
it('calls onUnarchive from the overflow menu', async () => {
const onUnarchive = vi.fn()
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByRole('button', { name: 'Restore this archived note' }))
const menu = await openOverflowMenu()
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Restore this archived note' }))
expect(onUnarchive).toHaveBeenCalledOnce()
})
})
@@ -171,6 +209,23 @@ describe('BreadcrumbBar — organized shortcut hint', () => {
})
})
describe('BreadcrumbBar — neighborhood action', () => {
it("opens the current note's neighborhood from the map button", () => {
const onEnterNeighborhood = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onEnterNeighborhood={onEnterNeighborhood} />)
fireEvent.click(screen.getByRole('button', { name: "Open note's neighborhood" }))
expect(onEnterNeighborhood).toHaveBeenCalledWith(baseEntry)
})
it('uses the requested neighborhood tooltip copy', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onEnterNeighborhood={vi.fn()} />)
await expectTooltip(screen.getByRole('button', { name: "Open note's neighborhood" }), "Open note's neighborhood")
})
})
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
it('always renders title elements in the DOM', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
@@ -339,33 +394,85 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
expect(screen.queryByRole('button', { name: 'More note actions are coming soon' })).not.toBeInTheDocument()
})
it('exposes lower-priority actions through the overflow menu', async () => {
it('keeps git diff first while placing archive and delete at the bottom', async () => {
const restoreMeasurement = mockCollapsedBreadcrumbOverflow()
try {
const { container } = render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
showDiffToggle
noteWidth="normal"
onToggleNoteWidth={vi.fn()}
onRevealFile={vi.fn()}
onCopyFilePath={vi.fn()}
onArchive={vi.fn()}
onDelete={vi.fn()}
/>,
)
await waitFor(() => {
expect(container.querySelector('.breadcrumb-bar__actions')).toHaveAttribute('data-overflow-collapsed', 'true')
})
const menu = await openOverflowMenu()
const menuLabels = within(menu).getAllByRole('menuitem').map((item) => item.textContent)
expect(menuLabels[0]).toBe('Git diff')
expect(menuLabels.slice(-2)).toEqual(['Archive this note', 'Delete this note'])
} finally {
restoreMeasurement()
}
})
it('does not duplicate visible lower-priority toolbar actions in the permanent overflow menu', async () => {
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
showDiffToggle
noteWidth="normal"
onToggleNoteWidth={vi.fn()}
onRevealFile={vi.fn()}
onCopyFilePath={vi.fn()}
onArchive={vi.fn()}
onDelete={vi.fn()}
onEnterNeighborhood={vi.fn()}
/>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
button: 0,
ctrlKey: false,
})
const menu = await openOverflowMenu()
expect(within(menu).queryByRole('menuitem', { name: 'Switch to wide note width' })).not.toBeInTheDocument()
expect(within(menu).queryByRole('menuitem', { name: 'Reveal in Finder' })).not.toBeInTheDocument()
expect(within(menu).queryByRole('menuitem', { name: 'Copy file path' })).not.toBeInTheDocument()
expect(within(menu).queryByRole('menuitem', { name: "Open note's neighborhood" })).not.toBeInTheDocument()
})
const menu = await screen.findByRole('menu')
expect(within(menu).getByRole('menuitem', { name: 'Show the current diff' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Switch to wide note width' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Reveal in Finder' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Copy file path' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Archive this note' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Delete this note' })).toBeInTheDocument()
it('exposes lower-priority actions when overflow hides their toolbar buttons', async () => {
const restoreMeasurement = mockCollapsedBreadcrumbOverflow()
try {
const { container } = render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
noteWidth="normal"
onToggleNoteWidth={vi.fn()}
onRevealFile={vi.fn()}
onCopyFilePath={vi.fn()}
onEnterNeighborhood={vi.fn()}
/>,
)
await waitFor(() => {
expect(container.querySelector('.breadcrumb-bar__actions')).toHaveAttribute('data-overflow-collapsed', 'true')
})
const menu = await openOverflowMenu()
expect(within(menu).getByRole('menuitem', { name: 'Switch to wide note width' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Reveal in Finder' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Copy file path' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: "Open note's neighborhood" })).toBeInTheDocument()
} finally {
restoreMeasurement()
}
})
})
@@ -425,3 +532,59 @@ describe('BreadcrumbBar — note width toggle', () => {
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — table of contents toggle', () => {
it('shows the table of contents action and calls the toggle handler', () => {
const onToggleTableOfContents = vi.fn()
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
onToggleTableOfContents={onToggleTableOfContents}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
})
it('uses the close label while the table of contents panel is active', () => {
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
showTableOfContents
onToggleTableOfContents={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Close table of contents' })).toBeInTheDocument()
})
it('offers the table of contents action from the overflow menu', async () => {
const onToggleTableOfContents = vi.fn()
const restoreMeasurement = mockCollapsedBreadcrumbOverflow()
try {
const { container } = render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
onToggleTableOfContents={onToggleTableOfContents}
/>,
)
await waitFor(() => {
expect(container.querySelector('.breadcrumb-bar__actions')).toHaveAttribute('data-overflow-collapsed', 'true')
})
const menu = await openOverflowMenu()
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open table of contents' }))
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
} finally {
restoreMeasurement()
}
})
})

View File

@@ -17,12 +17,14 @@ import {
GitBranch,
Code,
Sparkle,
ListBullets,
SidebarSimple,
Trash,
Archive,
ArrowUUpLeft,
ClipboardText,
FolderOpen,
MapTrifold,
Star,
CheckCircle,
ArrowsClockwise,
@@ -46,6 +48,8 @@ interface BreadcrumbBarProps {
forceRawMode?: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
showTableOfContents?: boolean
onToggleTableOfContents?: () => void
inspectorCollapsed?: boolean
onToggleInspector?: () => void
onToggleFavorite?: () => void
@@ -55,6 +59,7 @@ interface BreadcrumbBarProps {
onDelete?: () => void
onArchive?: () => void
onUnarchive?: () => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
@@ -64,7 +69,6 @@ interface BreadcrumbBarProps {
loadingTitle?: boolean
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
const TITLE_ACTION_GAP_PX = 24
@@ -307,31 +311,20 @@ function OrganizedAction({
return <ConfiguredToggleAction active={organized} config={TOGGLE_ACTION_CONFIGS.organized} locale={locale} onClick={onToggleOrganized} />
}
function DiffAction({
showDiffToggle,
diffMode,
diffLoading,
function NeighborhoodAction({
entry,
locale = 'en',
onToggleDiff,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'locale' | 'onToggleDiff'>) {
if (!showDiffToggle) {
return (
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.noDiff') }} style={DISABLED_ICON_STYLE}>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
onEnterNeighborhood,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onEnterNeighborhood'>) {
if (!onEnterNeighborhood) return null
const copy: ActionTooltipCopy = diffLoading
? { label: translate(locale, 'editor.toolbar.loadingDiff') }
: { label: translate(locale, diffMode ? 'editor.toolbar.rawReturn' : 'editor.toolbar.showDiff') }
return (
<IconActionButton
copy={copy}
onClick={onToggleDiff}
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
copy={{ label: translate(locale, 'editor.toolbar.openNeighborhood') }}
onClick={() => onEnterNeighborhood(entry)}
className="hover:text-foreground"
>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
<MapTrifold size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -351,38 +344,20 @@ function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<Breadc
)
}
function ArchiveAction({
archived,
function TableOfContentsAction({
showTableOfContents,
locale = 'en',
onArchive,
onUnarchive,
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'locale' | 'onArchive' | 'onUnarchive'>) {
if (archived) {
return (
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.restoreArchived') }} onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
onToggleTableOfContents,
}: Pick<BreadcrumbBarProps, 'showTableOfContents' | 'locale' | 'onToggleTableOfContents'>) {
if (!onToggleTableOfContents) return null
return (
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.archive') }} onClick={onArchive} className="hover:text-foreground">
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
function DeleteAction({ locale = 'en', onDelete }: Pick<BreadcrumbBarProps, 'locale' | 'onDelete'>) {
return (
<IconActionButton
copy={{
label: translate(locale, 'editor.toolbar.delete'),
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
}}
onClick={onDelete}
className="hover:text-destructive"
copy={{ label: translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents') }}
onClick={onToggleTableOfContents}
className={cn(showTableOfContents ? 'text-foreground' : 'hover:text-foreground')}
>
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
<ListBullets size={16} weight={showTableOfContents ? 'bold' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -444,17 +419,6 @@ function OverflowToolbarAction({ children }: { children: ReactNode }) {
return <span className="breadcrumb-bar__overflowable-action flex items-center gap-2">{children}</span>
}
function diffMenuLabelKey({
showDiffToggle,
diffMode,
diffLoading,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading'>): Parameters<typeof translate>[1] {
if (diffLoading) return 'editor.toolbar.loadingDiff'
if (diffMode) return 'editor.toolbar.rawReturn'
if (showDiffToggle) return 'editor.toolbar.showDiff'
return 'editor.toolbar.noDiff'
}
function availableDiffAction(showDiffToggle: boolean, onToggleDiff: () => void): (() => void) | undefined {
return showDiffToggle ? onToggleDiff : undefined
}
@@ -487,6 +451,13 @@ function ArchiveMenuIcon({ archived }: { archived: boolean }) {
return archived ? <ArrowUUpLeft size={16} /> : <Archive size={16} />
}
function neighborhoodAction(
entry: VaultEntry,
onEnterNeighborhood?: (entry: VaultEntry) => void,
): (() => void) | undefined {
return onEnterNeighborhood ? () => onEnterNeighborhood(entry) : undefined
}
function measureExpandedActionsWidth(
actions: HTMLDivElement,
collapsed: boolean,
@@ -786,8 +757,6 @@ function BreadcrumbTitleSkeleton() {
function BreadcrumbActions({
entry,
showDiffToggle,
diffMode,
diffLoading,
onToggleDiff,
rawMode,
onToggleRaw,
@@ -796,6 +765,8 @@ function BreadcrumbActions({
onToggleNoteWidth,
showAIChat,
onToggleAIChat,
showTableOfContents,
onToggleTableOfContents,
inspectorCollapsed,
onToggleInspector,
onToggleFavorite,
@@ -805,6 +776,7 @@ function BreadcrumbActions({
onDelete,
onArchive,
onUnarchive,
onEnterNeighborhood,
actionsRef,
overflowCollapsed,
locale = 'en',
@@ -822,41 +794,38 @@ function BreadcrumbActions({
<FavoriteAction favorite={entry.favorite} locale={locale} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} locale={locale} onToggleOrganized={onToggleOrganized} />
<OverflowToolbarAction>
<DiffAction
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
locale={locale}
/>
<NeighborhoodAction entry={entry} locale={locale} onEnterNeighborhood={onEnterNeighborhood} />
</OverflowToolbarAction>
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<OverflowToolbarAction>
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
</OverflowToolbarAction>
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<OverflowToolbarAction>
<TableOfContentsAction
showTableOfContents={showTableOfContents}
locale={locale}
onToggleTableOfContents={onToggleTableOfContents}
/>
</OverflowToolbarAction>
<OverflowToolbarAction>
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
</OverflowToolbarAction>
<OverflowToolbarAction>
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
</OverflowToolbarAction>
<OverflowToolbarAction>
<DeleteAction locale={locale} onDelete={onDelete} />
</OverflowToolbarAction>
<BreadcrumbOverflowMenu
entry={entry}
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
showTableOfContents={showTableOfContents}
onToggleTableOfContents={onToggleTableOfContents}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onArchive={onArchive}
onUnarchive={onUnarchive}
onDelete={onDelete}
onEnterNeighborhood={onEnterNeighborhood}
showResponsiveActions={overflowCollapsed}
locale={locale}
/>
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />
@@ -867,40 +836,48 @@ function BreadcrumbActions({
function BreadcrumbOverflowMenu({
entry,
showDiffToggle,
diffMode,
diffLoading,
onToggleDiff,
noteWidth,
onToggleNoteWidth,
showTableOfContents,
onToggleTableOfContents,
onRevealFile,
onCopyFilePath,
onArchive,
onUnarchive,
onDelete,
onEnterNeighborhood,
showResponsiveActions,
locale = 'en',
}: Pick<
BreadcrumbBarProps,
| 'entry'
| 'showDiffToggle'
| 'diffMode'
| 'diffLoading'
| 'onToggleDiff'
| 'noteWidth'
| 'onToggleNoteWidth'
| 'showTableOfContents'
| 'onToggleTableOfContents'
| 'onRevealFile'
| 'onCopyFilePath'
| 'onArchive'
| 'onUnarchive'
| 'onDelete'
| 'onEnterNeighborhood'
| 'locale'
>) {
> & {
showResponsiveActions: boolean
}) {
const runDiffAction = availableDiffAction(showDiffToggle, onToggleDiff)
const runRevealAction = pathAction(onRevealFile, entry.path)
const runCopyPathAction = pathAction(onCopyFilePath, entry.path)
const runArchiveAction = archiveAction(entry.archived, onArchive, onUnarchive)
const diffLabel = translate(locale, diffMenuLabelKey({ showDiffToggle, diffMode, diffLoading }))
const runNeighborhoodAction = neighborhoodAction(entry, onEnterNeighborhood)
const diffLabel = translate(locale, 'editor.toolbar.gitDiff')
const noteWidthLabel = translate(locale, noteWidthLabelKey(noteWidth))
const archiveLabel = translate(locale, archiveLabelKey(entry.archived))
const tableOfContentsLabel = translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents')
const neighborhoodLabel = translate(locale, 'editor.toolbar.openNeighborhood')
return (
<DropdownMenu>
@@ -923,18 +900,30 @@ function BreadcrumbOverflowMenu({
<GitBranch size={16} />
{diffLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!onToggleNoteWidth} onSelect={onToggleNoteWidth}>
<NoteWidthMenuIcon noteWidth={noteWidth} />
{noteWidthLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runRevealAction} onSelect={runRevealAction}>
<FolderOpen size={16} />
{translate(locale, 'editor.toolbar.revealFile')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runCopyPathAction} onSelect={runCopyPathAction}>
<ClipboardText size={16} />
{translate(locale, 'editor.toolbar.copyFilePath')}
</DropdownMenuItem>
{showResponsiveActions && (
<>
<DropdownMenuItem disabled={!runNeighborhoodAction} onSelect={runNeighborhoodAction}>
<MapTrifold size={16} />
{neighborhoodLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!onToggleNoteWidth} onSelect={onToggleNoteWidth}>
<NoteWidthMenuIcon noteWidth={noteWidth} />
{noteWidthLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!onToggleTableOfContents} onSelect={onToggleTableOfContents}>
<ListBullets size={16} />
{tableOfContentsLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runRevealAction} onSelect={runRevealAction}>
<FolderOpen size={16} />
{translate(locale, 'editor.toolbar.revealFile')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runCopyPathAction} onSelect={runCopyPathAction}>
<ClipboardText size={16} />
{translate(locale, 'editor.toolbar.copyFilePath')}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem disabled={!runArchiveAction} onSelect={runArchiveAction}>
<ArchiveMenuIcon archived={entry.archived} />
{archiveLabel}

View File

@@ -75,14 +75,14 @@ describe('BreadcrumbBar filename visibility', () => {
expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;')
})
it('moves lower-priority breadcrumb actions into the overflow menu from measured overflow state', () => {
it('keeps a permanent overflow menu while moving lower-priority actions from measured overflow state', () => {
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
expect(editorCss).not.toContain('@container (max-width:')
expect(editorCss).toContain('.breadcrumb-bar__overflow-menu')
expect(editorCss).toContain('display: flex;')
expect(editorCss).toContain(".breadcrumb-bar__actions[data-overflow-collapsed='true']")
expect(editorCss).toContain('.breadcrumb-bar__overflowable-action')
expect(editorCss).toContain('display: none;')
expect(editorCss).toContain('.breadcrumb-bar__overflow-menu')
expect(editorCss).toContain('display: flex;')
})
})

View File

@@ -114,7 +114,7 @@ function updateAiInput(text: string) {
editor.textContent = text
setSelection(editor, text.length)
fireEvent.input(editor)
return editor
return screen.queryByTestId('command-palette-ai-input') ?? editor
}
function resetNativeDropState() {

View File

@@ -188,6 +188,32 @@ describe('DynamicPropertiesPanel', () => {
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-03')
})
it('shows missing type-defined properties as gray editable placeholders', () => {
const typeEntry = makeEntry({
title: 'Book',
isA: 'Type',
properties: {
'start date': null,
},
})
renderPanel({
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
entries: [typeEntry],
frontmatter: {},
onUpdateProperty,
})
const placeholder = screen.getByTestId('type-derived-property')
expect(within(placeholder).getByText('Start date')).toHaveClass('text-muted-foreground/40')
fireEvent.click(placeholder)
const input = screen.getByDisplayValue('')
fireEvent.change(input, { target: { value: '2026-05-04' } })
fireEvent.blur(input)
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-04')
})
it('hides Owner with wikilink value from Properties panel', () => {
renderPanel({ frontmatter: { Owner: '[[person/luca]]' } })
// Owner with wikilink goes to RelationshipsPanel, not Properties

View File

@@ -63,7 +63,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)}
</span>
<div className="min-w-0">
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} locale={locale} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
</div>
)
@@ -153,6 +153,83 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: {
)
}
function TypeDerivedPropertySlot({
propKey,
editingKey,
displayMode,
autoMode,
vaultStatuses,
vaultTags,
onStartEdit,
onSave,
onSaveList,
onUpdate,
onDisplayModeChange,
locale,
}: {
propKey: string
editingKey: string | null
displayMode: PropertyDisplayMode
autoMode: PropertyDisplayMode
vaultStatuses: string[]
vaultTags: string[]
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
if (editingKey === propKey) {
return (
<PropertyRow
propKey={propKey}
value=""
editingKey={editingKey}
displayMode={displayMode}
autoMode={autoMode}
vaultStatuses={vaultStatuses}
vaultTags={vaultTags}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
}
const PlaceholderIcon = DISPLAY_MODE_ICONS[displayMode]
return (
<Button
type="button"
variant="ghost"
size="sm"
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
style={PROPERTY_PANEL_ROW_STYLE}
onClick={() => onStartEdit(propKey)}
disabled={!onUpdate}
data-testid="type-derived-property"
>
<span className={PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="type-derived-property-icon-slot"
>
<PlaceholderIcon
className="size-3.5 shrink-0 text-muted-foreground/40"
data-testid={`type-derived-property-icon-${displayMode}`}
/>
</span>
<span className="min-w-0 truncate text-muted-foreground/40">{humanizePropertyKey(propKey)}</span>
</span>
<span className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME}>{'\u2014'}</span>
</Button>
)
}
function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set<string> {
const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase()))
for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase())
@@ -229,6 +306,139 @@ function useSuggestedPropertyActions({
}
}
function PropertyEntryRows({
source,
entries,
editingKey,
displayOverrides,
vaultStatuses,
vaultTagsByKey,
locale,
onStartEdit,
onSave,
onSaveList,
onUpdate,
onDelete,
onDisplayModeChange,
}: {
source: 'frontmatter' | 'type-derived'
entries: [string, FrontmatterValue][]
editingKey: string | null
displayOverrides: Record<string, PropertyDisplayMode>
vaultStatuses: string[]
vaultTagsByKey: Record<string, string[]>
locale: AppLocale
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void
onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
return (
<>
{entries.map(([key, value]) => (
source === 'type-derived' ? (
<TypeDerivedPropertySlot
key={`type-derived:${key}`}
propKey={key}
editingKey={editingKey}
displayMode={getEffectiveDisplayMode(key, value, displayOverrides)}
autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
) : (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={onStartEdit} onSave={onSave}
onSaveList={onSaveList} onUpdate={onUpdate}
onDelete={onDelete}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
))}
</>
)
}
function PendingSuggestedPropertyRow({
pendingSuggestedKey,
editingKey,
vaultStatuses,
vaultTagsByKey,
locale,
onStartEdit,
onSave,
onSaveList,
onDisplayModeChange,
}: {
pendingSuggestedKey: string | null
editingKey: string | null
vaultStatuses: string[]
vaultTagsByKey: Record<string, string[]>
locale: AppLocale
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
if (!pendingSuggestedKey || editingKey !== pendingSuggestedKey) {
return null
}
return (
<PropertyRow
key={`pending:${pendingSuggestedKey}`}
propKey={pendingSuggestedKey}
value=""
editingKey={editingKey}
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
}
function SuggestedPropertyRows({
properties,
onAdd,
}: {
properties: Array<{ key: string; label: string }>
onAdd: (key: string) => void
}) {
return (
<>
{properties.map(({ key, label }) => (
<SuggestedPropertySlot
key={key}
label={label}
displayMode={getSuggestedDisplayMode(key)}
onAdd={() => onAdd(key)}
/>
))}
</>
)
}
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
@@ -248,12 +458,15 @@ export function DynamicPropertiesPanel({
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
typeDerivedPropertyEntries, handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, availableTypes), [entry.isA, availableTypes])
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
const existingKeys = useMemo(
() => getExistingPropertyKeys([...propertyEntries, ...typeDerivedPropertyEntries], frontmatter),
[propertyEntries, typeDerivedPropertyEntries, frontmatter],
)
const missingSuggested = useMemo(
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
[existingKeys, onAddProperty, pendingSuggestedKey],
@@ -285,46 +498,47 @@ export function DynamicPropertiesPanel({
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
))}
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
<PropertyRow
key={`pending:${pendingSuggestedKey}`}
propKey={pendingSuggestedKey}
value=""
editingKey={editingKey}
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
onStartEdit={handlePendingSuggestedEdit}
onSave={handleSaveSuggestedValue}
onSaveList={handleSaveList}
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
)}
{missingSuggested.map(({ key, label }) => (
<SuggestedPropertySlot
key={key}
label={label}
displayMode={getSuggestedDisplayMode(key)}
onAdd={() => handleSuggestedAdd(key)}
/>
))}
<PropertyEntryRows
source="frontmatter"
entries={propertyEntries}
editingKey={editingKey}
displayOverrides={displayOverrides}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={setEditingKey}
onSave={handleSaveValue}
onSaveList={handleSaveList}
onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
<PropertyEntryRows
source="type-derived"
entries={typeDerivedPropertyEntries}
editingKey={editingKey}
displayOverrides={displayOverrides}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={setEditingKey}
onSave={handleSaveTypeDerivedValue}
onSaveList={handleSaveList}
onUpdate={onUpdateProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
<PendingSuggestedPropertyRow
pendingSuggestedKey={pendingSuggestedKey}
editingKey={editingKey}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={handlePendingSuggestedEdit}
onSave={handleSaveSuggestedValue}
onSaveList={handleSaveList}
onDisplayModeChange={handleDisplayModeChange}
/>
<SuggestedPropertyRows properties={missingSuggested} onAdd={handleSuggestedAdd} />
{!showAddDialog && (
<AddPropertyButton
locale={locale}

View File

@@ -55,17 +55,13 @@
}
.breadcrumb-bar__overflow-menu {
display: none;
display: flex;
}
.breadcrumb-bar__actions[data-overflow-collapsed='true'] .breadcrumb-bar__overflowable-action {
display: none;
}
.breadcrumb-bar__actions[data-overflow-collapsed='true'] .breadcrumb-bar__overflow-menu {
display: flex;
}
/* Scroll area wrapping title + editor — single scroll context for alignment */
.editor-scroll-area {
flex: 1;

View File

@@ -1,4 +1,4 @@
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act, within } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
@@ -236,6 +236,7 @@ describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
blockNoteViewState.onChange = null
mockEditor.document = [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }]
clearParsedNoteBlockCache()
})
@@ -453,14 +454,18 @@ describe('Editor', () => {
expect(editable).toHaveAttribute('autocapitalize', 'off')
})
it('renders breadcrumb bar with action buttons', () => {
it('renders breadcrumb bar with action buttons', async () => {
renderEditor({
tabs: [mockTab],
activeTabPath: mockEntry.path,
})
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
button: 0,
ctrlKey: false,
})
expect(within(await screen.findByRole('menu')).getByRole('menuitem', { name: 'Delete this note' })).toBeInTheDocument()
})
it('keeps editor chrome visible while active note content is loading', () => {
@@ -510,7 +515,7 @@ describe('Editor', () => {
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
})
it('renders diff toggle button when file is modified', () => {
it('renders git diff in the breadcrumb overflow menu when file is modified', async () => {
render(
<Editor
{...defaultProps}
@@ -520,8 +525,11 @@ describe('Editor', () => {
onLoadDiff={async () => '+ added line'}
/>
)
const diffBtn = screen.getByRole('button', { name: 'Show the current diff' })
expect(diffBtn).toBeInTheDocument()
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
button: 0,
ctrlKey: false,
})
expect(within(await screen.findByRole('menu')).getByRole('menuitem', { name: 'Git diff' })).toBeInTheDocument()
})
it('includes inspector panel', () => {
@@ -537,6 +545,27 @@ describe('Editor', () => {
expect(screen.getAllByText('Properties').length).toBeGreaterThan(0)
})
it('renders the table of contents panel from the active note content', async () => {
mockEditor.document = [
{ id: 'toc-heading', type: 'heading', content: [{ type: 'text', text: 'Table Heading' }], props: { level: 2 }, children: [] },
]
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
inspectorEntry={mockEntry}
inspectorContent={`${mockContent}\n\n## Table Heading`}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
})
// Regression: editor content did not appear on first load because BlockNote's
// replaceBlocks/insertBlocks internally calls flushSync, which fails silently
// when invoked from within React's useEffect. Fix: defer via queueMicrotask.

View File

@@ -3,7 +3,7 @@ import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import 'katex/dist/katex.min.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import { clipboardImageFiles, uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import type { AiTarget } from '../lib/aiTargets'
import { translate, type AppLocale } from '../lib/i18n'
@@ -21,6 +21,7 @@ import { EditorContent } from './EditorContent'
import { EditorMemoryProbe } from './EditorMemoryProbe'
import { FilePreview } from './FilePreview'
import { schema } from './editorSchema'
import { useRightPanelExclusion } from './useRightPanelExclusion'
import type { RawEditorFindRequest } from './RawEditorFindBar'
import {
applyPendingRawExitContent,
@@ -30,6 +31,7 @@ import {
import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createImeCompositionKeyGuardExtension } from './imeCompositionKeyGuardExtension'
import { createMathInputExtension } from './mathInputExtension'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
import './Editor.css'
@@ -77,6 +79,7 @@ interface EditorProps {
noteListFilter?: { type: string | null; query: string }
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
@@ -185,6 +188,40 @@ interface EditorSetupParams {
diffToggleRef?: React.MutableRefObject<() => void>
}
function queueClipboardImagePasteUploads(
editor: ReturnType<typeof useCreateBlockNote>,
files: File[],
vaultPath?: string,
) {
const referenceBlock = editor.getTextCursorPosition().block
for (const file of files) {
const insertedBlock = editor.insertBlocks([
{ type: 'image' as const, props: { name: file.name } },
], referenceBlock, 'after')[0]
void uploadImageFile(file, vaultPath)
.then((url) => {
editor.updateBlock(insertedBlock.id, { props: { name: file.name, url } })
})
.catch((error) => {
console.warn('[editor] Failed to paste clipboard image:', error)
})
}
}
function handleClipboardImagePaste(
event: ClipboardEvent,
editor: ReturnType<typeof useCreateBlockNote>,
vaultPath?: string,
): boolean {
const files = clipboardImageFiles(event.clipboardData)
if (files.length === 0) return false
queueClipboardImagePasteUploads(editor, files, vaultPath)
return true
}
function useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff, onLoadDiffAtCommit, pendingCommitDiffRequest, onPendingCommitDiffHandled, getNoteStatus,
@@ -197,8 +234,17 @@ function useEditorSetup({
const editor = useCreateBlockNote({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
pasteHandler: ({ event, editor, defaultPasteHandler }) => {
if (handleClipboardImagePaste(event, editor, vaultPathRef.current)) return true
return defaultPasteHandler()
},
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
extensions: [
createImeCompositionKeyGuardExtension(),
createArrowLigaturesExtension(),
createMathInputExtension(),
],
})
useFilenameAutolinkGuard(editor)
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
@@ -323,12 +369,15 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
showTableOfContents,
onToggleTableOfContents,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onEnterNeighborhood,
onRevealFile,
onCopyFilePath,
onOpenExternalFile,
@@ -388,12 +437,15 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
showTableOfContents?: boolean
onToggleTableOfContents?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
@@ -470,12 +522,15 @@ function EditorLayout({
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
showTableOfContents={showTableOfContents}
onToggleTableOfContents={onToggleTableOfContents}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}
onEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onEnterNeighborhood={onEnterNeighborhood}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onDeleteNote={onDeleteNote}
@@ -494,11 +549,13 @@ function EditorLayout({
locale={locale}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
{(showAIChat || showTableOfContents || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
<EditorRightPanel
showAIChat={showAIChat}
showTableOfContents={showTableOfContents}
inspectorCollapsed={inspectorCollapsed}
inspectorWidth={inspectorWidth}
editor={editor}
defaultAiAgent={defaultAiAgent}
defaultAiTarget={defaultAiTarget}
defaultAiAgentReadiness={defaultAiAgentReadiness}
@@ -513,6 +570,7 @@ function EditorLayout({
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onToggleTableOfContents={onToggleTableOfContents}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
@@ -581,5 +639,16 @@ export const Editor = memo(function Editor(props: EditorProps) {
onContentChange: props.onContentChange,
flushPendingRawContentRef: props.flushPendingRawContentRef,
})
return <EditorLayout {...buildEditorLayoutProps(props, runtime, findRequest)} />
const rightPanel = useRightPanelExclusion(props)
return (
<EditorLayout
{...buildEditorLayoutProps(props, runtime, findRequest)}
onToggleInspector={rightPanel.handleToggleInspectorPanel}
showAIChat={props.showAIChat}
onToggleAIChat={rightPanel.handleToggleAIChatPanel}
showTableOfContents={rightPanel.showTableOfContents}
onToggleTableOfContents={rightPanel.handleToggleTableOfContents}
/>
)
})

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import type { AiTarget } from '../lib/aiTargets'
import type { AppLocale } from '../lib/i18n'
@@ -8,11 +9,14 @@ import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanelView } from './AiPanel'
import { useAiPanelController } from './useAiPanelController'
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
import { TableOfContentsPanel } from './TableOfContentsPanel'
interface EditorRightPanelProps {
showAIChat?: boolean
showTableOfContents?: boolean
inspectorCollapsed: boolean
inspectorWidth: number
editor: ReturnType<typeof useCreateBlockNote>
defaultAiAgent?: AiAgentId
defaultAiTarget?: AiTarget
defaultAiAgentReadiness?: AiAgentReadiness
@@ -27,6 +31,7 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -44,12 +49,13 @@ interface EditorRightPanelProps {
}
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
showAIChat, showTableOfContents, inspectorCollapsed, inspectorWidth,
editor,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onToggleInspector, onToggleAIChat, onToggleTableOfContents, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -82,6 +88,52 @@ export function EditorRightPanel({
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
}, [handleNewChat])
if (!inspectorCollapsed) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, height: '100%' }}
>
<Inspector
collapsed={inspectorCollapsed}
onToggle={onToggleInspector}
entry={inspectorEntry}
content={inspectorContent}
entries={entries}
gitHistory={gitHistory}
vaultPath={vaultPath}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}
locale={locale}
/>
</div>
)
}
if (showTableOfContents) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<TableOfContentsPanel
editor={editor}
entry={inspectorEntry}
locale={locale}
onClose={() => onToggleTableOfContents?.()}
sourceContent={inspectorContent}
/>
</div>
)
}
if (showAIChat) {
return (
<div
@@ -105,32 +157,5 @@ export function EditorRightPanel({
)
}
if (inspectorCollapsed) return null
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, height: '100%' }}
>
<Inspector
collapsed={inspectorCollapsed}
onToggle={onToggleInspector}
entry={inspectorEntry}
content={inspectorContent}
entries={entries}
gitHistory={gitHistory}
vaultPath={vaultPath}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}
locale={locale}
/>
</div>
)
return null
}

View File

@@ -411,10 +411,12 @@
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
user-select: none;
}
.editor__blocknote-container .tldraw-whiteboard .tl-container {
--color-background: var(--card);
--tl-layer-menu-click-capture: 25;
--tl-layer-panels: 30;
--tl-layer-menus: 40;
--tl-layer-toasts: 50;
@@ -423,6 +425,10 @@
--tl-layer-following-indicator: 80;
}
.editor__blocknote-container .tldraw-whiteboard [data-radix-popper-content-wrapper] {
position: absolute !important;
}
.editor__blocknote-container .mantine-Popover-dropdown,
.editor__blocknote-container .bn-menu,
.editor__blocknote-container .bn-suggestion-menu {

View File

@@ -54,6 +54,18 @@ const pdfEntry: VaultEntry = {
filename: 'report.pdf',
title: 'report.pdf',
}
const audioEntry: VaultEntry = {
...imageEntry,
path: '/vault/Attachments/meeting.mp3',
filename: 'meeting.mp3',
title: 'meeting.mp3',
}
const videoEntry: VaultEntry = {
...imageEntry,
path: '/vault/Attachments/demo.mp4',
filename: 'demo.mp4',
title: 'demo.mp4',
}
describe('FilePreview', () => {
beforeEach(() => {
@@ -110,6 +122,22 @@ describe('FilePreview', () => {
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
})
it('renders supported audio files through the media asset path', () => {
render(<FilePreview entry={audioEntry} />)
expect(screen.getByTestId('audio-file-preview')).toHaveAttribute('src', 'asset:///vault/Attachments/meeting.mp3')
expect(screen.getByText('MP3 file')).toBeInTheDocument()
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'audio' })
})
it('renders supported video files through the media asset path', () => {
render(<FilePreview entry={videoEntry} />)
expect(screen.getByTestId('video-file-preview')).toHaveAttribute('src', 'asset:///vault/Attachments/demo.mp4')
expect(screen.getByTestId('video-file-preview')).toHaveAttribute('title', 'demo.mp4')
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'video' })
})
it('provides a graceful fallback when a PDF preview cannot render', () => {
render(<FilePreview entry={pdfEntry} />)
@@ -128,4 +156,16 @@ describe('FilePreview', () => {
expect.objectContaining({ path: expect.any(String) }),
)
})
it('tracks media preview failures without leaking the file path', () => {
render(<FilePreview entry={audioEntry} />)
fireEvent.error(screen.getByTestId('audio-file-preview'))
expect(trackEventMock).toHaveBeenCalledWith('file_preview_failed', { preview_kind: 'audio' })
expect(trackEventMock).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ path: expect.any(String) }),
)
})
})

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, SpeakerHigh, Video, WarningCircle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types'
import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics'
import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview'
@@ -55,6 +55,14 @@ function FilePreviewHeaderIcon({ previewKind }: { previewKind: FilePreviewKind |
return <FilePdf size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
if (previewKind === 'audio') {
return <SpeakerHigh size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
if (previewKind === 'video') {
return <Video size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
return <FileDashed size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
@@ -179,6 +187,61 @@ function FilePreviewImage({
)
}
function FilePreviewMediaFrame({
children,
video = false,
}: {
children: ReactNode
video?: boolean
}) {
return (
<div className={`flex h-full items-center justify-center ${video ? 'min-h-[320px] bg-black p-4' : 'min-h-[260px] p-6'}`}>
{children}
</div>
)
}
function FilePreviewMedia({
entry,
mediaKind,
mediaSrc,
onMediaError,
}: {
entry: VaultEntry
mediaKind: 'audio' | 'video'
mediaSrc: string
onMediaError: () => void
}) {
if (mediaKind === 'audio') {
return (
<FilePreviewMediaFrame>
<audio
controls
preload="metadata"
src={mediaSrc}
className="w-full max-w-2xl"
data-testid="audio-file-preview"
onError={onMediaError}
/>
</FilePreviewMediaFrame>
)
}
return (
<FilePreviewMediaFrame video>
<video
controls
preload="metadata"
src={mediaSrc}
title={entry.title}
className="max-h-full max-w-full"
data-testid="video-file-preview"
onError={onMediaError}
/>
</FilePreviewMediaFrame>
)
}
function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string {
return isImage && imageSrc !== null && !imageFailed
}
@@ -189,6 +252,8 @@ function FilePreviewBody({
assetSrc,
imageFailed,
onImageError,
onAudioError,
onVideoError,
onOpenExternal,
}: {
entry: VaultEntry
@@ -196,6 +261,8 @@ function FilePreviewBody({
assetSrc: string | null
imageFailed: boolean
onImageError: () => void
onAudioError: () => void
onVideoError: () => void
onOpenExternal: () => void
}) {
if (shouldRenderImagePreview(previewKind === 'image', assetSrc, imageFailed)) {
@@ -206,6 +273,14 @@ function FilePreviewBody({
return <FilePreviewPdf entry={entry} pdfSrc={assetSrc} onOpenExternal={onOpenExternal} />
}
if (previewKind === 'audio' && assetSrc !== null) {
return <FilePreviewMedia entry={entry} mediaKind="audio" mediaSrc={assetSrc} onMediaError={onAudioError} />
}
if (previewKind === 'video' && assetSrc !== null) {
return <FilePreviewMedia entry={entry} mediaKind="video" mediaSrc={assetSrc} onMediaError={onVideoError} />
}
const fallback = fallbackContentForPreviewKind(previewKind)
return (
@@ -218,48 +293,96 @@ function FilePreviewBody({
)
}
function useFilePreviewFailureState(entryPath: string) {
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
const [failedMediaPath, setFailedMediaPath] = useState<string | null>(null)
const handleImageError = useCallback(() => {
setFailedImagePath(entryPath)
trackFilePreviewFailed('image')
}, [entryPath])
const handleAudioError = useCallback(() => {
setFailedMediaPath(entryPath)
trackFilePreviewFailed('audio')
}, [entryPath])
const handleVideoError = useCallback(() => {
setFailedMediaPath(entryPath)
trackFilePreviewFailed('video')
}, [entryPath])
return {
imageFailed: failedImagePath === entryPath,
mediaFailed: failedMediaPath === entryPath,
handleImageError,
handleAudioError,
handleVideoError,
}
}
function useFilePreviewActions({
entryPath,
onCopyFilePath,
onOpenExternalFile,
onRevealFile,
previewKind,
}: {
entryPath: string
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onRevealFile?: (path: string) => void
previewKind: FilePreviewKind | null
}) {
const handleOpenExternal = useCallback(() => {
trackFilePreviewAction('open_external', previewKind)
if (onOpenExternalFile) {
onOpenExternalFile(entryPath)
return
}
void openLocalFile(entryPath).catch((error) => {
console.warn('Failed to open file with default app:', error)
})
}, [entryPath, onOpenExternalFile, previewKind])
const handleRevealFile = useCallback(() => {
trackFilePreviewAction('reveal', previewKind)
onRevealFile?.(entryPath)
}, [entryPath, onRevealFile, previewKind])
const handleCopyFilePath = useCallback(() => {
trackFilePreviewAction('copy_path', previewKind)
onCopyFilePath?.(entryPath)
}, [entryPath, onCopyFilePath, previewKind])
return { handleOpenExternal, handleRevealFile, handleCopyFilePath }
}
function previewKindForBody(previewKind: FilePreviewKind | null, mediaFailed: boolean): FilePreviewKind | null {
return mediaFailed ? null : previewKind
}
export function FilePreview({
entry,
onCopyFilePath,
onOpenExternalFile,
onRevealFile,
}: FilePreviewProps) {
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
const previewKind = filePreviewKind(entry)
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
const fileTypeLabel = previewFileTypeLabel(entry)
const imageFailed = failedImagePath === entry.path
const handleImageError = useCallback(() => {
setFailedImagePath(entry.path)
trackFilePreviewFailed('image')
}, [entry.path])
const failures = useFilePreviewFailureState(entry.path)
const actions = useFilePreviewActions({
entryPath: entry.path,
onCopyFilePath,
onOpenExternalFile,
onRevealFile,
previewKind,
})
useEffect(() => {
trackFilePreviewOpened(previewKind)
}, [entry.path, previewKind])
const handleOpenExternal = useCallback(() => {
trackFilePreviewAction('open_external', previewKind)
if (onOpenExternalFile) {
onOpenExternalFile(entry.path)
return
}
void openLocalFile(entry.path).catch((error) => {
console.warn('Failed to open file with default app:', error)
})
}, [entry.path, onOpenExternalFile, previewKind])
const handleRevealFile = useCallback(() => {
trackFilePreviewAction('reveal', previewKind)
onRevealFile?.(entry.path)
}, [entry.path, onRevealFile, previewKind])
const handleCopyFilePath = useCallback(() => {
trackFilePreviewAction('copy_path', previewKind)
onCopyFilePath?.(entry.path)
}, [entry.path, onCopyFilePath, previewKind])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape') return
event.preventDefault()
@@ -279,18 +402,20 @@ export function FilePreview({
entry={entry}
previewKind={previewKind}
fileTypeLabel={fileTypeLabel}
onOpenExternal={handleOpenExternal}
onRevealFile={onRevealFile ? handleRevealFile : undefined}
onCopyFilePath={onCopyFilePath ? handleCopyFilePath : undefined}
onOpenExternal={actions.handleOpenExternal}
onRevealFile={onRevealFile ? actions.handleRevealFile : undefined}
onCopyFilePath={onCopyFilePath ? actions.handleCopyFilePath : undefined}
/>
<div className="min-h-0 flex-1 overflow-auto bg-background">
<FilePreviewBody
entry={entry}
previewKind={previewKind}
previewKind={previewKindForBody(previewKind, failures.mediaFailed)}
assetSrc={assetSrc}
imageFailed={imageFailed}
onImageError={handleImageError}
onOpenExternal={handleOpenExternal}
imageFailed={failures.imageFailed}
onImageError={failures.handleImageError}
onAudioError={failures.handleAudioError}
onVideoError={failures.handleVideoError}
onOpenExternal={actions.handleOpenExternal}
/>
</div>
</section>

View File

@@ -191,7 +191,6 @@ export function InlineWikilinkInput({
setSelectionRange,
setCombinedRef,
syncSelectionRange,
commitValueFromEditor,
focusSelectionRange,
} = useInlineWikilinkSelection({
value,
@@ -396,7 +395,20 @@ export function InlineWikilinkInput({
}
}
commitValueFromEditor()
if (!editor) return
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
const nextSelection = readSelectionRange(editor)
const clampedSelection: InlineSelectionRange = {
start: Math.min(nextSelection.start, nextValue.length),
end: Math.min(nextSelection.end, nextValue.length),
}
const shouldRestoreFocus = document.activeElement === editor
pendingFocusAfterRemountRef.current = shouldRestoreFocus ? clampedSelection : null
onChange(nextValue)
setSelectionRange(clampedSelection)
forceRender((current) => current + 1)
}
const flushPendingCompositionInput = () => {
if (isComposingRef.current || !pendingCompositionInputRef.current) return
@@ -431,6 +443,8 @@ export function InlineWikilinkInput({
queueMicrotask(flushPendingCompositionInput)
}
const handleInput = () => {
if (disabled) return
if (isComposingRef.current) {
pendingCompositionInputRef.current = true
return

View File

@@ -113,6 +113,7 @@ describe('Inspector', () => {
render(<Inspector {...defaultProps} />)
// Header now says "Properties" (not "Inspector")
expect(screen.getAllByText('Properties').length).toBeGreaterThan(0)
expect(screen.getByTestId('properties-panel-icon')).toBeInTheDocument()
expect(screen.getByText('No note selected')).toBeInTheDocument()
})
@@ -122,10 +123,19 @@ describe('Inspector', () => {
expect(screen.queryByText('No note selected')).not.toBeInTheDocument()
})
it('calls onToggle when toggle button clicked', () => {
it('calls onToggle when the close button is clicked', () => {
const onToggle = vi.fn()
render(<Inspector {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getAllByRole('button', { name: 'Close Properties (⌘⇧I)' })[1])
expect(onToggle).toHaveBeenCalledOnce()
})
it('closes when the properties sidebar icon is clicked', () => {
const onToggle = vi.fn()
render(<Inspector {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getAllByRole('button', { name: 'Close Properties (⌘⇧I)' })[0])
expect(onToggle).toHaveBeenCalledOnce()
})

View File

@@ -96,6 +96,7 @@ function ValidFrontmatterPanels({
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
entry={entry}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}

View File

@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act, within } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
@@ -125,6 +125,35 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByText('Related to')).toBeInTheDocument()
})
it('shows missing type-defined relationships as editable placeholders', () => {
const bookType = makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book',
isA: 'Type',
relationships: {
Mentor: ['[[person/alice]]'],
},
})
renderRelationshipsPanel({
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
entries: [...entries, bookType],
frontmatter: {},
onAddProperty,
})
const placeholder = screen.getByTestId('type-derived-relationship')
expect(within(placeholder).getByText('Mentor')).toHaveClass('text-muted-foreground/40')
fireEvent.click(within(placeholder).getByTestId('add-relation-ref'))
fireEvent.change(within(placeholder).getByTestId('add-relation-ref-input'), {
target: { value: 'My Project' },
})
fireEvent.keyDown(within(placeholder).getByTestId('add-relation-ref-input'), { key: 'Enter' })
expect(onAddProperty).toHaveBeenCalledWith('Mentor', '[[project/my-project]]')
})
it('navigates when clicking a relationship link', () => {
render(
<DynamicRelationshipsPanel

View File

@@ -71,6 +71,34 @@ describe('MermaidDiagram', () => {
expect(style?.getAttribute('nonce')).toBe(RUNTIME_STYLE_NONCE)
})
it('keeps Mermaid foreignObject labels visible after sanitizing the SVG', async () => {
mermaidMock.render.mockResolvedValueOnce({
svg: [
'<svg aria-label="Rendered Mermaid">',
'<g class="node">',
'<foreignObject width="200" height="40">',
'<div xmlns="http://www.w3.org/1999/xhtml">',
'<span class="nodeLabel" onclick="alert(1)">Employee<br>clocks in</span>',
'</div>',
'</foreignObject>',
'</g>',
'</svg>',
].join(''),
})
render(
<MermaidDiagram
diagram={'flowchart LR\nA(["Employee clocks in"]) --> B'}
source={'```mermaid\nflowchart LR\nA(["Employee clocks in"]) --> B\n```'}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport')).toHaveTextContent('Employeeclocks in')
})
expect(screen.getByText('Employeeclocks in')).not.toHaveAttribute('onclick')
})
it('falls back to the original source when Mermaid cannot render', async () => {
mermaidMock.render.mockRejectedValueOnce(new Error('parse error'))

View File

@@ -78,6 +78,37 @@ describe('NoteItem', () => {
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf')
})
it('renders audio and video files as clickable media preview rows', () => {
const audioEntry = makeEntry({
path: '/vault/attachments/interview.mp3',
filename: 'interview.mp3',
title: 'interview.mp3',
fileKind: 'binary',
})
const videoEntry = makeEntry({
path: '/vault/attachments/demo.mp4',
filename: 'demo.mp4',
title: 'demo.mp4',
fileKind: 'binary',
})
const onClickNote = vi.fn()
render(
<>
<NoteItem entry={audioEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />
<NoteItem entry={videoEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />
</>,
)
expect(screen.getByTestId('audio-file-item')).toHaveAttribute('title', 'Open audio preview')
expect(screen.getByTestId('video-file-item')).toHaveAttribute('title', 'Open video preview')
fireEvent.click(screen.getByTestId('audio-file-item'))
fireEvent.click(screen.getByTestId('video-file-item'))
expect(onClickNote).toHaveBeenCalledWith(audioEntry, expect.any(Object))
expect(onClickNote).toHaveBeenCalledWith(videoEntry, expect.any(Object))
})
it('renders text files as clickable rows', () => {
const textEntry = makeEntry({
path: '/vault/config.yml',

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
File, FileDashed, FilePdf, ImageSquare,
File, FileDashed, FilePdf, ImageSquare, SpeakerHigh, Video,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
@@ -205,6 +205,8 @@ function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): Com
const previewKind = filePreviewKind(entry)
if (previewKind === 'image') return ImageSquare
if (previewKind === 'pdf') return FilePdf
if (previewKind === 'audio') return SpeakerHigh
if (previewKind === 'video') return Video
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, customIcon)
}
@@ -380,6 +382,8 @@ function resolveNoteItemTitle({
}) {
if (previewKind === 'image') return 'Open image preview'
if (previewKind === 'pdf') return 'Open PDF preview'
if (previewKind === 'audio') return 'Open audio preview'
if (previewKind === 'video') return 'Open video preview'
return isUnavailableBinary ? 'Cannot open this file type' : undefined
}

View File

@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
const { createValueButtonMock } = vi.hoisted(() => ({
const { createValueButtonMock, calendarMockState } = vi.hoisted(() => ({
createValueButtonMock:
(testId: string, nextValue: (value: string) => string) =>
({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
@@ -11,6 +11,14 @@ const { createValueButtonMock } = vi.hoisted(() => ({
{value}
</button>
),
calendarMockState: {
props: [] as Array<{
captionLayout?: string
navLayout?: string
startMonth?: Date
endMonth?: Date
}>,
},
}))
vi.mock('./EditableValue', () => ({
@@ -71,7 +79,21 @@ vi.mock('./IconEditableValue', () => ({
}))
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value?: Date) => void }) => (
Calendar: ({
onSelect,
captionLayout,
navLayout,
startMonth,
endMonth,
}: {
onSelect: (value?: Date) => void
captionLayout?: string
navLayout?: string
startMonth?: Date
endMonth?: Date
}) => {
calendarMockState.props.push({ captionLayout, navLayout, startMonth, endMonth })
return (
<div>
<button data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
pick
@@ -80,7 +102,8 @@ vi.mock('@/components/ui/calendar', () => ({
empty
</button>
</div>
),
)
},
}))
vi.mock('@/components/ui/popover', () => ({
@@ -118,6 +141,7 @@ function makeRect(right: number, bottom: number): DOMRect {
describe('PropertyValueCells extra', () => {
afterEach(() => {
calendarMockState.props.length = 0
vi.restoreAllMocks()
})
@@ -180,6 +204,67 @@ describe('PropertyValueCells extra', () => {
expect(onStartEdit).toHaveBeenCalledWith(null)
})
it('saves typed distant dates and enables dropdown calendar navigation', () => {
const onSave = vi.fn()
render(
<SmartPropertyValueCell
propKey="Due"
value="2026-04-20"
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={vi.fn()}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
const input = screen.getByTestId('date-picker-input')
fireEvent.change(input, { target: { value: '1884-02-29' } })
fireEvent.keyDown(input, { key: 'Enter' })
const calendarProps = calendarMockState.props.at(-1)
expect(onSave).toHaveBeenCalledWith('Due', '1884-02-29')
expect(calendarProps?.captionLayout).toBe('dropdown')
expect(calendarProps?.navLayout).toBe('after')
expect(calendarProps?.startMonth?.getFullYear()).toBe(1800)
expect(calendarProps?.endMonth?.getFullYear()).toBe(2200)
})
it('rejects invalid typed dates and cancels partial input on escape', () => {
const onSave = vi.fn()
const onStartEdit = vi.fn()
render(
<SmartPropertyValueCell
propKey="Due"
value="2026-04-20"
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
const input = screen.getByTestId('date-picker-input')
fireEvent.change(input, { target: { value: '2025-02-29' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).not.toHaveBeenCalled()
expect(input).toHaveAttribute('aria-invalid', 'true')
fireEvent.change(input, { target: { value: '1999-12' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onSave).not.toHaveBeenCalled()
expect(onStartEdit).toHaveBeenCalledWith(null)
})
it('renders number displays, restores invalid input on escape, and falls back to editable text', () => {
const onSave = vi.fn()
const onStartEdit = vi.fn()

View File

@@ -4,10 +4,13 @@ import { ArrowUpRight } from '@phosphor-icons/react'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { XIcon } from 'lucide-react'
import { trackDatePropertyDirectEntrySaved } from '../lib/productAnalytics'
import { translate, type AppLocale } from '../lib/i18n'
import { isValidCssColor } from '../utils/colorUtils'
import {
type PropertyDisplayMode,
@@ -25,19 +28,64 @@ import { IconEditableValue } from './IconEditableValue'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import { canonicalSystemMetadataKey } from '../utils/systemMetadata'
function parseDateValue(value: string): Date | undefined {
const iso = toISODate(value)
const d = new Date(iso + 'T00:00:00')
return isNaN(d.getTime()) ? undefined : d
const ISO_DATE_INPUT_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const DEFAULT_DATE_PICKER_START_YEAR = 1800
const DEFAULT_DATE_PICKER_END_YEAR = 2200
function localDate(year: number, monthIndex: number, day: number): Date {
const date = new Date(0)
date.setFullYear(year, monthIndex, day)
date.setHours(0, 0, 0, 0)
return date
}
function dateToISO(day: Date): string {
const yyyy = day.getFullYear()
const yyyy = String(day.getFullYear()).padStart(4, '0')
const mm = String(day.getMonth() + 1).padStart(2, '0')
const dd = String(day.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
function parseDateInput(value: string): string | null {
const trimmed = value.trim()
const match = ISO_DATE_INPUT_RE.exec(trimmed)
if (!match) return null
const year = Number(match[1])
if (year < 1) return null
const date = localDate(year, Number(match[2]) - 1, Number(match[3]))
return dateToISO(date) === trimmed ? trimmed : null
}
function dateFromISO(value: string): Date | undefined {
const iso = parseDateInput(value)
if (!iso) return undefined
const match = ISO_DATE_INPUT_RE.exec(iso)
if (!match) return undefined
return localDate(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
}
function parseDateValue(value: string): Date | undefined {
return dateFromISO(toISODate(value))
}
function datePickerStartMonth(selectedDate: Date | undefined): Date {
const selectedYear = selectedDate?.getFullYear()
const year = selectedYear && selectedYear < DEFAULT_DATE_PICKER_START_YEAR
? selectedYear
: DEFAULT_DATE_PICKER_START_YEAR
return localDate(year, 0, 1)
}
function datePickerEndMonth(selectedDate: Date | undefined): Date {
const selectedYear = selectedDate?.getFullYear()
const year = selectedYear && selectedYear > DEFAULT_DATE_PICKER_END_YEAR
? selectedYear
: DEFAULT_DATE_PICKER_END_YEAR
return localDate(year, 11, 31)
}
const RELATIONSHIP_PROPERTY_KEYS = new Set(['belongs_to', 'related_to', 'has'])
function normalizePropertyKey(propKey: string): string {
@@ -226,63 +274,143 @@ function NumberValue({
)
}
function DateValue({ value, onSave, autoOpen = false, onCancel }: {
function DateValue({ value, onSave, locale = 'en', autoOpen = false, onCancel }: {
value: string
onSave: (newValue: string) => void
locale?: AppLocale
autoOpen?: boolean
onCancel?: () => void
}) {
const [open, setOpen] = useState(autoOpen)
const formatted = formatDateValue(value)
const pickDateLabel = translate(locale, 'inspector.properties.pickDate')
const selectedDate = parseDateValue(value)
const selectedIso = selectedDate ? dateToISO(selectedDate) : ''
const [draftValue, setDraftValue] = useState(selectedIso)
const [invalidDraft, setInvalidDraft] = useState(false)
const resetDraft = () => {
setDraftValue(selectedIso)
setInvalidDraft(false)
}
const closeWithoutSave = () => {
resetDraft()
setOpen(false)
onCancel?.()
}
const commitDraftValue = () => {
if (draftValue.trim() === '') {
if (value) onSave('')
else closeWithoutSave()
setOpen(false)
return
}
const parsed = parseDateInput(draftValue)
if (!parsed) {
setInvalidDraft(true)
return
}
onSave(parsed)
trackDatePropertyDirectEntrySaved()
setOpen(false)
}
const handleSelect = (day: Date | undefined) => {
if (day) onSave(dateToISO(day))
setOpen(false)
}
const handleClear = (e: React.MouseEvent) => {
const handleClear = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
onSave('')
setOpen(false)
}
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setDraftValue(event.target.value)
if (invalidDraft) setInvalidDraft(false)
}
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
commitDraftValue()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeWithoutSave()
}
}
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
if (!nextOpen && !value) onCancel?.()
resetDraft()
if (!nextOpen) onCancel?.()
}}
>
<PopoverTrigger asChild>
<button
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none text-left transition-colors hover:opacity-80${formatted ? ' bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
<Button
type="button"
variant="ghost"
size="xs"
className={`min-w-0 justify-start border-none text-left transition-colors hover:opacity-80${formatted ? ' bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
style={PROPERTY_CHIP_STYLE}
title={value}
data-testid="date-display"
>
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
</button>
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || pickDateLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end" side="left" data-testid="date-picker-popover">
<div className="border-b p-2">
<Input
className="h-8 w-[8.75rem] bg-background px-2 py-1 font-mono text-[12px] tabular-nums"
type="text"
inputMode="numeric"
pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"
placeholder="YYYY-MM-DD"
aria-label={pickDateLabel}
aria-invalid={invalidDraft ? 'true' : undefined}
value={draftValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
autoFocus
data-testid="date-picker-input"
/>
</div>
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleSelect}
defaultMonth={selectedDate}
captionLayout="dropdown"
navLayout="after"
startMonth={datePickerStartMonth(selectedDate)}
endMonth={datePickerEndMonth(selectedDate)}
data-testid="date-picker-calendar"
/>
{selectedDate && (
<div className="border-t px-3 py-2">
<button
className="inline-flex items-center gap-1 border-none bg-transparent text-xs text-muted-foreground transition-colors hover:text-foreground"
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={handleClear}
data-testid="date-picker-clear"
>
<XIcon className="size-3" />
Clear date
</button>
</Button>
</div>
)}
</PopoverContent>
@@ -389,6 +517,7 @@ function autoDetectFromValue(propKey: string, value: FrontmatterValue): Property
type SmartCellProps = {
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
locale?: AppLocale
vaultStatuses: string[]; vaultTags: string[]
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
@@ -455,6 +584,7 @@ const SCALAR_DISPLAY_RENDERERS: readonly [PropertyDisplayMode, ScalarDisplayRend
<DateValue
key={`${props.propKey}:${props.isEditing ? 'editing' : 'view'}`}
value={String(props.value ?? '')}
locale={props.locale}
onSave={(nextValue) => props.onSave(props.propKey, nextValue)}
autoOpen={props.isEditing}
onCancel={() => props.onStartEdit(null)}

View File

@@ -10,6 +10,13 @@ interface SafeSvgDivProps extends HTMLAttributes<HTMLDivElement> {
svg: string
}
const MERMAID_SVG_SANITIZE_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['xmlns'],
HTML_INTEGRATION_POINTS: { foreignobject: true },
}
function importSanitizedHtmlNodes(html: string): Node[] {
const sanitized = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true, mathMl: true },
@@ -19,13 +26,12 @@ function importSanitizedHtmlNodes(html: string): Node[] {
}
function importSanitizedSvgNode(svg: string): Node | null {
const sanitized = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
})
const parsed = new DOMParser().parseFromString(sanitized, 'image/svg+xml')
if (parsed.querySelector('parsererror')) return null
const sanitized = DOMPurify.sanitize(svg, MERMAID_SVG_SANITIZE_CONFIG)
const parsed = new DOMParser().parseFromString(sanitized, 'text/html')
const parsedSvg = parsed.body.querySelector('svg')
if (!parsedSvg) return null
const svgNode = document.importNode(parsed.documentElement, true)
const svgNode = document.importNode(parsedSvg, true)
svgNode.querySelectorAll('style').forEach((style) => {
style.setAttribute('nonce', RUNTIME_STYLE_NONCE)
})

View File

@@ -57,15 +57,50 @@ function createStorageMock(): Storage {
}
}
function installMatchMedia(matches = false) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
})),
})
}
describe('SettingsPanel', () => {
const onSave = vi.fn()
const onClose = vi.fn()
const localStorageMock = createStorageMock()
function renderOpenSettings(settings: Settings = emptySettings) {
return render(
<SettingsPanel open={true} settings={settings} onSave={onSave} onClose={onClose} />
)
}
function saveSettingsPanel() {
fireEvent.click(screen.getByTestId('settings-save'))
}
function expectSettingsSaved(partial: Partial<Settings>) {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining(partial))
}
function selectThemeMode(label: string) {
fireEvent.click(screen.getByRole('radio', { name: label }))
}
beforeEach(() => {
vi.clearAllMocks()
trackEventMock.mockClear()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
installMatchMedia(false)
window.localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
@@ -160,6 +195,8 @@ describe('SettingsPanel', () => {
autogit_inactive_threshold_seconds: 30,
release_channel: null,
theme_mode: 'light',
note_width_mode: 'normal',
sidebar_type_pluralization_enabled: true,
hide_gitignored_files: true,
all_notes_show_pdfs: false,
all_notes_show_images: false,
@@ -255,6 +292,7 @@ describe('SettingsPanel', () => {
expect(screen.getByTestId('settings-theme-mode')).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('radio', { name: 'System' })).toHaveAttribute('aria-checked', 'false')
})
it('defaults the language selector to system language', () => {
@@ -273,6 +311,53 @@ describe('SettingsPanel', () => {
expect(screen.getByText('系统(简体中文)')).toBeInTheDocument()
})
it('defaults note width to normal and sidebar type pluralization to enabled', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByTestId('settings-default-note-width')).toHaveAttribute('data-value', 'normal')
expect(
within(screen.getByTestId('settings-sidebar-type-pluralization')).getByRole('switch')
).toHaveAttribute('aria-checked', 'true')
})
it('preserves saved default note width and sidebar type pluralization preferences', () => {
render(
<SettingsPanel
open={true}
settings={{
...emptySettings,
note_width_mode: 'wide',
sidebar_type_pluralization_enabled: false,
}}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByTestId('settings-default-note-width')).toHaveAttribute('data-value', 'wide')
expect(
within(screen.getByTestId('settings-sidebar-type-pluralization')).getByRole('switch')
).toHaveAttribute('aria-checked', 'false')
})
it('saves default note width and sidebar type pluralization preferences', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.pointerDown(screen.getByTestId('settings-default-note-width'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: 'Wide' }))
fireEvent.click(within(screen.getByTestId('settings-sidebar-type-pluralization')).getByRole('switch'))
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
note_width_mode: 'wide',
sidebar_type_pluralization_enabled: false,
}))
})
it('keeps the language selector keyboard accessible', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
@@ -313,31 +398,42 @@ describe('SettingsPanel', () => {
})
it('saves the selected dark color mode', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
renderOpenSettings()
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
fireEvent.click(screen.getByTestId('settings-save'))
selectThemeMode('Dark')
saveSettingsPanel()
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
expectSettingsSaved({
theme_mode: 'dark',
}))
})
})
it('applies the selected dark color mode immediately while settings stays open', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
renderOpenSettings()
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
selectThemeMode('Dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark')
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
expectSettingsSaved({
theme_mode: 'dark',
}))
})
})
it('saves system color mode while applying the current OS appearance immediately', () => {
installMatchMedia(true)
renderOpenSettings()
selectThemeMode('System')
saveSettingsPanel()
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('system')
expectSettingsSaved({
theme_mode: 'system',
})
})
it('preserves a saved dark color mode until changed', () => {
@@ -580,6 +676,29 @@ describe('SettingsPanel', () => {
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
})
it('keeps Tab focus inside the settings panel', () => {
render(
<>
<button type="button" data-testid="background-action">Background</button>
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
</>
)
const backgroundAction = screen.getByTestId('background-action')
const closeButton = screen.getByTitle('Close settings')
const saveButton = screen.getByTestId('settings-save')
backgroundAction.focus()
fireEvent.keyDown(document, { key: 'Tab' })
expect(closeButton).toHaveFocus()
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
expect(saveButton).toHaveFocus()
fireEvent.keyDown(document, { key: 'Tab' })
expect(closeButton).toHaveFocus()
})
it('copies the MCP config from the AI Agents section', () => {
const onCopyMcpConfig = vi.fn()
render(

View File

@@ -24,7 +24,7 @@ import {
type RefObject,
} from 'react'
import { Moon, Sun, X } from '@phosphor-icons/react'
import { Bot, Copy, Folder, GitBranch, ListChecks, Palette, RefreshCw, ShieldCheck } from 'lucide-react'
import { Bot, Copy, Folder, GitBranch, ListChecks, Monitor, Palette, RefreshCw, ShieldCheck } from 'lucide-react'
import type { Settings } from '../types'
import {
APP_LOCALES,
@@ -37,7 +37,7 @@ import {
type UiLanguagePreference,
} from '../lib/i18n'
import {
applyThemeModeToDocument,
applyThemeSelectionToDocument,
DEFAULT_THEME_MODE,
readStoredThemeMode,
type ThemeMode,
@@ -46,7 +46,11 @@ import {
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
import { trackEvent } from '../lib/telemetry'
import { trackAllNotesVisibilityChanged } from '../lib/productAnalytics'
import {
trackAllNotesVisibilityChanged,
trackDefaultNoteWidthChanged,
trackSidebarTypePluralizationChanged,
} from '../lib/productAnalytics'
import { AiProviderSettings } from './AiProviderSettings'
import { PrivacySettingsSection } from './PrivacySettingsSection'
import {
@@ -59,13 +63,16 @@ import {
SettingsSwitchRow,
} from './SettingsControls'
import { SettingsFooter } from './SettingsFooter'
import { VaultContentSettingsSection } from './VaultContentSettingsSection'
import {
resolveAllNotesFileVisibility,
settingsWithAllNotesFileVisibility,
type AllNotesFileVisibility,
} from '../utils/allNotesFileVisibility'
import { DEFAULT_NOTE_WIDTH_MODE, normalizeNoteWidthMode } from '../utils/noteWidth'
import { Button } from './ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
import type { NoteWidthMode } from '../types'
interface SettingsPanelProps {
open: boolean
@@ -93,6 +100,8 @@ interface SettingsDraft {
releaseChannel: ReleaseChannel
themeMode: ThemeMode
uiLanguage: UiLanguagePreference
defaultNoteWidth: NoteWidthMode
sidebarTypePluralizationEnabled: boolean
initialH1AutoRename: boolean
hideGitignoredFiles: boolean
allNotesFileVisibility: AllNotesFileVisibility
@@ -128,6 +137,10 @@ interface SettingsBodyProps {
setThemeMode: (value: ThemeMode) => void
uiLanguage: UiLanguagePreference
setUiLanguage: (value: UiLanguagePreference) => void
defaultNoteWidth: NoteWidthMode
setDefaultNoteWidth: (value: NoteWidthMode) => void
sidebarTypePluralizationEnabled: boolean
setSidebarTypePluralizationEnabled: (value: boolean) => void
locale: AppLocale
systemLocale: AppLocale
initialH1AutoRename: boolean
@@ -158,10 +171,66 @@ const SETTINGS_SECTION_IDS = {
} as const
type Translate = ReturnType<typeof createTranslator>
const SETTINGS_FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
function isSaveShortcut(event: ReactKeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
}
function getSettingsFocusableElements(panel: HTMLElement): HTMLElement[] {
return Array.from(panel.querySelectorAll<HTMLElement>(SETTINGS_FOCUSABLE_SELECTOR))
.filter((element) => (
element.tabIndex >= 0 &&
element.getAttribute('aria-disabled') !== 'true' &&
!element.closest('[hidden], [aria-hidden="true"]')
))
}
function focusSettingsBoundary(focusableElements: HTMLElement[], shiftKey: boolean): void {
const targetIndex = shiftKey ? focusableElements.length - 1 : 0
focusableElements[targetIndex]?.focus()
}
function isSettingsPanelElement(panel: HTMLElement, activeElement: Element | null): activeElement is HTMLElement {
return activeElement instanceof HTMLElement && panel.contains(activeElement)
}
function isSettingsFocusBoundary(activeElement: HTMLElement, focusableElements: HTMLElement[], shiftKey: boolean): boolean {
const boundaryIndex = shiftKey ? 0 : focusableElements.length - 1
return activeElement === focusableElements[boundaryIndex]
}
function trapSettingsPanelFocus(event: KeyboardEvent, panel: HTMLElement | null): void {
if (event.key !== 'Tab' || !panel) return
const focusableElements = getSettingsFocusableElements(panel)
if (focusableElements.length === 0) {
event.preventDefault()
panel.focus()
return
}
const activeElement = document.activeElement
if (!isSettingsPanelElement(panel, activeElement)) {
event.preventDefault()
focusSettingsBoundary(focusableElements, event.shiftKey)
return
}
if (isSettingsFocusBoundary(activeElement, focusableElements, event.shiftKey)) {
event.preventDefault()
focusSettingsBoundary(focusableElements, event.shiftKey)
}
}
function createSettingsDraft(
settings: Settings,
explicitOrganizationEnabled: boolean,
@@ -184,6 +253,8 @@ function createSettingsDraft(
releaseChannel: normalizeReleaseChannel(settings.release_channel),
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
defaultNoteWidth: normalizeNoteWidthMode(settings.note_width_mode) ?? DEFAULT_NOTE_WIDTH_MODE,
sidebarTypePluralizationEnabled: settings.sidebar_type_pluralization_enabled ?? true,
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
hideGitignoredFiles: shouldHideGitignoredFiles(settings),
allNotesFileVisibility: resolveAllNotesFileVisibility(settings),
@@ -226,6 +297,8 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
release_channel: serializeReleaseChannel(draft.releaseChannel),
theme_mode: draft.themeMode,
ui_language: serializeUiLanguagePreference(draft.uiLanguage),
note_width_mode: draft.defaultNoteWidth,
sidebar_type_pluralization_enabled: draft.sidebarTypePluralizationEnabled,
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
default_ai_target: draft.defaultAiTarget,
@@ -240,13 +313,26 @@ function trackTelemetryConsentChange(previousAnalytics: boolean, nextAnalytics:
if (previousAnalytics && !nextAnalytics) trackEvent('telemetry_opted_out')
}
function trackSettingsPreferenceChanges(settings: Settings, draft: SettingsDraft): void {
const previousNoteWidth = normalizeNoteWidthMode(settings.note_width_mode) ?? DEFAULT_NOTE_WIDTH_MODE
if (previousNoteWidth !== draft.defaultNoteWidth) {
trackDefaultNoteWidthChanged(draft.defaultNoteWidth)
}
const previousPluralization = settings.sidebar_type_pluralization_enabled ?? true
if (previousPluralization !== draft.sidebarTypePluralizationEnabled) {
trackSidebarTypePluralizationChanged(draft.sidebarTypePluralizationEnabled)
}
}
function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number {
if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback
return Math.round(value)
}
function applyThemeModeSelection(value: ThemeMode): void {
if (typeof document !== 'undefined') applyThemeModeToDocument(document, value)
const matchMedia = typeof window !== 'undefined' ? window.matchMedia?.bind(window) : undefined
if (typeof document !== 'undefined') applyThemeSelectionToDocument(document, value, matchMedia)
if (typeof window !== 'undefined') writeStoredThemeMode(window.localStorage, value)
}
@@ -260,6 +346,17 @@ function useSettingsPanelAutofocus(panelRef: RefObject<HTMLDivElement | null>):
}, [panelRef])
}
function useSettingsPanelFocusTrap(panelRef: RefObject<HTMLDivElement | null>): void {
useEffect(() => {
const handleDocumentKeyDown = (event: KeyboardEvent) => {
trapSettingsPanelFocus(event, panelRef.current)
}
document.addEventListener('keydown', handleDocumentKeyDown, true)
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
}, [panelRef])
}
export function SettingsPanel({
open,
settings,
@@ -320,6 +417,7 @@ function SettingsPanelInner({
}, [explicitOrganizationEnabled, settings])
useSettingsPanelAutofocus(panelRef)
useSettingsPanelFocusTrap(panelRef)
const updateDraft = useCallback(
<Key extends keyof SettingsDraft>(key: Key, value: SettingsDraft[Key]) => {
@@ -347,6 +445,7 @@ function SettingsPanelInner({
const handleSave = useCallback(() => {
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
trackSettingsPreferenceChanges(settings, draft)
onSave(buildSettingsFromDraft(settings, draft))
onSaveExplicitOrganization?.(draft.explicitOrganization)
onClose()
@@ -482,6 +581,10 @@ function SettingsBodyFromDraft({
setThemeMode={setThemeMode}
uiLanguage={draft.uiLanguage}
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
defaultNoteWidth={draft.defaultNoteWidth}
setDefaultNoteWidth={(value) => updateDraft('defaultNoteWidth', value)}
sidebarTypePluralizationEnabled={draft.sidebarTypePluralizationEnabled}
setSidebarTypePluralizationEnabled={(value) => updateDraft('sidebarTypePluralizationEnabled', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
hideGitignoredFiles={draft.hideGitignoredFiles}
@@ -610,6 +713,10 @@ function SettingsSyncAndAppearanceSections({
function SettingsContentSections({
t,
defaultNoteWidth,
setDefaultNoteWidth,
sidebarTypePluralizationEnabled,
setSidebarTypePluralizationEnabled,
initialH1AutoRename,
setInitialH1AutoRename,
hideGitignoredFiles,
@@ -621,6 +728,10 @@ function SettingsContentSections({
<SettingsSection id={SETTINGS_SECTION_IDS.content}>
<VaultContentSettingsSection
t={t}
defaultNoteWidth={defaultNoteWidth}
setDefaultNoteWidth={setDefaultNoteWidth}
sidebarTypePluralizationEnabled={sidebarTypePluralizationEnabled}
setSidebarTypePluralizationEnabled={setSidebarTypePluralizationEnabled}
initialH1AutoRename={initialH1AutoRename}
setInitialH1AutoRename={setInitialH1AutoRename}
hideGitignoredFiles={hideGitignoredFiles}
@@ -769,6 +880,9 @@ function ThemeModeControl({
<ThemeModeButton label={t('settings.theme.dark')} selected={value === 'dark'} value="dark" onSelect={onChange}>
<Moon size={14} />
</ThemeModeButton>
<ThemeModeButton label={t('settings.theme.system')} selected={value === 'system'} value="system" onSelect={onChange}>
<Monitor size={14} />
</ThemeModeButton>
</div>
)
}
@@ -920,79 +1034,6 @@ function LanguageSettingsSection({
)
}
function VaultContentSettingsSection({
t,
initialH1AutoRename,
setInitialH1AutoRename,
hideGitignoredFiles,
setHideGitignoredFiles,
allNotesFileVisibility,
setAllNotesFileVisibility,
}: Pick<
SettingsBodyProps,
| 't'
| 'initialH1AutoRename'
| 'setInitialH1AutoRename'
| 'hideGitignoredFiles'
| 'setHideGitignoredFiles'
| 'allNotesFileVisibility'
| 'setAllNotesFileVisibility'
>) {
const updateAllNotesFileVisibility = (patch: Partial<AllNotesFileVisibility>) => {
setAllNotesFileVisibility({ ...allNotesFileVisibility, ...patch })
}
return (
<>
<SectionHeading
title={t('settings.vaultContent.title')}
/>
<SettingsGroup>
<SettingsSwitchRow
label={t('settings.titles.autoRename')}
description={t('settings.titles.autoRenameDescription')}
checked={initialH1AutoRename}
onChange={setInitialH1AutoRename}
testId="settings-initial-h1-auto-rename"
/>
<SettingsSwitchRow
label={t('settings.vaultContent.hideGitignored')}
description={t('settings.vaultContent.hideGitignoredDescription')}
checked={hideGitignoredFiles}
onChange={setHideGitignoredFiles}
testId="settings-hide-gitignored-files"
/>
<SettingsSwitchRow
label={t('settings.allNotesVisibility.pdfs')}
description={t('settings.allNotesVisibility.pdfsDescription')}
checked={allNotesFileVisibility.pdfs}
onChange={(checked) => updateAllNotesFileVisibility({ pdfs: checked })}
testId="settings-all-notes-show-pdfs"
/>
<SettingsSwitchRow
label={t('settings.allNotesVisibility.images')}
description={t('settings.allNotesVisibility.imagesDescription')}
checked={allNotesFileVisibility.images}
onChange={(checked) => updateAllNotesFileVisibility({ images: checked })}
testId="settings-all-notes-show-images"
/>
<SettingsSwitchRow
label={t('settings.allNotesVisibility.unsupported')}
description={t('settings.allNotesVisibility.unsupportedDescription')}
checked={allNotesFileVisibility.unsupported}
onChange={(checked) => updateAllNotesFileVisibility({ unsupported: checked })}
testId="settings-all-notes-show-unsupported"
/>
</SettingsGroup>
</>
)
}
function buildDefaultAiTargetOptions(
aiAgentsStatus: AiAgentsStatus,
providers: AiModelProvider[],

View File

@@ -41,6 +41,11 @@ describe('buildSectionGroup', () => {
expect(group.Icon).toBe(FileText)
})
it('uses exact type names when automatic pluralization is disabled', () => {
const group = buildSectionGroup('Widget', {}, false)
expect(group.label).toBe('Widget')
})
it('overrides built-in type icon/color when type entry has custom values', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Project: { ...baseEntry, title: 'Project', isA: 'Type', icon: 'rocket', color: 'green', sidebarLabel: 'My Projects' },

View File

@@ -515,6 +515,22 @@ describe('Sidebar', () => {
// Recipe has no sidebarLabel → should auto-pluralize to "Recipes"
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
it('uses exact type names when automatic sidebar pluralization is disabled', () => {
render(
<Sidebar
entries={entriesWithCustomTypes}
selection={defaultSelection}
onSelect={() => {}}
pluralizeTypeLabels={false}
/>
)
expect(screen.getByText('Recipe')).toBeInTheDocument()
expect(screen.getByText('Project')).toBeInTheDocument()
expect(screen.queryByText('Recipes')).not.toBeInTheDocument()
expect(screen.queryByText('Projects')).not.toBeInTheDocument()
})
})
describe('type visibility via visible property', () => {

View File

@@ -66,6 +66,7 @@ interface SidebarProps {
showInbox?: boolean
inboxCount?: number
allNotesFileVisibility?: AllNotesFileVisibility
pluralizeTypeLabels?: boolean
locale?: AppLocale
onCollapse?: () => void
onGoBack?: () => void
@@ -485,9 +486,10 @@ function useSidebarRuntime({
onDeleteType,
onToggleTypeVisibility,
allNotesFileVisibility,
pluralizeTypeLabels = true,
locale = 'en',
}: SidebarProps) {
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, pluralizeTypeLabels)
const { activeCount, archivedCount } = useEntryCounts(entries, allNotesFileVisibility)
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
const typeInteractions = useSidebarTypeInteractions({

View File

@@ -240,6 +240,19 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
function createEditor() {
const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] }
const tiptapDom = document.createElement('div')
tiptapDom.getBoundingClientRect = vi.fn(() => ({
bottom: 420,
height: 360,
left: 120,
right: 720,
toJSON: () => ({}),
top: 60,
width: 600,
x: 120,
y: 60,
}))
return {
document: [
{ id: 'heading-block', type: 'heading', content: [], children: [] },
@@ -250,7 +263,17 @@ function createEditor() {
{ type: 'table', content: { type: 'tableContent' } },
]),
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
_tiptapEditor: { commands: { setContent: vi.fn() } },
_tiptapEditor: {
commands: {
setContent: vi.fn(),
setTextSelection: vi.fn(),
},
state: { doc: { content: { size: 100 } } },
view: {
dom: tiptapDom,
posAtCoords: vi.fn(() => ({ pos: 1 })),
},
},
focus: vi.fn(),
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
insertBlocks: vi.fn(),
@@ -274,6 +297,26 @@ function renderEditorHarness(editor = createEditor()) {
return { container: container!, editor }
}
function renderEditorHarnessInScrollArea(editor = createEditor()) {
render(
<div className="editor-scroll-area" data-testid="editor-scroll-area">
<div className="editor-content-wrapper">
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>
</div>
</div>,
{ wrapper: TooltipProvider },
)
const scrollArea = screen.getByTestId('editor-scroll-area')
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
return { container: container!, editor, scrollArea }
}
function createCodeBlockFixture(text: string) {
const codeBlock = document.createElement('div')
codeBlock.setAttribute('data-content-type', 'codeBlock')
@@ -285,6 +328,13 @@ function createCodeBlockFixture(text: string) {
return { codeBlock, code }
}
function createParagraphFixture(text: string) {
const paragraph = document.createElement('p')
const textNode = document.createTextNode(text)
paragraph.appendChild(textNode)
return { paragraph, textNode }
}
function selectNodeContents(node: Node) {
const range = document.createRange()
range.selectNodeContents(node)
@@ -629,7 +679,7 @@ describe('SingleEditorView', () => {
expect(editor.focus).not.toHaveBeenCalled()
})
it('does not override full-note copy selections that merely include a code block', async () => {
it('keeps full-note copy selections from collapsing to code-block text only', async () => {
const { container } = renderEditorHarness()
const paragraph = document.createElement('p')
paragraph.textContent = 'Before'
@@ -649,7 +699,57 @@ describe('SingleEditorView', () => {
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
expect(clipboardData.setData).not.toHaveBeenCalled()
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Beforeconst value = 1')
expect(clipboardData.setData).not.toHaveBeenCalledWith('text/plain', 'const value = 1')
})
it('copies ordinary selected editor text without appending a newline', async () => {
const { container } = renderEditorHarness()
const { paragraph, textNode } = createParagraphFixture('Only copied words')
await act(async () => {
container.appendChild(paragraph)
await Promise.resolve()
})
selectNodeContents(textNode)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(paragraph, { clipboardData })
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Only copied words')
})
it('removes one synthetic terminal newline while preserving internal newlines', async () => {
const { container } = renderEditorHarness()
const { paragraph, textNode } = createParagraphFixture('First line\nSecond line\n')
await act(async () => {
container.appendChild(paragraph)
await Promise.resolve()
})
selectNodeContents(textNode)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(paragraph, { clipboardData })
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'First line\nSecond line')
})
it('keeps selected rich text available as HTML when normalizing plain copy text', async () => {
const { container } = renderEditorHarness()
const paragraph = document.createElement('p')
const strong = document.createElement('strong')
strong.textContent = 'Bold copy'
paragraph.appendChild(strong)
await act(async () => {
container.appendChild(paragraph)
await Promise.resolve()
})
selectNodeContents(strong)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(strong, { clipboardData })
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Bold copy')
expect(clipboardData.setData).toHaveBeenCalledWith('text/html', '<strong>Bold copy</strong>')
})
it('handles registered plain-text paste requests through BlockNote insertion', () => {
@@ -780,6 +880,97 @@ describe('SingleEditorView', () => {
expect(editor.focus).toHaveBeenCalled()
})
it('extends mouse selections from editor whitespace using clamped BlockNote coordinates', () => {
const { container, editor } = renderEditorHarness()
editor._tiptapEditor.view.posAtCoords
.mockReturnValueOnce({ pos: 4 })
.mockReturnValueOnce({ pos: 18 })
.mockReturnValueOnce({ pos: 18 })
fireEvent.mouseDown(container, { button: 0, clientX: 12, clientY: 72 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 680, clientY: 180 })
fireEvent.mouseUp(window, { clientX: 680, clientY: 180 })
expect(editor.focus).toHaveBeenCalled()
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(1, {
left: 121,
top: 72,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenNthCalledWith(1, {
from: 4,
to: 4,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({
from: 4,
to: 18,
})
fireEvent.click(container)
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
it('extends mouse selections from the surrounding editor scroll whitespace', () => {
const { editor, scrollArea } = renderEditorHarnessInScrollArea()
editor._tiptapEditor.view.posAtCoords
.mockReturnValueOnce({ pos: 5 })
.mockReturnValueOnce({ pos: 22 })
.mockReturnValueOnce({ pos: 22 })
fireEvent.mouseDown(scrollArea, { button: 0, clientX: 24, clientY: 96 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 920, clientY: 190 })
fireEvent.mouseUp(window, { clientX: 920, clientY: 190 })
expect(editor.focus).toHaveBeenCalled()
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(1, {
left: 121,
top: 96,
})
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(2, {
left: 719,
top: 190,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({
from: 5,
to: 22,
})
})
it('extends mouse selections to the document end when dragging below the editor content', () => {
const { container, editor } = renderEditorHarness()
editor._tiptapEditor.state.doc.content.size = 42
editor._tiptapEditor.view.posAtCoords
.mockReturnValueOnce({ pos: 7 })
.mockReturnValue(null)
fireEvent.mouseDown(container, { button: 0, clientX: 250, clientY: 80 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 260, clientY: 900 })
fireEvent.mouseUp(window, { clientX: 260, clientY: 900 })
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(2, {
left: 260,
top: 419,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({
from: 7,
to: 41,
})
})
it('leaves native BlockNote and non-primary mouse selections alone', () => {
const { container, editor } = renderEditorHarness()
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
container.appendChild(editable)
fireEvent.mouseDown(editable, { button: 0, clientX: 200, clientY: 80 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 260, clientY: 120 })
fireEvent.mouseDown(container, { button: 2, clientX: 200, clientY: 80 })
expect(editor._tiptapEditor.view.posAtCoords).not.toHaveBeenCalled()
expect(editor._tiptapEditor.commands.setTextSelection).not.toHaveBeenCalled()
})
it('routes the custom link-toolbar open action through openExternalUrl', () => {
render(
<SingleEditorView

View File

@@ -286,6 +286,7 @@ function isSelectionInsideElement(element: HTMLElement): boolean {
const TITLE_HEADING_SELECTOR = 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])'
const TITLE_HEADING_WRAPPER_SELECTOR = '.bn-block-outer, .bn-block'
const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]'
const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u'
const CODE_BLOCK_COPY_RESET_MS = 1200
function nodeElement(node: Node | null): HTMLElement | null {
@@ -343,6 +344,36 @@ function selectedCodeBlockText(options: {
return options.selection?.toString() || range.cloneContents().textContent || ''
}
function selectedEditorRange(selection: Selection | null, container: HTMLElement): Range | null {
if (!hasSingleActiveRange(selection)) return null
const range = selection.getRangeAt(0)
return rangeBelongsToElement(range, container) ? range : null
}
function selectedEditorPlainText(selection: Selection, range: Range): string | null {
const text = selection.toString() || range.cloneContents().textContent || ''
if (text.length === 0) return null
return text.replace(/\r?\n$/, '')
}
function selectedEditorHtml(range: Range): string {
const wrapper = document.createElement('div')
const selectedContent = range.cloneContents()
const commonElement = nodeElement(range.commonAncestorContainer)
if (commonElement?.matches(CLIPBOARD_INLINE_FORMAT_SELECTOR)) {
const inlineWrapper = commonElement.cloneNode(false)
inlineWrapper.appendChild(selectedContent)
wrapper.appendChild(inlineWrapper)
return wrapper.innerHTML
}
wrapper.appendChild(selectedContent)
return wrapper.innerHTML
}
function codeBlockText(codeBlock: HTMLElement): string {
const codeElement = codeBlock.querySelector<HTMLElement>('pre code')
return codeElement?.textContent ?? ''
@@ -559,14 +590,324 @@ function queueTitleHeadingCursorRepair(
return true
}
type EditorClientPoint = Pick<MouseEvent, 'clientX' | 'clientY'>
type TiptapSelectionRange = { from: number; to: number }
type TiptapSelectionBridge = {
commands?: {
setTextSelection?: (selection: number | TiptapSelectionRange) => unknown
}
state?: {
doc?: {
content?: { size?: unknown }
}
}
view?: {
dom?: Element
posAtCoords?: (coords: { left: number; top: number }) => { pos?: unknown } | null
}
}
type EditorWithTiptapSelection = {
_tiptapEditor?: TiptapSelectionBridge
}
type WhitespaceSelectionStart = {
anchor: number
tiptapEditor: TiptapSelectionBridge
}
type WhitespaceDragState = WhitespaceSelectionStart & {
moved: boolean
startX: number
startY: number
}
type WhitespaceMouseDownEvent = EditorClientPoint & {
button: number
target: EventTarget | null
preventDefault: () => void
}
const EDGE_SELECTION_INSET_PX = 1
const DRAG_SELECTION_THRESHOLD_PX = 3
function getTiptapSelectionBridge(
editor: ReturnType<typeof useCreateBlockNote>,
): TiptapSelectionBridge | null {
return (editor as EditorWithTiptapSelection)._tiptapEditor ?? null
}
function isValidDocumentPosition(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function textSelectionDocumentBounds(
tiptapEditor: TiptapSelectionBridge,
): { start: number; end: number } | null {
const size = tiptapEditor.state?.doc?.content?.size
if (!isValidDocumentPosition(size)) return null
if (size <= 0) return { start: 0, end: 0 }
const end = Math.max(1, Math.floor(size) - 1)
return { start: Math.min(1, end), end }
}
function clampCoordinate(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min
if (max <= min) return min
return Math.min(max, Math.max(min, value))
}
function clampedEditorCoords(
point: EditorClientPoint,
editorRect: DOMRect,
): { left: number; top: number } {
return {
left: clampCoordinate(
point.clientX,
editorRect.left + EDGE_SELECTION_INSET_PX,
editorRect.right - EDGE_SELECTION_INSET_PX,
),
top: clampCoordinate(
point.clientY,
editorRect.top + EDGE_SELECTION_INSET_PX,
editorRect.bottom - EDGE_SELECTION_INSET_PX,
),
}
}
function fallbackTextPosition(
tiptapEditor: TiptapSelectionBridge,
point: EditorClientPoint,
editorRect: DOMRect,
): number | null {
const bounds = textSelectionDocumentBounds(tiptapEditor)
if (!bounds) return null
return point.clientY < editorRect.top ? bounds.start : bounds.end
}
function textPositionAtEditorPoint(
tiptapEditor: TiptapSelectionBridge,
point: EditorClientPoint,
): number | null {
const view = tiptapEditor.view
const editorDom = view?.dom
if (!editorDom || typeof view.posAtCoords !== 'function') return null
const editorRect = editorDom.getBoundingClientRect()
const position = view.posAtCoords(clampedEditorCoords(point, editorRect))?.pos
if (isValidDocumentPosition(position)) return position
return fallbackTextPosition(tiptapEditor, point, editorRect)
}
function applyTiptapTextSelection(
tiptapEditor: TiptapSelectionBridge,
anchor: number,
head: number,
): boolean {
const setTextSelection = tiptapEditor.commands?.setTextSelection
if (typeof setTextSelection !== 'function') return false
const range = {
from: Math.min(anchor, head),
to: Math.max(anchor, head),
}
try {
setTextSelection(range)
return true
} catch {
return false
}
}
function suppressNextContainerClick(suppressNextContainerClickRef: React.MutableRefObject<boolean>) {
suppressNextContainerClickRef.current = true
window.setTimeout(() => {
suppressNextContainerClickRef.current = false
}, 0)
}
function whitespaceSelectionStartFromEvent(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
event: WhitespaceMouseDownEvent
selectionRoot: HTMLElement
}): WhitespaceSelectionStart | null {
const { editable, editor, event, selectionRoot } = options
if (!editable || event.button !== 0) return null
const target = eventTargetElement(event.target)
if (!target || !selectionRoot.contains(target)) return null
if (shouldIgnoreContainerClick(target)) return null
const tiptapEditor = getTiptapSelectionBridge(editor)
if (!tiptapEditor) return null
const anchor = textPositionAtEditorPoint(tiptapEditor, event)
return anchor === null ? null : { anchor, tiptapEditor }
}
function movedPastDragThreshold(state: WhitespaceDragState, point: EditorClientPoint): boolean {
const movedDistance = Math.max(
Math.abs(point.clientX - state.startX),
Math.abs(point.clientY - state.startY),
)
return movedDistance >= DRAG_SELECTION_THRESHOLD_PX
}
function updateWhitespaceDragSelection(
state: WhitespaceDragState,
point: EditorClientPoint,
): boolean {
const head = textPositionAtEditorPoint(state.tiptapEditor, point)
if (head === null) return false
state.moved = state.moved || movedPastDragThreshold(state, point) || head !== state.anchor
return applyTiptapTextSelection(state.tiptapEditor, state.anchor, head)
}
function installWhitespaceSelectionDrag(options: {
cleanupDragRef: React.MutableRefObject<(() => void) | null>
state: WhitespaceDragState
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}): () => void {
const { cleanupDragRef, state, suppressNextContainerClickRef } = options
function cleanupDrag() {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
if (cleanupDragRef.current === cleanupDrag) {
cleanupDragRef.current = null
}
}
function handleMouseMove(moveEvent: MouseEvent) {
if ((moveEvent.buttons & 1) !== 1) {
cleanupDrag()
return
}
if (updateWhitespaceDragSelection(state, moveEvent)) {
moveEvent.preventDefault()
}
}
function handleMouseUp(upEvent: MouseEvent) {
updateWhitespaceDragSelection(state, upEvent)
if (state.moved) {
suppressNextContainerClick(suppressNextContainerClickRef)
}
cleanupDrag()
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return cleanupDrag
}
function closestEditorScrollArea(container: HTMLElement): HTMLElement | null {
const scrollArea = container.closest('.editor-scroll-area')
return scrollArea instanceof HTMLElement ? scrollArea : null
}
function eventTargetIsOutsideContainer(event: MouseEvent, container: HTMLElement): boolean {
const target = eventTargetElement(event.target)
return !target || !container.contains(target)
}
function installScrollAreaWhitespaceSelection(options: {
beginWhitespaceSelection: (event: WhitespaceMouseDownEvent, selectionRoot: HTMLElement) => void
container: HTMLElement
}): (() => void) | undefined {
const { beginWhitespaceSelection, container } = options
const scrollArea = closestEditorScrollArea(container)
if (!scrollArea || scrollArea === container) return undefined
const selectionRoot = scrollArea
function handleScrollAreaMouseDown(event: MouseEvent) {
if (eventTargetIsOutsideContainer(event, container)) {
beginWhitespaceSelection(event, selectionRoot)
}
}
selectionRoot.addEventListener('mousedown', handleScrollAreaMouseDown, true)
return () => {
selectionRoot.removeEventListener('mousedown', handleScrollAreaMouseDown, true)
}
}
function useEditorWhitespaceMouseSelection(options: {
containerRef: React.RefObject<HTMLDivElement | null>
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}) {
const { containerRef, editable, editor, suppressNextContainerClickRef } = options
const cleanupDragRef = useRef<(() => void) | null>(null)
useEffect(() => () => {
cleanupDragRef.current?.()
}, [])
const beginWhitespaceSelection = useCallback((
event: WhitespaceMouseDownEvent,
selectionRoot: HTMLElement,
) => {
const selectionStart = whitespaceSelectionStartFromEvent({
editable,
editor,
event,
selectionRoot,
})
if (!selectionStart) return
cleanupDragRef.current?.()
editor.focus()
const { anchor, tiptapEditor } = selectionStart
if (!applyTiptapTextSelection(tiptapEditor, anchor, anchor)) return
event.preventDefault()
const state: WhitespaceDragState = {
...selectionStart,
moved: false,
startX: event.clientX,
startY: event.clientY,
}
cleanupDragRef.current = installWhitespaceSelectionDrag({
cleanupDragRef,
state,
suppressNextContainerClickRef,
})
}, [editable, editor, suppressNextContainerClickRef])
useEffect(() => {
const container = containerRef.current
if (!container) return
return installScrollAreaWhitespaceSelection({ beginWhitespaceSelection, container })
}, [beginWhitespaceSelection, containerRef])
return useCallback((event: React.MouseEvent<HTMLDivElement>) => {
beginWhitespaceSelection(event, event.currentTarget)
}, [beginWhitespaceSelection])
}
function useEditorContainerClickHandler(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}) {
const { editable, editor } = options
const { editable, editor, suppressNextContainerClickRef } = options
return useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
if (suppressNextContainerClickRef.current) {
suppressNextContainerClickRef.current = false
return
}
const target = e.target as HTMLElement
if (queueTitleHeadingCursorRepair(target, editor)) return
if (shouldIgnoreContainerClick(target)) return
@@ -582,7 +923,7 @@ function useEditorContainerClickHandler(options: {
}
}
editor.focus()
}, [editor, editable])
}, [editor, editable, suppressNextContainerClickRef])
}
function useCompositionAwareEditorChange(options: {
@@ -636,15 +977,40 @@ function useCompositionAwareEditorChange(options: {
}, [])
}
function handleCodeBlockCopy(event: React.ClipboardEvent<HTMLDivElement>) {
function handleCodeBlockCopy(event: React.ClipboardEvent<HTMLDivElement>): boolean {
const codeText = selectedCodeBlockText({
selection: window.getSelection(),
container: event.currentTarget,
})
if (codeText === null) return
if (codeText === null) return false
event.clipboardData.setData('text/plain', codeText)
event.preventDefault()
return true
}
function handleSelectedEditorCopy(event: React.ClipboardEvent<HTMLDivElement>) {
const selection = window.getSelection()
const range = selectedEditorRange(selection, event.currentTarget)
if (!selection || !range) return
const plainText = selectedEditorPlainText(selection, range)
if (plainText === null) return
event.clipboardData.setData('text/plain', plainText)
const html = selectedEditorHtml(range)
if (html.length > 0) {
event.clipboardData.setData('text/html', html)
}
event.preventDefault()
}
function handleEditorCopy(event: React.ClipboardEvent<HTMLDivElement>) {
if (handleCodeBlockCopy(event)) return
handleSelectedEditorCopy(event)
}
function nonEmptyString(value: unknown): string | null {
@@ -877,7 +1243,18 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const { cssVars } = useEditorTheme()
const themeMode = useDocumentThemeMode()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const suppressNextContainerClickRef = useRef(false)
const handleContainerClick = useEditorContainerClickHandler({
editable,
editor,
suppressNextContainerClickRef,
})
const handleWhitespaceMouseSelection = useEditorWhitespaceMouseSelection({
containerRef,
editable,
editor,
suppressNextContainerClickRef,
})
const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
@@ -927,6 +1304,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
activatePlainTextPaste()
handleCodeBlockCopyFocus(event)
}, [activatePlainTextPaste, handleCodeBlockCopyFocus])
const handleMouseDownCapture = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
activatePlainTextPaste()
handleWhitespaceMouseSelection(event)
}, [activatePlainTextPaste, handleWhitespaceMouseSelection])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const suggestionMenuItems = useSuggestionMenuItems({
baseItems,
@@ -943,10 +1324,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`}
style={cssVars as React.CSSProperties}
onClick={handleContainerClick}
onCopyCapture={handleCodeBlockCopy}
onCopyCapture={handleEditorCopy}
onFocusCapture={handleFocusCapture}
onMouseLeave={clearCopyTarget}
onMouseDownCapture={activatePlainTextPaste}
onMouseDownCapture={handleMouseDownCapture}
onMouseMove={handleCodeBlockCopyMouseMove}
onPasteCapture={handleTitleHeadingRichPaste}
>

View File

@@ -567,6 +567,7 @@ describe('StatusBar', () => {
/>
)
fireEvent.click(screen.getByTestId('status-sync'))
expect(screen.getByTestId('status-bar')).toHaveStyle({ zIndex: '30' })
expect(screen.getByTestId('git-status-popup')).toBeInTheDocument()
expect(screen.getByText('main')).toBeInTheDocument()
expect(screen.getByText(/2 ahead/)).toBeInTheDocument()

View File

@@ -17,6 +17,7 @@ import type { VaultOption } from './status-bar/types'
export type { VaultOption } from './status-bar/types'
const COMPACT_STATUS_BAR_MAX_WIDTH = 1000
const STATUS_BAR_STACKING_Z_INDEX = 30
function getWindowWidth() {
return typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth
@@ -246,7 +247,7 @@ function StatusBarFooter(props: StatusBarFooterProps) {
fontSize: 12,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
zIndex: STATUS_BAR_STACKING_Z_INDEX,
}}
>
<StatusBarPrimaryFromFooter {...props} />

View File

@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
import { TableOfContentsPanel } from './TableOfContentsPanel'
import { buildTableOfContents, buildTableOfContentsFromMarkdown } from './tableOfContentsModel'
const entry = {
title: 'The Compounding Software Factory',
} as VaultEntry
const blocks = [
{ id: 'h1', type: 'heading', props: { level: 1 }, content: [{ type: 'text', text: 'The default path is degradation' }] },
{ id: 'h2', type: 'heading', props: { level: 2 }, content: [{ type: 'text', text: 'What causes teams to degrade' }] },
{ id: 'h3', type: 'heading', props: { level: 3 }, content: [{ type: 'text', text: 'Poor coding hygiene' }] },
{ id: 'ignored', type: 'paragraph', props: {}, content: [{ type: 'text', text: 'Body' }] },
]
describe('TableOfContentsPanel', () => {
it('builds a title-rooted H1/H2/H3 hierarchy', () => {
const toc = buildTableOfContents(entry.title, blocks)
expect(toc.title).toBe('The Compounding Software Factory')
expect(toc.children[0].title).toBe('The default path is degradation')
expect(toc.children[0].children[0].title).toBe('What causes teams to degrade')
expect(toc.children[0].children[0].children[0].title).toBe('Poor coding hygiene')
})
it('does not duplicate the note title when the first markdown H1 matches it', () => {
const toc = buildTableOfContentsFromMarkdown(
'Introducing Tolaria',
'# Introducing Tolaria\n\n## Tolaria + Refactoring\n\n## Principles',
)
expect(toc.title).toBe('Introducing Tolaria')
expect(toc.children.map((item) => item.title)).toEqual(['Tolaria + Refactoring', 'Principles'])
})
it('keeps navigation ids after removing a duplicate markdown title H1', async () => {
const setTextCursorPosition = vi.fn()
render(
<TableOfContentsPanel
editor={{
document: [
{ id: 'title-block', type: 'heading', props: { level: 1 }, content: [{ type: 'text', text: 'Introducing Tolaria' }] },
{ id: 'section-block', type: 'heading', props: { level: 2 }, content: [{ type: 'text', text: 'Tolaria + Refactoring' }] },
],
setTextCursorPosition,
}}
entry={{ ...entry, title: 'Introducing Tolaria' } as VaultEntry}
sourceContent={'# Introducing Tolaria\n\n## Tolaria + Refactoring'}
onClose={vi.fn()}
/>,
)
fireEvent.click(await screen.findByRole('button', { name: /Introducing Tolaria/ }))
expect(setTextCursorPosition).toHaveBeenCalledWith('title-block', 'start')
fireEvent.click(await screen.findByRole('button', { name: /Tolaria \+ Refactoring/ }))
expect(setTextCursorPosition).toHaveBeenCalledWith('section-block', 'start')
})
it('resolves navigation ids on click after the async TOC build starts without ids', async () => {
const setTextCursorPosition = vi.fn()
const editor = {
document: [] as unknown[],
setTextCursorPosition,
}
render(
<TableOfContentsPanel
editor={editor}
entry={{ ...entry, title: 'New Note' } as VaultEntry}
sourceContent={'# New Note\n\n## New Heading'}
onClose={vi.fn()}
/>,
)
await screen.findByRole('button', { name: /New Heading/ })
editor.document = [
{ id: 'title-block', type: 'heading', props: { level: 1 }, content: [{ type: 'text', text: 'New Note' }] },
{ id: 'new-heading-block', type: 'heading', props: { level: 2 }, content: [{ type: 'text', text: 'New Heading' }] },
]
fireEvent.click(screen.getByRole('button', { name: /New Heading/ }))
expect(setTextCursorPosition).toHaveBeenCalledWith('new-heading-block', 'start')
})
it('updates from source content even when the editor document is stale', async () => {
const { rerender } = render(
<TableOfContentsPanel
editor={{ document: blocks, setTextCursorPosition: vi.fn() }}
entry={{ ...entry, title: 'Old Note' } as VaultEntry}
sourceContent={'# Old Note\n\n## Old Heading'}
onClose={vi.fn()}
/>,
)
await screen.findByRole('button', { name: /Old Heading/ })
rerender(
<TableOfContentsPanel
editor={{ document: blocks, setTextCursorPosition: vi.fn() }}
entry={{ ...entry, title: 'New Note' } as VaultEntry}
sourceContent={'# New Note\n\n## New Heading'}
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /New Note/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Old Heading/ })).not.toBeInTheDocument()
expect(await screen.findByRole('button', { name: /New Heading/ })).toBeInTheDocument()
})
it('does not show stale editor headings while new note source content is loading', () => {
render(
<TableOfContentsPanel
editor={{ document: blocks, setTextCursorPosition: vi.fn() }}
entry={{ ...entry, title: 'New Note' } as VaultEntry}
sourceContent={null}
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /New Note/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /The default path is degradation/ })).not.toBeInTheDocument()
})
it('renders heading icons, nesting guides, and navigates to clicked headings', async () => {
const setTextCursorPosition = vi.fn()
render(
<TableOfContentsPanel
editor={{ document: blocks, setTextCursorPosition }}
entry={entry}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('Table of Contents')).toBeInTheDocument()
expect(await screen.findByTestId('toc-connector:toc-title')).toBeInTheDocument()
expect(screen.getByTestId('toc-connector:h1')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /What causes teams to degrade/ }))
expect(setTextCursorPosition).toHaveBeenCalledWith('h2', 'start')
})
})

View File

@@ -0,0 +1,288 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { ListBullets, TextHOne, TextHThree, TextHTwo, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { trackEvent } from '../lib/telemetry'
import type { AppLocale } from '../lib/i18n'
import { translate } from '../lib/i18n'
import type { VaultEntry } from '../types'
import {
buildTableOfContents,
resolveTocItemBlockId,
type TocItem,
} from './tableOfContentsModel'
import { buildTableOfContentsInWorker, TOC_BUILD_DEBOUNCE_MS } from './tableOfContentsWorkerClient'
import {
FOLDER_ROW_CONTENT_INSET,
getFolderConnectorLeft,
getFolderDepthIndent,
} from './folder-tree/folderTreeLayout'
interface TableOfContentsEditor {
document?: unknown[]
focus?: () => void
setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void
}
interface TableOfContentsPanelProps {
editor: TableOfContentsEditor
entry: VaultEntry | null
locale?: AppLocale
onClose: () => void
sourceContent?: string | null
}
interface TocState {
noteKey: string
toc: TocItem
}
interface DebouncedTocOptions {
editor: TableOfContentsEditor
noteKey: string
sourceContent?: string | null
title: string
titleOnlyToc: TocItem
}
function HeadingIcon({ level }: { level: TocItem['level'] }) {
const className = 'size-[17px] shrink-0 text-muted-foreground'
if (level === 1) return <TextHOne size={17} className={className} />
if (level === 2) return <TextHTwo size={17} className={className} />
return <TextHThree size={17} className={className} />
}
function buildTitleOnlyToc(title: string): TocItem {
return { id: 'toc-title', level: 1, title, children: [] }
}
function noteKeyForEntry(entry: VaultEntry | null, title: string): string {
return `${entry?.path ?? ''}:${title}`
}
function cssAttributeValue(value: string): string {
return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(value)
: value.replace(/["\\]/g, '\\$&')
}
function scrollBlockIntoView(blockId: string) {
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-id="${cssAttributeValue(blockId)}"]`)
?.scrollIntoView?.({ block: 'center' })
})
}
function useDebouncedToc({
editor,
noteKey,
sourceContent,
title,
titleOnlyToc,
}: DebouncedTocOptions): TocItem {
const [tocState, setTocState] = useState<TocState>(() => ({
noteKey,
toc: titleOnlyToc,
}))
useEffect(() => {
if (sourceContent === undefined) return undefined
let cancelled = false
const timeout = window.setTimeout(() => {
void buildTableOfContentsInWorker(title, sourceContent ?? '')
.then((nextToc) => {
if (!cancelled) setTocState({ noteKey, toc: nextToc })
})
.catch(() => {
if (!cancelled) setTocState({ noteKey, toc: titleOnlyToc })
})
}, TOC_BUILD_DEBOUNCE_MS)
return () => {
cancelled = true
window.clearTimeout(timeout)
}
}, [noteKey, sourceContent, title, titleOnlyToc])
useEffect(() => {
if (sourceContent !== undefined) return undefined
const timeout = window.setTimeout(() => {
setTocState({ noteKey, toc: buildTableOfContents(title, editor.document ?? []) })
}, TOC_BUILD_DEBOUNCE_MS)
return () => window.clearTimeout(timeout)
}, [editor.document, noteKey, sourceContent, title])
return tocState.noteKey === noteKey ? tocState.toc : titleOnlyToc
}
function useTocNavigation(editor: TableOfContentsEditor, title: string) {
return useCallback((item: TocItem) => {
const blockId = resolveTocItemBlockId(title, item, editor.document ?? [])
if (!blockId) return
try {
editor.setTextCursorPosition?.(blockId, 'start')
} catch {
// BlockNote can transiently reject selection while a note swap settles.
}
editor.focus?.()
scrollBlockIntoView(blockId)
trackEvent('table_of_contents_heading_selected')
}, [editor, title])
}
function TocRow({
depth,
item,
onNavigate,
}: {
depth: number
item: TocItem
onNavigate: (item: TocItem) => void
}) {
const hasChildren = item.children.length > 0
const depthIndent = getFolderDepthIndent(depth)
const contentInset = FOLDER_ROW_CONTENT_INSET
return (
<div
className="group relative flex items-center gap-1 rounded text-foreground transition-colors hover:bg-accent"
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
>
<Button
type="button"
variant="ghost"
className="h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium text-foreground hover:bg-transparent hover:text-foreground"
style={{
paddingTop: 6,
paddingBottom: 6,
paddingLeft: contentInset,
paddingRight: 16,
}}
title={item.title}
aria-expanded={hasChildren ? true : undefined}
onClick={() => onNavigate(item)}
>
<HeadingIcon level={item.level} />
<span className="truncate">{item.title}</span>
</Button>
</div>
)
}
function TocChildren({
depth,
item,
onNavigate,
}: {
depth: number
item: TocItem
onNavigate: (item: TocItem) => void
}) {
if (item.children.length === 0) return null
return (
<div className="relative" data-testid={`toc-children:${item.id}`}>
<div
className="absolute top-0 bottom-0 bg-border"
data-testid={`toc-connector:${item.id}`}
style={{ left: getFolderConnectorLeft(depth), width: 1 }}
/>
{item.children.map((child) => (
<TocItemNode
key={child.id}
depth={depth + 1}
item={child}
onNavigate={onNavigate}
/>
))}
</div>
)
}
function TocItemNode({
depth,
item,
onNavigate,
}: {
depth: number
item: TocItem
onNavigate: (item: TocItem) => void
}) {
return (
<>
<TocRow
depth={depth}
item={item}
onNavigate={onNavigate}
/>
<TocChildren
depth={depth}
item={item}
onNavigate={onNavigate}
/>
</>
)
}
function TableOfContentsHeader({ locale = 'en', onClose }: Pick<TableOfContentsPanelProps, 'locale' | 'onClose'>) {
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '6px 12px', gap: 8, cursor: 'default' }}
>
<ListBullets size={16} className="shrink-0 text-muted-foreground" />
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{translate(locale, 'tableOfContents.title')}
</span>
<span className="flex-1" />
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={onClose}
aria-label={translate(locale, 'tableOfContents.close')}
title={translate(locale, 'tableOfContents.close')}
>
<X size={16} />
</Button>
</div>
)
}
export const TableOfContentsPanel = memo(function TableOfContentsPanel({
editor,
entry,
locale = 'en',
onClose,
sourceContent,
}: TableOfContentsPanelProps) {
const title = entry?.title ?? translate(locale, 'tableOfContents.untitledHeading')
const noteKey = noteKeyForEntry(entry, title)
const titleOnlyToc = useMemo(() => buildTitleOnlyToc(title), [title])
const toc = useDebouncedToc({
editor,
noteKey,
sourceContent,
title,
titleOnlyToc,
})
const navigateToItem = useTocNavigation(editor, title)
return (
<aside className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground">
<TableOfContentsHeader locale={locale} onClose={onClose} />
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3" data-testid="table-of-contents-panel">
<TocItemNode
depth={0}
item={toc}
onNavigate={navigateToItem}
/>
</div>
</aside>
)
})

View File

@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TldrawWhiteboard } from './TldrawWhiteboard'
interface MockTldrawProps {
assetUrls: MockAssetUrls
}
interface MockAssetUrls {
embedIcons: Record<string, string>
fonts: Record<string, string>
icons: Record<string, string>
translations: Record<string, string>
}
const tldrawMock = vi.hoisted(() => ({
Tldraw: vi.fn(),
}))
const assetImportMock = vi.hoisted(() => ({
getAssetUrlsByImport: vi.fn((formatAssetUrl: (assetUrl?: string) => string) => ({
embedIcons: {},
fonts: {
tldraw_draw: formatAssetUrl('/assets/Shantell_Sans-Informal_Regular.woff2'),
},
icons: {
'tool-pencil': `${formatAssetUrl('/assets/0_merged.svg')}#tool-pencil`,
},
translations: {
en: formatAssetUrl(undefined),
},
})),
}))
vi.mock('@tldraw/assets/imports.vite', () => assetImportMock)
vi.mock('tldraw', async () => {
const { createElement } = await import('react')
tldrawMock.Tldraw.mockImplementation(({ assetUrls }: MockTldrawProps) =>
createElement('div', {
'data-testid': 'mock-tldraw',
'data-draw-font-url': assetUrls.fonts.tldraw_draw,
})
)
return {
Box: class Box {
x: number
y: number
w: number
h: number
constructor(x: number, y: number, w: number, h: number) {
this.x = x
this.y = y
this.w = w
this.h = h
}
},
Tldraw: tldrawMock.Tldraw,
createTLStore: vi.fn(() => ({
listen: vi.fn(() => vi.fn()),
})),
getSnapshot: vi.fn(() => ({ document: {} })),
loadSnapshot: vi.fn(),
}
})
function renderedTldrawAssetUrls(): MockAssetUrls {
const props = tldrawMock.Tldraw.mock.calls[0]?.[0] as MockTldrawProps
expect(props.assetUrls).toBeDefined()
return props.assetUrls
}
function expectNoCdnUrls(urls: Record<string, string>) {
Object.values(urls).forEach((url) => {
expect(url).not.toContain('cdn.tldraw.com')
})
}
function expectBundledTldrawAssetUrls(assetUrls: MockAssetUrls) {
expect(assetUrls.fonts.tldraw_draw).toContain('Shantell_Sans-Informal_Regular.woff2')
expect(assetUrls.icons['tool-pencil']).toContain('0_merged.svg#tool-pencil')
expect(assetUrls.translations.en).toBe('data:application/json;base64,e30K')
expectNoCdnUrls(assetUrls.fonts)
expectNoCdnUrls(assetUrls.icons)
expectNoCdnUrls(assetUrls.translations)
}
describe('TldrawWhiteboard', () => {
it('uses bundled tldraw assets instead of CDN URLs', () => {
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
expect(screen.getByTestId('mock-tldraw')).toHaveAttribute('data-draw-font-url')
expect(assetImportMock.getAssetUrlsByImport).toHaveBeenCalledWith(expect.any(Function))
expectBundledTldrawAssetUrls(renderedTldrawAssetUrls())
})
})

View File

@@ -1,13 +1,25 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
import {
Box,
Tldraw,
createTLStore,
getSnapshot,
loadSnapshot,
type Editor,
type TLEventInfo,
type TLStoreSnapshot,
} from 'tldraw'
import 'tldraw/tldraw.css'
const EMPTY_TLDRAW_TRANSLATION_URL = 'data:application/json;base64,e30K'
function resolveTldrawAssetUrl(assetUrl: string | undefined): string {
return assetUrl ?? EMPTY_TLDRAW_TRANSLATION_URL
}
const tldrawAssetUrls = getAssetUrlsByImport(resolveTldrawAssetUrl)
interface TldrawWhiteboardProps {
boardId: string
height: string
@@ -80,6 +92,90 @@ function serializeSnapshot(snapshot: TLStoreSnapshot): string {
return `${JSON.stringify(snapshot, null, 2)}\n`
}
function documentZoom(): number {
const inlineZoom = document.documentElement.style.getPropertyValue('zoom')
const computedZoom = getComputedStyle(document.documentElement).zoom
const zoom = inlineZoom || computedZoom
const parsed = Number.parseFloat(zoom)
if (!Number.isFinite(parsed) || parsed <= 0) return 1
return zoom.endsWith('%') ? parsed / 100 : parsed
}
function viewportBounds(screenBounds: Box | HTMLElement): Box | HTMLElement {
if (screenBounds instanceof Box) return screenBounds
const zoom = documentZoom()
if (zoom === 1) return screenBounds
const rect = screenBounds.getBoundingClientRect()
return new Box(
(rect.left || rect.x) / zoom,
(rect.top || rect.y) / zoom,
Math.max(rect.width / zoom, 1),
Math.max(rect.height / zoom, 1),
)
}
function zoomAdjustedPoint<T extends { x: number; y: number; z?: number }>(point: T, zoom: number): T {
return {
...point,
x: point.x / zoom,
y: point.y / zoom,
}
}
function zoomAdjustedEvent(info: TLEventInfo): TLEventInfo {
const zoom = documentZoom()
if (zoom === 1) return info
switch (info.type) {
case 'click':
case 'pinch':
case 'pointer':
case 'wheel':
return {
...info,
point: zoomAdjustedPoint(info.point, zoom),
} as TLEventInfo
default:
return info
}
}
function installZoomAwareViewport(editor: Editor): () => void {
const updateViewportScreenBounds = editor.updateViewportScreenBounds.bind(editor)
const updateViewport: Editor['updateViewportScreenBounds'] = (screenBounds, center) =>
updateViewportScreenBounds(viewportBounds(screenBounds), center)
const dispatch = editor.dispatch.bind(editor)
const animationFrameIds: number[] = []
const timeoutIds: number[] = []
editor.updateViewportScreenBounds = updateViewport
editor.dispatch = (info: TLEventInfo) => dispatch(zoomAdjustedEvent(info))
const updateCurrentCanvas = () => {
const canvas = editor.getContainer().querySelector<HTMLElement>('.tl-canvas')
if (canvas) updateViewport(canvas)
}
const scheduleViewportUpdate = () => {
updateCurrentCanvas()
animationFrameIds.push(window.requestAnimationFrame(updateCurrentCanvas))
timeoutIds.push(window.setTimeout(updateCurrentCanvas, 150))
}
scheduleViewportUpdate()
window.addEventListener('laputa-zoom-change', scheduleViewportUpdate)
return () => {
window.removeEventListener('laputa-zoom-change', scheduleViewportUpdate)
animationFrameIds.forEach((id) => window.cancelAnimationFrame(id))
timeoutIds.forEach((id) => window.clearTimeout(id))
editor.updateViewportScreenBounds = updateViewportScreenBounds
editor.dispatch = dispatch
}
}
export function TldrawWhiteboard({
boardId,
height,
@@ -182,10 +278,15 @@ export function TldrawWhiteboard({
<div
ref={boardRef}
className="tldraw-whiteboard"
contentEditable={false}
data-board-id={boardId}
style={cssSize(visibleSize)}
>
<Tldraw store={store} />
<Tldraw
assetUrls={tldrawAssetUrls}
onMount={installZoomAwareViewport}
store={store}
/>
<div
aria-label="Resize whiteboard width"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--width"

View File

@@ -0,0 +1,128 @@
import type { TranslationKey, TranslationValues } from '../lib/i18n'
import type { NoteWidthMode } from '../types'
import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility'
import {
SectionHeading,
SelectControl,
SettingsGroup,
SettingsRow,
SettingsSwitchRow,
} from './SettingsControls'
type Translate = (key: TranslationKey, values?: TranslationValues) => string
interface VaultContentSettingsSectionProps {
t: Translate
defaultNoteWidth: NoteWidthMode
setDefaultNoteWidth: (value: NoteWidthMode) => void
sidebarTypePluralizationEnabled: boolean
setSidebarTypePluralizationEnabled: (value: boolean) => void
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
hideGitignoredFiles: boolean
setHideGitignoredFiles: (value: boolean) => void
allNotesFileVisibility: AllNotesFileVisibility
setAllNotesFileVisibility: (value: AllNotesFileVisibility) => void
}
const NOTE_WIDTH_OPTIONS: readonly NoteWidthMode[] = ['normal', 'wide']
const NOTE_WIDTH_LABEL_KEYS: Record<NoteWidthMode, TranslationKey> = {
normal: 'settings.noteWidth.normal',
wide: 'settings.noteWidth.wide',
}
function buildNoteWidthOptions(t: Translate): Array<{ value: NoteWidthMode; label: string }> {
return NOTE_WIDTH_OPTIONS.map((value) => ({
value,
label: t(NOTE_WIDTH_LABEL_KEYS[value]),
}))
}
export function VaultContentSettingsSection({
t,
defaultNoteWidth,
setDefaultNoteWidth,
sidebarTypePluralizationEnabled,
setSidebarTypePluralizationEnabled,
initialH1AutoRename,
setInitialH1AutoRename,
hideGitignoredFiles,
setHideGitignoredFiles,
allNotesFileVisibility,
setAllNotesFileVisibility,
}: VaultContentSettingsSectionProps) {
const updateAllNotesFileVisibility = (patch: Partial<AllNotesFileVisibility>) => {
setAllNotesFileVisibility({ ...allNotesFileVisibility, ...patch })
}
return (
<>
<SectionHeading
title={t('settings.vaultContent.title')}
/>
<SettingsGroup>
<SettingsRow
label={t('settings.noteWidth.default')}
description={t('settings.noteWidth.defaultDescription')}
>
<SelectControl
ariaLabel={t('settings.noteWidth.default')}
value={defaultNoteWidth}
onValueChange={(value) => setDefaultNoteWidth(value as NoteWidthMode)}
options={buildNoteWidthOptions(t)}
testId="settings-default-note-width"
/>
</SettingsRow>
<SettingsSwitchRow
label={t('settings.sidebarTypePluralization.label')}
description={t('settings.sidebarTypePluralization.description')}
checked={sidebarTypePluralizationEnabled}
onChange={setSidebarTypePluralizationEnabled}
testId="settings-sidebar-type-pluralization"
/>
<SettingsSwitchRow
label={t('settings.titles.autoRename')}
description={t('settings.titles.autoRenameDescription')}
checked={initialH1AutoRename}
onChange={setInitialH1AutoRename}
testId="settings-initial-h1-auto-rename"
/>
<SettingsSwitchRow
label={t('settings.vaultContent.hideGitignored')}
description={t('settings.vaultContent.hideGitignoredDescription')}
checked={hideGitignoredFiles}
onChange={setHideGitignoredFiles}
testId="settings-hide-gitignored-files"
/>
<SettingsSwitchRow
label={t('settings.allNotesVisibility.pdfs')}
description={t('settings.allNotesVisibility.pdfsDescription')}
checked={allNotesFileVisibility.pdfs}
onChange={(checked) => updateAllNotesFileVisibility({ pdfs: checked })}
testId="settings-all-notes-show-pdfs"
/>
<SettingsSwitchRow
label={t('settings.allNotesVisibility.images')}
description={t('settings.allNotesVisibility.imagesDescription')}
checked={allNotesFileVisibility.images}
onChange={(checked) => updateAllNotesFileVisibility({ images: checked })}
testId="settings-all-notes-show-images"
/>
<SettingsSwitchRow
label={t('settings.allNotesVisibility.unsupported')}
description={t('settings.allNotesVisibility.unsupportedDescription')}
checked={allNotesFileVisibility.unsupported}
onChange={(checked) => updateAllNotesFileVisibility({ unsupported: checked })}
testId="settings-all-notes-show-unsupported"
/>
</SettingsGroup>
</>
)
}

View File

@@ -416,16 +416,37 @@ describe('WikilinkChatInput', () => {
fireEvent.input(editor)
expect(onDraftChange).toHaveBeenLastCalledWith(portugueseText)
const cjkKey = createEvent.keyDown(editor, { key: '你' })
fireEvent(editor, cjkKey)
const updatedEditor = screen.getByTestId('agent-input')
const cjkKey = createEvent.keyDown(updatedEditor, { key: '你' })
fireEvent(updatedEditor, cjkKey)
expect(cjkKey.defaultPrevented).toBe(false)
editor.textContent = `${portugueseText}`
setSelection(editor, portugueseText.length + 1)
fireEvent.input(editor)
updatedEditor.textContent = `${portugueseText}`
setSelection(updatedEditor, portugueseText.length + 1)
fireEvent.input(updatedEditor)
expect(onDraftChange).toHaveBeenLastCalledWith(`${portugueseText}`)
})
it('remounts after native DOM input so React does not diff a mutated contentEditable tree', async () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement
initialEditor.focus()
initialEditor.textContent = 'follow up after edit'
setSelection(initialEditor, 'follow up after edit'.length)
fireEvent.input(initialEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenLastCalledWith('follow up after edit')
})
const remountedEditor = screen.getByTestId('agent-input') as HTMLDivElement
expect(remountedEditor).not.toBe(initialEditor)
expect(remountedEditor.textContent).toBe('follow up after edit')
expect(document.activeElement).toBe(remountedEditor)
})
it('deletes an inline chip with a single Backspace', () => {
render(<Controlled />)
updateEditorText('edit my [[alp')

View File

@@ -24,11 +24,14 @@ type BreadcrumbActions = Pick<
| 'forceRawMode'
| 'showAIChat'
| 'onToggleAIChat'
| 'showTableOfContents'
| 'onToggleTableOfContents'
| 'inspectorCollapsed'
| 'onToggleInspector'
| 'showDiffToggle'
| 'onToggleFavorite'
| 'onToggleOrganized'
| 'onEnterNeighborhood'
| 'onRevealFile'
| 'onCopyFilePath'
| 'onDeleteNote'
@@ -187,10 +190,13 @@ function ActiveTabBreadcrumb({
forceRawMode={actions.forceRawMode}
showAIChat={actions.showAIChat}
onToggleAIChat={actions.onToggleAIChat}
showTableOfContents={actions.showTableOfContents}
onToggleTableOfContents={actions.onToggleTableOfContents}
inspectorCollapsed={actions.inspectorCollapsed}
onToggleInspector={actions.onToggleInspector}
onToggleFavorite={bindPath(actions.onToggleFavorite, path)}
onToggleOrganized={bindPath(actions.onToggleOrganized, path)}
onEnterNeighborhood={actions.onEnterNeighborhood}
onRevealFile={actions.onRevealFile}
onCopyFilePath={actions.onCopyFilePath}
onDelete={bindPath(actions.onDeleteNote, path)}
@@ -227,6 +233,8 @@ function EditorLoadingBreadcrumb({
forceRawMode={false}
showAIChat={actions.showAIChat}
onToggleAIChat={actions.onToggleAIChat}
showTableOfContents={actions.showTableOfContents}
onToggleTableOfContents={actions.onToggleTableOfContents}
inspectorCollapsed={actions.inspectorCollapsed}
onToggleInspector={actions.onToggleInspector}
noteWidth={actions.noteWidth}
@@ -246,11 +254,14 @@ function buildBreadcrumbActions(model: EditorContentModel): BreadcrumbActions {
forceRawMode: model.forceRawMode,
showAIChat: model.showAIChat,
onToggleAIChat: model.onToggleAIChat,
showTableOfContents: model.showTableOfContents,
onToggleTableOfContents: model.onToggleTableOfContents,
inspectorCollapsed: model.inspectorCollapsed,
onToggleInspector: model.onToggleInspector,
showDiffToggle: model.showDiffToggle,
onToggleFavorite: model.onToggleFavorite,
onToggleOrganized: model.onToggleOrganized,
onEnterNeighborhood: model.onEnterNeighborhood,
onRevealFile: model.onRevealFile,
onCopyFilePath: model.onCopyFilePath,
onDeleteNote: model.onDeleteNote,

View File

@@ -31,12 +31,15 @@ export interface EditorContentProps {
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
showTableOfContents?: boolean
onToggleTableOfContents?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
onEditorChange?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onDeleteNote?: (path: string) => void

View File

@@ -1,6 +1,6 @@
import { compactMarkdown } from '../utils/compact-markdown'
import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks'
import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { serializeDurableEditorBlocks } from '../utils/editorDurableMarkdown'
import { findNearestTextCursorBlockById } from './blockNoteCursorTarget'
interface BlockLike {
@@ -133,11 +133,11 @@ function getLineIndexFromRatio({ totalLines, ratio }: { totalLines: number; rati
}
function serializeBlock(editor: BlockNotePositionEditor, block: BlockLike): string {
return compactMarkdown(serializeMermaidAwareBlocks(editor, restoreWikilinksInBlocks([block])))
return compactMarkdown(serializeDurableEditorBlocks(editor, restoreWikilinksInBlocks([block])))
}
function serializeEditorBody(editor: BlockNotePositionEditor): string {
return compactMarkdown(serializeMermaidAwareBlocks(editor, restoreWikilinksInBlocks(editor.document)))
return compactMarkdown(serializeDurableEditorBlocks(editor, restoreWikilinksInBlocks(editor.document)))
}
function buildBlockLineRanges({

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown'
import { serializeEditorDocumentToMarkdown } from './editorRawModeSync'
import { TLDRAW_BLOCK_TYPE } from '../utils/tldrawMarkdown'
import { serializeEditorDocumentToMarkdown, syncActiveTabIntoRawBuffer } from './editorRawModeSync'
describe('editorRawModeSync Mermaid serialization', () => {
it('keeps the original fenced Mermaid source when rich content enters raw mode', () => {
@@ -28,4 +29,39 @@ describe('editorRawModeSync Mermaid serialization', () => {
'---\ntitle: Flow\n---\n\n# Flow\n',
)).toBe(`---\ntitle: Flow\n---\n${source}\n`)
})
it('serializes durable blocks into raw mode even when no pending rich edit was flushed', () => {
const rawLatestContentRef = { current: null as string | null }
const editor = {
document: [{
id: 'board-1',
type: TLDRAW_BLOCK_TYPE,
props: {
boardId: 'planning-map',
height: '520',
snapshot: '{}',
width: '',
},
children: [],
}],
blocksToMarkdownLossy: vi.fn(),
}
const synced = syncActiveTabIntoRawBuffer({
editor: editor as never,
activeTabPath: 'note/whiteboard-embed.md',
activeTabContent: [
'# Whiteboard Embed',
'',
'```tldraw id="planning-map"',
'{}',
'```',
].join('\n'),
rawLatestContentRef,
serializeRichEditorContent: false,
})
expect(synced).toContain('```tldraw id="planning-map" height="520"')
expect(rawLatestContentRef.current).toBe(synced)
})
})

View File

@@ -2,7 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { hasDurableEditorBlocks, serializeDurableEditorBlocks } from '../utils/editorDurableMarkdown'
import { portableImageUrls } from '../utils/vaultImages'
interface Tab {
@@ -30,7 +30,7 @@ export function serializeEditorDocumentToMarkdown(
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const rawBodyMarkdown = compactMarkdown(serializeMermaidAwareBlocks(editor, restored))
const rawBodyMarkdown = compactMarkdown(serializeDurableEditorBlocks(editor, restored))
const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
@@ -91,7 +91,8 @@ export function syncActiveTabIntoRawBuffer(options: {
} = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeRichEditorContent
const shouldSerializeRichEditorContent = serializeRichEditorContent || hasDurableEditorBlocks(editor.document)
const syncedContent = shouldSerializeRichEditorContent
? serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
: activeTabContent
rawLatestContentRef.current = syncedContent

View File

@@ -16,6 +16,7 @@ import { createTolariaCodeBlockOptions } from './codeBlockOptions'
import { NoteTitleIcon } from './NoteTitleIcon'
import { MermaidDiagram } from './MermaidDiagram'
import { SafeHtmlSpan } from './SafeMarkup'
import { updateTldrawBlockPropsSafely } from './tldrawBlockProps'
const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({
default: module.TldrawWhiteboard,
@@ -177,6 +178,7 @@ const TldrawBlock = createReactBlockSpec(
},
{
runsBefore: ['codeBlock'],
meta: { selectable: false },
render: (props) => (
<Suspense fallback={<div className="tldraw-whiteboard tldraw-whiteboard--loading" />}>
<TldrawWhiteboard
@@ -185,25 +187,24 @@ const TldrawBlock = createReactBlockSpec(
snapshot={props.block.props.snapshot}
width={props.block.props.width}
onSnapshotChange={(snapshot) => {
props.editor.updateBlock(props.block, {
type: TLDRAW_BLOCK_TYPE,
props: {
boardId: props.block.props.boardId,
height: props.block.props.height,
updateTldrawBlockPropsSafely({
blockId: props.block.id,
editor: props.editor,
nextProps: (currentProps) => ({
...currentProps,
snapshot,
width: props.block.props.width,
},
}),
})
}}
onSizeChange={(size) => {
props.editor.updateBlock(props.block, {
type: TLDRAW_BLOCK_TYPE,
props: {
boardId: props.block.props.boardId,
updateTldrawBlockPropsSafely({
blockId: props.block.id,
editor: props.editor,
nextProps: (currentProps) => ({
...currentProps,
height: size.height,
snapshot: props.block.props.snapshot,
width: size.width,
},
}),
})
}}
/>

View File

@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest'
import {
createImeCompositionKeyGuardExtension,
shouldStopComposingEnterKey,
} from './imeCompositionKeyGuardExtension'
type KeyListener = (event: KeyboardEvent) => void
function createKeyboardEvent(event: Partial<KeyboardEvent> = {}) {
return {
code: '',
isComposing: false,
key: 'Enter',
keyCode: 13,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
...event,
} as KeyboardEvent & {
preventDefault: ReturnType<typeof vi.fn>
stopImmediatePropagation: ReturnType<typeof vi.fn>
}
}
function createFixture() {
let keydownListener: KeyListener | null = null
const view = { composing: false }
const dom = {
addEventListener: vi.fn((type: string, listener: KeyListener) => {
if (type === 'keydown') {
keydownListener = listener
}
}),
}
const editor = {
_tiptapEditor: { view },
prosemirrorView: view,
}
const extension = createImeCompositionKeyGuardExtension()({ editor: editor as never })
return {
dom,
extension,
fireKeydown(event: Partial<KeyboardEvent> = {}) {
if (!keydownListener) {
throw new Error('IME composition key guard did not register a keydown listener')
}
const keyboardEvent = createKeyboardEvent(event)
keydownListener(keyboardEvent)
return keyboardEvent
},
mount() {
const controller = new AbortController()
extension.mount?.({
dom: dom as never,
root: document,
signal: controller.signal,
})
return controller
},
view,
}
}
describe('shouldStopComposingEnterKey', () => {
it('matches Enter while the native event is composing', () => {
const event = createKeyboardEvent({ isComposing: true })
expect(shouldStopComposingEnterKey(event, { composing: false })).toBe(true)
})
it('matches Enter while the ProseMirror view is still composing', () => {
const event = createKeyboardEvent({ isComposing: false })
expect(shouldStopComposingEnterKey(event, { composing: true })).toBe(true)
})
it('leaves normal Enter available for list editing', () => {
const event = createKeyboardEvent({ isComposing: false })
expect(shouldStopComposingEnterKey(event, { composing: false })).toBe(false)
})
it('leaves non-Enter composition keys alone', () => {
const event = createKeyboardEvent({ isComposing: true, key: 'a', keyCode: 65 })
expect(shouldStopComposingEnterKey(event, { composing: false })).toBe(false)
})
})
describe('createImeCompositionKeyGuardExtension', () => {
it('registers a capture keydown listener when the editor mounts', () => {
const fixture = createFixture()
fixture.mount()
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
expect.objectContaining({
capture: true,
signal: expect.any(AbortSignal),
}),
)
})
it('stops composing Enter before BlockNote list shortcuts can split the item', () => {
const fixture = createFixture()
fixture.mount()
const event = fixture.fireKeydown({ isComposing: true })
expect(event.stopImmediatePropagation).toHaveBeenCalledTimes(1)
expect(event.preventDefault).not.toHaveBeenCalled()
})
it('guards Enter while ProseMirror still reports composition', () => {
const fixture = createFixture()
fixture.view.composing = true
fixture.mount()
const event = fixture.fireKeydown({ isComposing: false })
expect(event.stopImmediatePropagation).toHaveBeenCalledTimes(1)
expect(event.preventDefault).not.toHaveBeenCalled()
})
it('does not intercept normal Enter outside IME composition', () => {
const fixture = createFixture()
fixture.mount()
const event = fixture.fireKeydown()
expect(event.stopImmediatePropagation).not.toHaveBeenCalled()
expect(event.preventDefault).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,43 @@
import { createExtension } from '@blocknote/core'
interface ComposingEditorView {
composing?: boolean
}
function isComposingKeyEvent(event: KeyboardEvent, view?: ComposingEditorView | null): boolean {
return event.isComposing || event.keyCode === 229 || Boolean(view?.composing)
}
function isEnterKey(event: KeyboardEvent): boolean {
return event.key === 'Enter'
|| event.code === 'Enter'
|| event.code === 'NumpadEnter'
|| event.keyCode === 13
}
export function shouldStopComposingEnterKey(
event: KeyboardEvent,
view?: ComposingEditorView | null,
): boolean {
return isEnterKey(event) && isComposingKeyEvent(event, view)
}
export const createImeCompositionKeyGuardExtension = createExtension(({ editor }) => {
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldStopComposingEnterKey(event, readView())) return
event.stopImmediatePropagation()
}
return {
key: 'imeCompositionKeyGuard',
mount: ({ dom, signal }) => {
dom.addEventListener('keydown', handleKeyDown, {
capture: true,
signal,
})
},
} as const
})

View File

@@ -1,4 +1,4 @@
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { SidebarSimple, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { useDragRegion } from '../../hooks/useDragRegion'
@@ -35,6 +35,16 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
const { onMouseDown } = useDragRegion()
const propertiesTitle = translate(locale, 'inspector.title.properties')
const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor)
const propertiesIcon = (testId?: string) => (
<SidebarSimple
size={16}
weight="regular"
className="shrink-0 text-muted-foreground"
style={{ transform: 'scaleX(-1)' }}
data-testid={testId}
/>
)
const toggleLabel = translate(locale, collapsed ? 'inspector.title.propertiesShortcut' : 'inspector.title.closePropertiesShortcut')
return (
<div
@@ -46,13 +56,21 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={translate(locale, 'inspector.title.propertiesShortcut')}
title={toggleLabel}
aria-label={toggleLabel}
>
<SlidersHorizontal size={16} />
{propertiesIcon('properties-panel-icon')}
</button>
) : (
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={toggleLabel}
aria-label={toggleLabel}
>
{propertiesIcon('properties-panel-icon')}
</button>
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
{showWarnings && onOpenRawEditor && (
<FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} />
@@ -61,7 +79,8 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={translate(locale, 'inspector.title.closePropertiesShortcut')}
title={toggleLabel}
aria-label={toggleLabel}
>
<X size={16} />
</button>

View File

@@ -17,9 +17,11 @@ import { LinkButton } from './LinkButton'
import {
PROPERTY_PANEL_GRID_STYLE,
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME,
} from '../propertyPanelLayout'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { translate, type AppLocale } from '../../lib/i18n'
import { canonicalFrontmatterKey } from '../../utils/systemMetadata'
const RELATIONSHIP_SECTION_ROW_CLASS_NAME = 'flex min-w-0 flex-col gap-1 px-1.5'
const RELATIONSHIPS_PANEL_GRID_CLASS_NAME = 'grid min-w-0 gap-x-2 gap-y-3'
@@ -27,6 +29,7 @@ const RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME = 'min-w-0 flex-1 truncate'
const RELATIONSHIP_SECTION_VALUE_CLASS_NAME = 'min-w-0'
const RELATIONSHIP_ACTION_ROW_CLASS_NAME = 'min-w-0 px-1.5'
const SUGGESTED_RELATIONSHIPS = ['belongs_to', 'related_to', 'has'] as const
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
type RelationshipEntryGroup = {
key: string
@@ -65,16 +68,22 @@ interface TitleSelectionState {
trimmed: string
}
function RelationshipSectionRow({ label, children, dataTestId }: {
function RelationshipSectionRow({ label, children, dataTestId, placeholder = false }: {
label: string
children: ReactNode
dataTestId?: string
locale: AppLocale
placeholder?: boolean
}) {
const labelClassName = placeholder ? PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME : PROPERTY_PANEL_LABEL_CLASS_NAME
const labelTextClassName = placeholder
? `${RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME} text-muted-foreground/40`
: RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME
return (
<div className={RELATIONSHIP_SECTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }} data-testid={dataTestId}>
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} data-testid="relationship-section-label">
<span className={RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME}>{humanizePropertyKey(label)}</span>
<span className={labelClassName} data-testid="relationship-section-label">
<span className={labelTextClassName}>{humanizePropertyKey(label)}</span>
</span>
<div className={RELATIONSHIP_SECTION_VALUE_CLASS_NAME}>{children}</div>
</div>
@@ -481,6 +490,56 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function findTypeEntry(entries: VaultEntry[], entry?: VaultEntry): VaultEntry | undefined {
if (!entry?.isA || entry.isA === 'Type') return undefined
return entries.find((candidate) => candidate.isA === 'Type' && candidate.title === entry.isA)
}
function isRelationshipSchemaKey(key: string): boolean {
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
}
function buildExistingRelationshipKeys(frontmatter: ParsedFrontmatter, relationshipEntries: RelationshipEntryGroup[]): Set<string> {
const existingKeys = new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
for (const group of relationshipEntries) existingKeys.add(canonicalFrontmatterKey(group.key))
return existingKeys
}
function buildTypeDerivedRelationshipEntries({
entry,
entries,
frontmatter,
relationshipEntries,
}: {
entry?: VaultEntry
entries: VaultEntry[]
frontmatter: ParsedFrontmatter
relationshipEntries: RelationshipEntryGroup[]
}): RelationshipEntryGroup[] {
const typeEntry = findTypeEntry(entries, entry)
if (!typeEntry) return []
const existingKeys = buildExistingRelationshipKeys(frontmatter, relationshipEntries)
const result: RelationshipEntryGroup[] = []
const seen = new Set<string>()
const addPlaceholder = (key: string) => {
const canonicalKey = canonicalFrontmatterKey(key)
if (canonicalKey === 'type' || existingKeys.has(canonicalKey) || seen.has(canonicalKey)) return
seen.add(canonicalKey)
result.push({ key, refs: [] })
}
for (const [key, refs] of Object.entries(typeEntry.relationships ?? {})) {
if (refs.length > 0) addPlaceholder(key)
}
for (const key of Object.keys(typeEntry.properties ?? {})) {
if (isRelationshipSchemaKey(key)) addPlaceholder(key)
}
return result
}
function NoteTargetInput({ entries, value, locale, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
@@ -602,6 +661,7 @@ function useMissingSuggestedRelationships(
}
function useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
@@ -609,21 +669,30 @@ function useRelationshipPanelState({
onUpdateProperty,
onDeleteProperty,
}: {
entry?: VaultEntry
frontmatter: ParsedFrontmatter
entries: VaultEntry[]
vaultPath?: string
} & RelationshipPanelEditHandlers) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const typeDerivedRelationshipEntries = useMemo(
() => buildTypeDerivedRelationshipEntries({ entry, entries, frontmatter, relationshipEntries }),
[entry, entries, frontmatter, relationshipEntries],
)
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
const { handleRemoveRef, handleAddRef, canEdit } = useRelationshipMutations(relationshipEntries, {
onAddProperty,
onUpdateProperty,
onDeleteProperty,
})
const missingSuggestedRels = useMissingSuggestedRelationships(relationshipEntries, onAddProperty)
const missingSuggestedRels = useMissingSuggestedRelationships(
[...relationshipEntries, ...typeDerivedRelationshipEntries],
onAddProperty,
)
return {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
@@ -717,16 +786,17 @@ function DisabledLinkButton({ locale }: { locale: AppLocale }) {
)
}
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote, dataTestId = 'suggested-relationship' }: {
label: string
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
dataTestId?: string
}) {
return (
<RelationshipSectionRow label={label} dataTestId="suggested-relationship" locale={locale}>
<RelationshipSectionRow label={label} dataTestId={dataTestId} locale={locale} placeholder>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
@@ -738,8 +808,8 @@ function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, o
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
export function DynamicRelationshipsPanel({ entry, frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
entry?: VaultEntry; frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
@@ -749,12 +819,14 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
}) {
const {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
} = useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
@@ -774,6 +846,18 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
locale={locale}
/>
))}
{onAddProperty && typeDerivedRelationshipEntries.map(({ key }) => (
<SuggestedRelationshipSlot
key={`type-derived:${key}`}
label={key}
entries={entries}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty(key, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
dataTestId="type-derived-relationship"
/>
))}
{missingSuggestedRels.map(label => (
<SuggestedRelationshipSlot
key={label}

View File

@@ -157,12 +157,12 @@ export function useSidebarInlineRenameInput({
}
}
export function useSidebarSections(entries: VaultEntry[]) {
export function useSidebarSections(entries: VaultEntry[], pluralizeTypeLabels = true) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const sections = buildDynamicSections(entries, typeEntryMap)
const sections = buildDynamicSections(entries, typeEntryMap, pluralizeTypeLabels)
return sortSections(sections, typeEntryMap)
}, [entries, typeEntryMap])
}, [entries, pluralizeTypeLabels, typeEntryMap])
const visibleSections = useMemo(
() => allSectionGroups.filter((group) => typeEntryMap[group.type]?.visible !== false),
[allSectionGroups, typeEntryMap],

View File

@@ -1,8 +1,9 @@
import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react'
import type { ReactElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AiAgentsBadge } from './AiAgentsBadge'
import type { AiModelProvider } from '../../lib/aiTargets'
vi.mock('../../utils/url', async () => {
const actual = await vi.importActual('../../utils/url')
@@ -17,62 +18,106 @@ const installedStatuses = {
gemini: { status: 'installed' as const, version: '0.5.1' },
}
const openAiProvider: AiModelProvider = {
id: 'openai',
name: 'OpenAI',
kind: 'open_ai',
base_url: null,
api_key_storage: 'local_file',
api_key_env_var: 'OPENAI_API_KEY',
headers: null,
models: [{
id: 'gpt-5.5',
display_name: null,
context_window: null,
max_output_tokens: null,
capabilities: {
streaming: true,
tools: false,
vision: true,
json_mode: true,
reasoning: true,
},
}],
}
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
type AiAgentsBadgeTestProps = ComponentProps<typeof AiAgentsBadge>
function renderBadge(props: Partial<AiAgentsBadgeTestProps> = {}) {
return render(
<AiAgentsBadge
statuses={installedStatuses}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
{...props}
/>,
)
}
function focusAiAgentsTrigger() {
const trigger = screen.getByTestId('status-ai-agents')
act(() => {
trigger.focus()
})
return trigger
}
function openAiAgentsMenu() {
const trigger = screen.getByTestId('status-ai-agents')
act(() => {
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
}
describe('AiAgentsBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the dropdown trigger off the Radix tooltip popper path', () => {
render(
<AiAgentsBadge
statuses={installedStatuses}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
/>,
)
renderBadge()
const trigger = screen.getByTestId('status-ai-agents')
const trigger = focusAiAgentsTrigger()
expect(trigger).toHaveAttribute('data-tooltip-mode', 'native-title')
expect(trigger.getAttribute('title')).toContain('Claude Code')
act(() => {
trigger.focus()
})
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
act(() => {
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
openAiAgentsMenu()
expect(screen.getByTestId('status-ai-agents-menu')).toBeInTheDocument()
})
it('selects only the active model target when an API model is the default target', () => {
renderBadge({
defaultTarget: 'model:openai/gpt-5.5',
providers: [openAiProvider],
onSetDefaultTarget: vi.fn(),
})
openAiAgentsMenu()
expect(screen.getByText(/Default AI target: OpenAI.*gpt-5\.5/)).toBeInTheDocument()
expect(screen.getByRole('menuitemradio', { name: /Claude Code/ })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('menuitemradio', { name: /OpenAI.*gpt-5\.5/ })).toHaveAttribute('aria-checked', 'true')
})
it('shows the vault guidance summary and restore action', async () => {
const onRestoreGuidance = vi.fn()
render(
<AiAgentsBadge
statuses={installedStatuses}
guidanceStatus={{
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
onRestoreGuidance={onRestoreGuidance}
/>,
)
act(() => {
const trigger = screen.getByTestId('status-ai-agents')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
renderBadge({
guidanceStatus: {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
},
onRestoreGuidance,
})
openAiAgentsMenu()
expect(screen.getByTestId('status-ai-guidance-summary')).toHaveTextContent('Tolaria guidance missing or broken')
act(() => {
@@ -84,26 +129,16 @@ describe('AiAgentsBadge', () => {
it('supports opening the menu and restoring guidance from the keyboard', () => {
const onRestoreGuidance = vi.fn()
render(
<AiAgentsBadge
statuses={installedStatuses}
guidanceStatus={{
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
onRestoreGuidance={onRestoreGuidance}
/>,
)
act(() => {
const trigger = screen.getByTestId('status-ai-agents')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
renderBadge({
guidanceStatus: {
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
},
onRestoreGuidance,
})
openAiAgentsMenu()
const restoreItem = screen.getByTestId('status-ai-guidance-restore')
act(() => {

View File

@@ -14,6 +14,7 @@ import {
import {
configuredModelTargets,
resolveAiTarget,
type AiTarget,
type AiModelProvider,
} from '../../lib/aiTargets'
import type { Settings } from '../../types'
@@ -89,8 +90,12 @@ function triggerLabel(defaultAgent: AiAgentId): string {
return getAiAgentDefinition(defaultAgent).shortLabel
}
function menuHeading(locale: AppLocale, defaultAgent: AiAgentId, selectedAgentReady: boolean): string {
const agent = getAiAgentDefinition(defaultAgent).label
function menuHeading(locale: AppLocale, selectedTarget: AiTarget, selectedAgentReady: boolean): string {
if (selectedTarget.kind === 'api_model') {
return translate(locale, 'status.ai.defaultTarget', { target: selectedTarget.label })
}
const agent = selectedTarget.label
return selectedAgentReady
? translate(locale, 'status.ai.active', { agent })
: translate(locale, 'status.ai.unavailable', { agent })
@@ -174,18 +179,18 @@ function GuidanceMenuSection({
function AgentMenuContent({
statuses,
guidanceStatus,
defaultAgent,
defaultTarget,
providers = [],
selectedTarget,
selectedAgentReady,
onSetDefaultAgent,
onSetDefaultTarget,
onRestoreGuidance,
locale = 'en',
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
}: AiAgentsBadgeProps & { selectedTarget: AiTarget; selectedAgentReady: boolean }) {
const installedAgents = installedAgentDefinitions(statuses)
const missingAgents = missingAgentDefinitions(statuses)
const modelTargets = configuredModelTargets(providers)
const selectedAgentValue = selectedTarget.kind === 'agent' && selectedAgentReady ? selectedTarget.agent : undefined
return (
<DropdownMenuContent
@@ -194,12 +199,12 @@ function AgentMenuContent({
className="min-w-[18rem]"
data-testid="status-ai-agents-menu"
>
<DropdownMenuLabel>{menuHeading(locale, defaultAgent, selectedAgentReady)}</DropdownMenuLabel>
<DropdownMenuLabel>{menuHeading(locale, selectedTarget, selectedAgentReady)}</DropdownMenuLabel>
{installedAgents.length === 0 ? (
<DropdownMenuItem disabled>{translate(locale, 'status.ai.noAgents')}</DropdownMenuItem>
) : (
<DropdownMenuRadioGroup
value={selectedAgentReady ? defaultAgent : undefined}
value={selectedAgentValue}
onValueChange={(value) => {
onSetDefaultAgent?.(value as AiAgentId)
onSetDefaultTarget?.(`agent:${value}`)
@@ -217,7 +222,7 @@ function AgentMenuContent({
)}
<ModelTargetMenuSection
targets={modelTargets}
defaultTarget={defaultTarget}
selectedTarget={selectedTarget}
locale={locale}
onSetDefaultTarget={onSetDefaultTarget}
/>
@@ -246,12 +251,12 @@ function AgentMenuContent({
function ModelTargetMenuSection({
targets,
defaultTarget,
selectedTarget,
locale,
onSetDefaultTarget,
}: {
targets: ReturnType<typeof configuredModelTargets>
defaultTarget?: string
selectedTarget: AiTarget
locale: AppLocale
onSetDefaultTarget?: (target: string) => void
}) {
@@ -262,7 +267,7 @@ function ModelTargetMenuSection({
<DropdownMenuSeparator />
<DropdownMenuLabel>{translate(locale, 'status.ai.modelTargets')}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={defaultTarget}
value={selectedTarget.kind === 'api_model' ? selectedTarget.id : undefined}
onValueChange={(value) => onSetDefaultTarget?.(value)}
>
{targets.map((target) => (
@@ -337,6 +342,7 @@ export function AiAgentsBadge({
onSetDefaultAgent={onSetDefaultAgent}
onSetDefaultTarget={onSetDefaultTarget}
onRestoreGuidance={onRestoreGuidance}
selectedTarget={selectedTarget}
selectedAgentReady={selectedAgentReady}
locale={locale}
/>

View File

@@ -0,0 +1,27 @@
/// <reference lib="webworker" />
import { buildTableOfContentsFromMarkdownOnly, type TocItem } from './tableOfContentsModel'
interface TocWorkerRequest {
entryTitle: string
markdown: string
requestId: number
}
interface TocWorkerResponse {
requestId: number
toc: TocItem
}
const ctx = self as DedicatedWorkerGlobalScope
ctx.onmessage = (event: MessageEvent<TocWorkerRequest>) => {
const { entryTitle, markdown, requestId } = event.data
const response: TocWorkerResponse = {
requestId,
toc: buildTableOfContentsFromMarkdownOnly(entryTitle, markdown),
}
ctx.postMessage(response)
}
export {}

View File

@@ -0,0 +1,273 @@
type TocLevel = 1 | 2 | 3
interface TocInlineText {
type?: string
text?: string
}
interface TocBlock {
id?: string
type?: string
props?: { level?: number }
content?: TocInlineText[]
}
interface MarkdownHeading {
blockId?: string
level: TocLevel
title: string
}
interface HeadingTitlePair {
entryTitle: string
title: string
}
interface DuplicateHeadingCheck extends HeadingTitlePair {
headingIndex: number
level: TocLevel
}
interface VisibleHeadings {
headings: MarkdownHeading[]
titleBlockId?: string
}
interface HeadingBlockMatch {
blocks: unknown[]
entryTitle: string
headings: MarkdownHeading[]
}
export interface TocItem {
blockId?: string
children: TocItem[]
id: string
level: TocLevel
matchIndex?: number
title: string
}
function isTocLevel(value: number | undefined): value is TocLevel {
return value === 1 || value === 2 || value === 3
}
function headingText(block: TocBlock): string {
if (!Array.isArray(block.content)) return ''
return block.content
.filter((item) => item.type === 'text')
.map((item) => item.text ?? '')
.join('')
.trim()
}
function normalizeHeadingTitle({ title }: { title: string }): string {
return title.trim().replace(/\s+/g, ' ')
}
function sameHeadingTitle({ entryTitle, title }: HeadingTitlePair): boolean {
return normalizeHeadingTitle({ title }) === normalizeHeadingTitle({ title: entryTitle })
}
function isHeadingBlock(block: unknown): block is TocBlock {
return typeof block === 'object'
&& block !== null
&& !Array.isArray(block)
&& (block as TocBlock).type === 'heading'
&& isTocLevel((block as TocBlock).props?.level)
}
function tocLevelForBlock(block: TocBlock): TocLevel | null {
const level = block.props?.level
return isTocLevel(level) ? level : null
}
function nearestParent(stack: TocItem[], level: TocLevel): TocItem {
for (let index = stack.length - 1; index >= 0; index -= 1) {
const item = stack[index]
if (item && item.level < level) return item
}
return stack[0]
}
function appendTocHeading(stack: TocItem[], item: TocItem) {
const parent = nearestParent(stack, item.level)
parent.children.push(item)
stack[item.level] = item
stack.length = item.level + 1
}
function shouldSkipDuplicateTitleHeading({ entryTitle, title, level, headingIndex }: DuplicateHeadingCheck): boolean {
return headingIndex === 0
&& level === 1
&& sameHeadingTitle({ entryTitle, title })
}
function visibleHeadingsForEntry(entryTitle: string, headings: MarkdownHeading[]): VisibleHeadings {
return headings.reduce<VisibleHeadings>((result, heading, index) => {
if (shouldSkipDuplicateTitleHeading({
entryTitle,
title: heading.title,
level: heading.level,
headingIndex: index,
})) {
return { ...result, titleBlockId: heading.blockId }
}
result.headings.push(heading)
return result
}, { headings: [] })
}
export function buildTableOfContents(entryTitle: string, blocks: unknown[]): TocItem {
const root: TocItem = { id: 'toc-title', level: 1, title: entryTitle, children: [] }
const stack: TocItem[] = [root]
let headingCount = 0
blocks.forEach((block, index) => {
if (!isHeadingBlock(block)) return
const title = headingText(block)
if (!title) return
const level = tocLevelForBlock(block)
if (level === null) return
if (shouldSkipDuplicateTitleHeading({ entryTitle, title, level, headingIndex: headingCount })) {
root.blockId = block.id
headingCount += 1
return
}
const item: TocItem = {
blockId: block.id,
children: [],
id: block.id ?? `toc-heading-${index}`,
level,
matchIndex: headingCount - (root.blockId ? 1 : 0),
title,
}
appendTocHeading(stack, item)
headingCount += 1
})
return root
}
function stripFrontmatter({ markdown }: { markdown: string }): string {
if (!markdown.startsWith('---')) return markdown
const delimiter = markdown.indexOf('\n---', 3)
if (delimiter === -1) return markdown
const afterDelimiter = markdown.indexOf('\n', delimiter + 4)
return afterDelimiter === -1 ? '' : markdown.slice(afterDelimiter + 1)
}
function stripInlineMarkdown({ text }: { text: string }): string {
return text
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/[*_`~]/g, '')
.trim()
}
function parseMarkdownHeadings({ markdown }: { markdown: string }): MarkdownHeading[] {
return stripFrontmatter({ markdown })
.split('\n')
.map((line) => line.match(/^(#{1,3})\s+(.+?)\s*#*\s*$/))
.filter((match): match is RegExpMatchArray => match !== null)
.map((match) => ({
level: match[1].length as TocLevel,
title: stripInlineMarkdown({ text: match[2] }),
}))
.filter((heading) => heading.title.length > 0)
}
function parsedBlockHeadings(blocks: unknown[]): MarkdownHeading[] {
return blocks
.filter(isHeadingBlock)
.flatMap((block) => {
const level = tocLevelForBlock(block)
if (level === null) return []
return [{
blockId: block.id,
level,
title: headingText(block),
}]
})
.filter((heading) => heading.title.length > 0)
}
function blockIdsForMatchingHeadings({ blocks, entryTitle, headings }: HeadingBlockMatch): VisibleHeadings {
const visibleBlocks = visibleHeadingsForEntry(entryTitle, parsedBlockHeadings(blocks))
const visibleMarkdown = visibleHeadingsForEntry(entryTitle, headings)
if (visibleBlocks.headings.length !== visibleMarkdown.headings.length) return visibleMarkdown
const blockIds = visibleBlocks.headings.map((blockHeading, index) => {
const heading = visibleMarkdown.headings[index]
if (blockHeading.level !== heading.level) return undefined
if (!sameHeadingTitle({ entryTitle: blockHeading.title, title: heading.title })) return undefined
return blockHeading.blockId
})
if (blockIds.includes(undefined)) return visibleMarkdown
return {
headings: visibleMarkdown.headings.map((heading, index) => ({
...heading,
blockId: blockIds[index],
})),
titleBlockId: visibleBlocks.titleBlockId,
}
}
function tocItemMatchesHeading(item: TocItem, heading: MarkdownHeading | undefined): heading is MarkdownHeading {
return heading !== undefined
&& heading.level === item.level
&& sameHeadingTitle({ entryTitle: heading.title, title: item.title })
}
function indexedHeadingForTocItem(item: TocItem, headings: MarkdownHeading[]): MarkdownHeading | undefined {
return item.matchIndex === undefined ? undefined : headings[item.matchIndex]
}
function matchingHeadingForTocItem(item: TocItem, headings: MarkdownHeading[]): MarkdownHeading | undefined {
const indexedHeading = indexedHeadingForTocItem(item, headings)
return tocItemMatchesHeading(item, indexedHeading)
? indexedHeading
: headings.find((heading) => tocItemMatchesHeading(item, heading))
}
export function resolveTocItemBlockId(entryTitle: string, item: TocItem, blocks: unknown[]): string | undefined {
if (item.blockId) return item.blockId
const visibleBlocks = visibleHeadingsForEntry(entryTitle, parsedBlockHeadings(blocks))
if (item.id === 'toc-title') return visibleBlocks.titleBlockId
return matchingHeadingForTocItem(item, visibleBlocks.headings)?.blockId
}
export function buildTableOfContentsFromMarkdown(entryTitle: string, markdown: string, blocks: unknown[] = []): TocItem {
const headings = parseMarkdownHeadings({ markdown })
const visibleHeadings = blockIdsForMatchingHeadings({ blocks, entryTitle, headings })
const root: TocItem = {
blockId: visibleHeadings.titleBlockId,
id: 'toc-title',
level: 1,
title: entryTitle,
children: [],
}
const stack: TocItem[] = [root]
visibleHeadings.headings.forEach((heading, index) => {
appendTocHeading(stack, {
blockId: heading.blockId,
children: [],
id: heading.blockId ?? `toc-heading-${index}`,
level: heading.level,
matchIndex: index,
title: heading.title,
})
})
return root
}
export function buildTableOfContentsFromMarkdownOnly(entryTitle: string, markdown: string): TocItem {
return buildTableOfContentsFromMarkdown(entryTitle, markdown)
}

View File

@@ -0,0 +1,74 @@
import { buildTableOfContentsFromMarkdownOnly, type TocItem } from './tableOfContentsModel'
export const TOC_BUILD_DEBOUNCE_MS = 180
interface WorkerRequest {
entryTitle: string
markdown: string
requestId: number
}
interface WorkerResponse {
requestId: number
toc: TocItem
}
interface PendingRequest {
reject: (reason?: unknown) => void
resolve: (toc: TocItem) => void
}
let nextRequestId = 1
let tocWorker: Worker | null = null
const pendingRequests = new Map<number, PendingRequest>()
function rejectPendingRequests(reason: unknown) {
pendingRequests.forEach(({ reject }) => reject(reason))
pendingRequests.clear()
}
function buildTocWithoutWorker(entryTitle: string, markdown: string): Promise<TocItem> {
return new Promise((resolve) => {
window.setTimeout(() => {
resolve(buildTableOfContentsFromMarkdownOnly(entryTitle, markdown))
}, 0)
})
}
function handleWorkerMessage(event: MessageEvent<WorkerResponse>) {
const pending = pendingRequests.get(event.data.requestId)
if (!pending) return
pendingRequests.delete(event.data.requestId)
pending.resolve(event.data.toc)
}
function handleWorkerError(event: ErrorEvent) {
rejectPendingRequests(event.error ?? event.message)
tocWorker?.terminate()
tocWorker = null
}
function activeTocWorker(): Worker | null {
if (typeof Worker === 'undefined') return null
if (tocWorker) return tocWorker
tocWorker = new Worker(new URL('./tableOfContents.worker.ts', import.meta.url), { type: 'module' })
tocWorker.onmessage = handleWorkerMessage
tocWorker.onerror = handleWorkerError
return tocWorker
}
export function buildTableOfContentsInWorker(entryTitle: string, markdown: string): Promise<TocItem> {
const worker = activeTocWorker()
if (!worker) return buildTocWithoutWorker(entryTitle, markdown)
return new Promise((resolve, reject) => {
const requestId = nextRequestId
nextRequestId += 1
pendingRequests.set(requestId, { resolve, reject })
const request: WorkerRequest = { entryTitle, markdown, requestId }
worker.postMessage(request)
})
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import { TLDRAW_BLOCK_TYPE } from '../utils/tldrawMarkdown'
import {
updateTldrawBlockPropsSafely,
type TldrawBlockMutationEditor,
} from './tldrawBlockProps'
function tldrawBlock(props: {
boardId?: string
height?: string
id?: string
snapshot?: string
width?: string
}) {
return {
id: props.id ?? 'whiteboard-block',
props: {
boardId: props.boardId ?? 'planning-map',
height: props.height ?? '520',
snapshot: props.snapshot ?? '{}',
width: props.width ?? '',
},
type: TLDRAW_BLOCK_TYPE,
}
}
describe('tldraw block prop updates', () => {
it('ignores whiteboard callbacks after the owning BlockNote block disappears', () => {
const editor: TldrawBlockMutationEditor = {
getBlock: vi.fn(() => undefined),
updateBlock: vi.fn(),
}
expect(updateTldrawBlockPropsSafely({
blockId: 'whiteboard-block',
editor,
nextProps: (props) => ({ ...props, snapshot: '{ "store": {} }' }),
})).toBe(false)
expect(editor.updateBlock).not.toHaveBeenCalled()
})
it('resolves live whiteboard props before writing a debounced snapshot', () => {
const editor: TldrawBlockMutationEditor = {
getBlock: vi.fn(() => tldrawBlock({
boardId: 'fresh-board',
height: '640',
snapshot: '{ "store": "old" }',
width: '900',
})),
updateBlock: vi.fn(),
}
expect(updateTldrawBlockPropsSafely({
blockId: 'whiteboard-block',
editor,
nextProps: (props) => ({ ...props, snapshot: '{ "store": "next" }' }),
})).toBe(true)
expect(editor.updateBlock).toHaveBeenCalledWith('whiteboard-block', {
props: {
boardId: 'fresh-board',
height: '640',
snapshot: '{ "store": "next" }',
width: '900',
},
type: TLDRAW_BLOCK_TYPE,
})
})
it('turns a final missing-block race into a no-op', () => {
const missingBlockError = new Error('Block with ID whiteboard-block not found')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const editor: TldrawBlockMutationEditor = {
getBlock: vi.fn(() => tldrawBlock({})),
updateBlock: vi.fn(() => {
throw missingBlockError
}),
}
expect(updateTldrawBlockPropsSafely({
blockId: 'whiteboard-block',
editor,
nextProps: (props) => ({ ...props, height: '720' }),
})).toBe(false)
expect(warn).toHaveBeenCalledWith('[editor] Ignored stale whiteboard block update:', missingBlockError)
warn.mockRestore()
})
})

View File

@@ -0,0 +1,81 @@
import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown'
export interface TldrawBlockProps {
boardId: string
height: string
snapshot: string
width: string
}
export interface TldrawBlockMutationEditor {
getBlock: (blockId: string) => unknown
updateBlock: (blockId: string, update: TldrawBlockUpdate) => unknown
}
interface TldrawBlockUpdate {
props: TldrawBlockProps
type: typeof TLDRAW_BLOCK_TYPE
}
interface LiveTldrawBlock {
id: string
props: TldrawBlockProps
}
interface TldrawBlockMutation {
blockId: string
editor: TldrawBlockMutationEditor
nextProps: (props: TldrawBlockProps) => TldrawBlockProps
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringProp(value: unknown, fallback: string) {
return typeof value === 'string' ? value : fallback
}
function tldrawBlockProps(value: unknown): TldrawBlockProps | null {
if (!isRecord(value)) return null
if (typeof value.boardId !== 'string' || typeof value.snapshot !== 'string') return null
return {
boardId: value.boardId,
height: stringProp(value.height, TLDRAW_DEFAULT_HEIGHT),
snapshot: value.snapshot,
width: stringProp(value.width, ''),
}
}
function liveTldrawBlock(value: unknown): LiveTldrawBlock | null {
if (!isRecord(value)) return null
if (value.type !== TLDRAW_BLOCK_TYPE || typeof value.id !== 'string') return null
const props = tldrawBlockProps(value.props)
return props ? { id: value.id, props } : null
}
function isMissingBlockError(error: unknown) {
return error instanceof Error
&& error.message.includes('Block with ID')
&& error.message.includes('not found')
}
export function updateTldrawBlockPropsSafely({ blockId, editor, nextProps }: TldrawBlockMutation) {
const liveBlock = liveTldrawBlock(editor.getBlock(blockId))
if (!liveBlock) return false
try {
editor.updateBlock(liveBlock.id, {
props: nextProps(liveBlock.props),
type: TLDRAW_BLOCK_TYPE,
})
return true
} catch (error) {
if (!isMissingBlockError(error)) throw error
console.warn('[editor] Ignored stale whiteboard block update:', error)
return false
}
}

View File

@@ -330,35 +330,102 @@ describe('tolariaEditorFormatting behavior', () => {
})
it('hides the floating toolbar while the editor is composing IME text', () => {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
vi.useFakeTimers()
try {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
act(() => {
fireEvent.compositionStart(editorInput)
})
act(() => {
fireEvent.compositionStart(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
fireEvent.compositionEnd(editorInput)
})
act(() => {
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(250)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
} finally {
vi.useRealTimers()
}
})
it('keeps the floating toolbar hidden through rapid Zhuyin composition settle cycles', () => {
vi.useFakeTimers()
try {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
act(() => {
fireEvent.compositionStart(editorInput)
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(120)
fireEvent.compositionStart(editorInput)
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(249)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(1)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
} finally {
vi.useRealTimers()
}
})
it('ignores composition events that start outside the editor', () => {

View File

@@ -6,10 +6,31 @@ import type {
} from '@blocknote/core'
import { useEffect, useRef, useState } from 'react'
const COMPOSITION_SETTLE_MS = 250
function eventTargetsEditor(editorElement: Element, target: EventTarget | null) {
return target instanceof Node && editorElement.contains(target)
}
function focusTargetsEditor(editorElement: Element) {
const activeElement = editorElement.ownerDocument.activeElement
return activeElement instanceof Node && editorElement.contains(activeElement)
}
function selectionTargetsEditor(editorElement: Element) {
const anchorNode = editorElement.ownerDocument.getSelection()?.anchorNode
return anchorNode instanceof Node && editorElement.contains(anchorNode)
}
function compositionEventTargetsEditor(
editorElement: Element,
event: CompositionEvent,
) {
return eventTargetsEditor(editorElement, event.target)
|| focusTargetsEditor(editorElement)
|| selectionTargetsEditor(editorElement)
}
export function useEditorComposing<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
@@ -17,41 +38,82 @@ export function useEditorComposing<
>(editor: BlockNoteEditor<BSchema, ISchema, SSchema>) {
const [isComposing, setIsComposing] = useState(false)
const composingRef = useRef(false)
const settleTimeoutRef = useRef<number | null>(null)
const editorElement = editor.domElement ?? null
useEffect(() => {
const clearSettleTimeout = () => {
if (settleTimeoutRef.current === null) return
window.clearTimeout(settleTimeoutRef.current)
settleTimeoutRef.current = null
}
const updateComposing = (nextIsComposing: boolean) => {
if (composingRef.current === nextIsComposing) return
composingRef.current = nextIsComposing
setIsComposing(nextIsComposing)
}
const startComposing = () => {
clearSettleTimeout()
updateComposing(true)
}
const finishComposing = () => {
clearSettleTimeout()
settleTimeoutRef.current = window.setTimeout(() => {
settleTimeoutRef.current = null
updateComposing(false)
}, COMPOSITION_SETTLE_MS)
}
clearSettleTimeout()
updateComposing(false)
if (!editorElement) return
const handleCompositionStart = (event: CompositionEvent) => {
if (!eventTargetsEditor(editorElement, event.target)) return
updateComposing(true)
if (!compositionEventTargetsEditor(editorElement, event)) return
startComposing()
}
const handleCompositionUpdate = (event: CompositionEvent) => {
if (!compositionEventTargetsEditor(editorElement, event)) return
startComposing()
}
const handleCompositionEnd = (event: CompositionEvent) => {
if (
!composingRef.current
&& !eventTargetsEditor(editorElement, event.target)
&& !compositionEventTargetsEditor(editorElement, event)
) {
return
}
updateComposing(false)
finishComposing()
}
const handleCompositionCancel: EventListener = (event) => {
if (event instanceof CompositionEvent) {
handleCompositionEnd(event)
return
}
if (!composingRef.current) return
finishComposing()
}
document.addEventListener('compositionstart', handleCompositionStart, true)
document.addEventListener('compositionupdate', handleCompositionUpdate, true)
document.addEventListener('compositionend', handleCompositionEnd, true)
document.addEventListener('compositioncancel', handleCompositionCancel, true)
return () => {
clearSettleTimeout()
document.removeEventListener('compositionstart', handleCompositionStart, true)
document.removeEventListener('compositionupdate', handleCompositionUpdate, true)
document.removeEventListener('compositionend', handleCompositionEnd, true)
document.removeEventListener('compositioncancel', handleCompositionCancel, true)
}
}, [editorElement])

Some files were not shown because too many files have changed in this diff Show More