Compare commits
32 Commits
codex/mobi
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df9c1d707 | ||
|
|
ccfa148014 | ||
|
|
dda4943425 | ||
|
|
be87c3864c | ||
|
|
982d6476d7 | ||
|
|
92c0b895dc | ||
|
|
75a8444b41 | ||
|
|
d347cda9e3 | ||
|
|
f5adbf9cf7 | ||
|
|
19059fbf81 | ||
|
|
1694e3ad07 | ||
|
|
e46613b82b | ||
|
|
38ea51627d | ||
|
|
e5fa40b826 | ||
|
|
ff4a78f964 | ||
|
|
9973d8d4e6 | ||
|
|
e7e33c479d | ||
|
|
69b9da6da6 | ||
|
|
71ef8325a0 | ||
|
|
582d1d25d6 | ||
|
|
ebf4545d46 | ||
|
|
3c483ba0e6 | ||
|
|
71629763ee | ||
|
|
417e37f1d1 | ||
|
|
a3e3192b66 | ||
|
|
86c503c1f9 | ||
|
|
21a47b4a77 | ||
|
|
64d961bd98 | ||
|
|
1b218f5250 | ||
|
|
dfb9f98b7a | ||
|
|
6a033cd2db | ||
|
|
b872b6148c |
49
.github/workflows/release-stable.yml
vendored
49
.github/workflows/release-stable.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'stable-v*'
|
||||
- 'v20*'
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
@@ -34,14 +35,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 +576,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 +600,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 +745,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#*/}"
|
||||
|
||||
|
||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -69,10 +69,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 +786,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
3
.gitignore
vendored
@@ -73,3 +73,6 @@ CODE-HEALTH-REPORT.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local Codacy CLI runtime/config generated by the MCP server
|
||||
.codacy/
|
||||
|
||||
@@ -160,9 +160,9 @@ 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
|
||||
|
||||
@@ -170,6 +170,10 @@ The renderer may cache recently opened or preloaded markdown content, but cached
|
||||
|
||||
`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,6 +285,7 @@ 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.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
@@ -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.
|
||||
|
||||
@@ -546,21 +554,22 @@ 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.
|
||||
- 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
|
||||
@@ -570,7 +579,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- `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.
|
||||
- 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.
|
||||
- `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 +594,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 +612,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"]
|
||||
|
||||
@@ -676,10 +684,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]]`.
|
||||
|
||||
@@ -786,6 +795,7 @@ interface Settings {
|
||||
theme_mode: 'light' | 'dark' | 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 +806,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 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. `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
|
||||
|
||||
|
||||
@@ -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,19 +207,19 @@ 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.
|
||||
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. 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -323,12 +323,12 @@ tolaria/
|
||||
|
||||
| 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 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.
|
||||
|
||||
|
||||
32
docs/adr/0109-debounced-worker-derived-editor-indexes.md
Normal file
32
docs/adr/0109-debounced-worker-derived-editor-indexes.md
Normal 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.
|
||||
@@ -162,3 +162,4 @@ 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 |
|
||||
|
||||
16
lara.lock
16
lara.lock
@@ -134,6 +134,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
|
||||
@@ -421,6 +427,8 @@ files:
|
||||
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 +436,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
|
||||
|
||||
@@ -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
@@ -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
34
pnpm-lock.yaml
generated
@@ -16,10 +16,10 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: 93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4
|
||||
hash: c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d
|
||||
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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@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)
|
||||
|
||||
18
release-notes/stable-v2026.5.2.md
Normal file
18
release-notes/stable-v2026.5.2.md
Normal 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.
|
||||
20
release-notes/v2026-05-04.md
Normal file
20
release-notes/v2026-05-04.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
@@ -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));
|
||||
|
||||
@@ -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, ¤t_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"));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
117
src-tauri/src/vault/path_identity.rs
Normal file
117
src-tauri/src/vault/path_identity.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -654,6 +654,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)
|
||||
@@ -746,8 +779,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 () => {
|
||||
@@ -772,6 +803,8 @@ describe('App', () => {
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
|
||||
})
|
||||
|
||||
input.textContent = 'Summarize the active vault'
|
||||
fireEvent.input(input)
|
||||
fireEvent.click(screen.getByTestId('agent-send'))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1348,4 +1381,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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
36
src/App.tsx
36
src/App.tsx
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -425,3 +425,54 @@ 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()
|
||||
render(
|
||||
<BreadcrumbBar
|
||||
entry={baseEntry}
|
||||
{...defaultProps}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
})
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open table of contents' }))
|
||||
|
||||
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GitBranch,
|
||||
Code,
|
||||
Sparkle,
|
||||
ListBullets,
|
||||
SidebarSimple,
|
||||
Trash,
|
||||
Archive,
|
||||
@@ -46,6 +47,8 @@ interface BreadcrumbBarProps {
|
||||
forceRawMode?: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
showTableOfContents?: boolean
|
||||
onToggleTableOfContents?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
@@ -351,6 +354,24 @@ function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<Breadc
|
||||
)
|
||||
}
|
||||
|
||||
function TableOfContentsAction({
|
||||
showTableOfContents,
|
||||
locale = 'en',
|
||||
onToggleTableOfContents,
|
||||
}: Pick<BreadcrumbBarProps, 'showTableOfContents' | 'locale' | 'onToggleTableOfContents'>) {
|
||||
if (!onToggleTableOfContents) return null
|
||||
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents') }}
|
||||
onClick={onToggleTableOfContents}
|
||||
className={cn(showTableOfContents ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<ListBullets size={16} weight={showTableOfContents ? 'bold' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function ArchiveAction({
|
||||
archived,
|
||||
locale = 'en',
|
||||
@@ -796,6 +817,8 @@ function BreadcrumbActions({
|
||||
onToggleNoteWidth,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
showTableOfContents,
|
||||
onToggleTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onToggleFavorite,
|
||||
@@ -835,6 +858,13 @@ function BreadcrumbActions({
|
||||
<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>
|
||||
@@ -852,6 +882,8 @@ function BreadcrumbActions({
|
||||
onToggleDiff={onToggleDiff}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
showTableOfContents={showTableOfContents}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onArchive={onArchive}
|
||||
@@ -872,6 +904,8 @@ function BreadcrumbOverflowMenu({
|
||||
onToggleDiff,
|
||||
noteWidth,
|
||||
onToggleNoteWidth,
|
||||
showTableOfContents,
|
||||
onToggleTableOfContents,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onArchive,
|
||||
@@ -887,6 +921,8 @@ function BreadcrumbOverflowMenu({
|
||||
| 'onToggleDiff'
|
||||
| 'noteWidth'
|
||||
| 'onToggleNoteWidth'
|
||||
| 'showTableOfContents'
|
||||
| 'onToggleTableOfContents'
|
||||
| 'onRevealFile'
|
||||
| 'onCopyFilePath'
|
||||
| 'onArchive'
|
||||
@@ -901,6 +937,7 @@ function BreadcrumbOverflowMenu({
|
||||
const diffLabel = translate(locale, diffMenuLabelKey({ showDiffToggle, diffMode, diffLoading }))
|
||||
const noteWidthLabel = translate(locale, noteWidthLabelKey(noteWidth))
|
||||
const archiveLabel = translate(locale, archiveLabelKey(entry.archived))
|
||||
const tableOfContentsLabel = translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents')
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -927,6 +964,10 @@ function BreadcrumbOverflowMenu({
|
||||
<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')}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -236,6 +236,7 @@ describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
blockNoteCreation.options = []
|
||||
blockNoteViewState.onChange = null
|
||||
mockEditor.document = [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }]
|
||||
clearParsedNoteBlockCache()
|
||||
})
|
||||
|
||||
@@ -537,6 +538,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.
|
||||
|
||||
@@ -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,
|
||||
@@ -185,6 +186,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,6 +232,11 @@ 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()],
|
||||
})
|
||||
@@ -323,6 +363,8 @@ function EditorLayout({
|
||||
showDiffToggle,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
showTableOfContents,
|
||||
onToggleTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onNavigateWikilink,
|
||||
@@ -388,6 +430,8 @@ function EditorLayout({
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
showTableOfContents?: boolean
|
||||
onToggleTableOfContents?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
@@ -470,6 +514,8 @@ function EditorLayout({
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
showTableOfContents={showTableOfContents}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
@@ -494,11 +540,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 +561,7 @@ function EditorLayout({
|
||||
noteListFilter={noteListFilter}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
@@ -581,5 +630,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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -96,6 +96,7 @@ function ValidFrontmatterPanels({
|
||||
/>
|
||||
<Separator data-testid="inspector-properties-relationships-separator" />
|
||||
<DynamicRelationshipsPanel
|
||||
entry={entry}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -160,6 +160,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,
|
||||
@@ -273,6 +275,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} />
|
||||
@@ -580,6 +629,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(
|
||||
|
||||
@@ -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,6 +313,18 @@ 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)
|
||||
@@ -260,6 +345,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 +416,7 @@ function SettingsPanelInner({
|
||||
}, [explicitOrganizationEnabled, settings])
|
||||
|
||||
useSettingsPanelAutofocus(panelRef)
|
||||
useSettingsPanelFocusTrap(panelRef)
|
||||
|
||||
const updateDraft = useCallback(
|
||||
<Key extends keyof SettingsDraft>(key: Key, value: SettingsDraft[Key]) => {
|
||||
@@ -347,6 +444,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 +580,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 +712,10 @@ function SettingsSyncAndAppearanceSections({
|
||||
|
||||
function SettingsContentSections({
|
||||
t,
|
||||
defaultNoteWidth,
|
||||
setDefaultNoteWidth,
|
||||
sidebarTypePluralizationEnabled,
|
||||
setSidebarTypePluralizationEnabled,
|
||||
initialH1AutoRename,
|
||||
setInitialH1AutoRename,
|
||||
hideGitignoredFiles,
|
||||
@@ -621,6 +727,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}
|
||||
@@ -920,79 +1030,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[],
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
143
src/components/TableOfContentsPanel.test.tsx
Normal file
143
src/components/TableOfContentsPanel.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
288
src/components/TableOfContentsPanel.tsx
Normal file
288
src/components/TableOfContentsPanel.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
108
src/components/TldrawWhiteboard.test.tsx
Normal file
108
src/components/TldrawWhiteboard.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
128
src/components/VaultContentSettingsSection.tsx
Normal file
128
src/components/VaultContentSettingsSection.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -24,6 +24,8 @@ type BreadcrumbActions = Pick<
|
||||
| 'forceRawMode'
|
||||
| 'showAIChat'
|
||||
| 'onToggleAIChat'
|
||||
| 'showTableOfContents'
|
||||
| 'onToggleTableOfContents'
|
||||
| 'inspectorCollapsed'
|
||||
| 'onToggleInspector'
|
||||
| 'showDiffToggle'
|
||||
@@ -187,6 +189,8 @@ 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)}
|
||||
@@ -227,6 +231,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,6 +252,8 @@ 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,
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface EditorContentProps {
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
showTableOfContents?: boolean
|
||||
onToggleTableOfContents?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,6 +177,7 @@ const TldrawBlock = createReactBlockSpec(
|
||||
},
|
||||
{
|
||||
runsBefore: ['codeBlock'],
|
||||
meta: { selectable: false },
|
||||
render: (props) => (
|
||||
<Suspense fallback={<div className="tldraw-whiteboard tldraw-whiteboard--loading" />}>
|
||||
<TldrawWhiteboard
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
27
src/components/tableOfContents.worker.ts
Normal file
27
src/components/tableOfContents.worker.ts
Normal 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 {}
|
||||
273
src/components/tableOfContentsModel.ts
Normal file
273
src/components/tableOfContentsModel.ts
Normal 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)
|
||||
}
|
||||
74
src/components/tableOfContentsWorkerClient.ts
Normal file
74
src/components/tableOfContentsWorkerClient.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
104
src/components/useRightPanelExclusion.ts
Normal file
104
src/components/useRightPanelExclusion.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface RightPanelExclusionOptions {
|
||||
inspectorCollapsed: boolean
|
||||
onToggleAIChat?: () => void
|
||||
onToggleInspector: () => void
|
||||
showAIChat?: boolean
|
||||
}
|
||||
|
||||
interface RightPanelToggleOptions extends RightPanelExclusionOptions {
|
||||
closeTableOfContents: () => void
|
||||
openTableOfContents?: () => void
|
||||
showTableOfContents?: boolean
|
||||
}
|
||||
|
||||
function prepareRightPanelOpen(
|
||||
panel: 'ai' | 'properties',
|
||||
{
|
||||
closeTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleAIChat,
|
||||
onToggleInspector,
|
||||
showAIChat,
|
||||
}: RightPanelToggleOptions,
|
||||
) {
|
||||
if (panel === 'properties' && !inspectorCollapsed) return
|
||||
if (panel === 'ai' && showAIChat) return
|
||||
|
||||
closeTableOfContents()
|
||||
if (panel === 'properties' && showAIChat) onToggleAIChat?.()
|
||||
if (panel === 'ai' && !inspectorCollapsed) onToggleInspector()
|
||||
}
|
||||
|
||||
function toggleTableOfContentsPanel({
|
||||
closeTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleAIChat,
|
||||
onToggleInspector,
|
||||
openTableOfContents,
|
||||
showAIChat,
|
||||
showTableOfContents,
|
||||
}: RightPanelToggleOptions) {
|
||||
if (showTableOfContents) {
|
||||
closeTableOfContents()
|
||||
return
|
||||
}
|
||||
|
||||
if (!inspectorCollapsed) onToggleInspector()
|
||||
if (showAIChat) onToggleAIChat?.()
|
||||
openTableOfContents?.()
|
||||
}
|
||||
|
||||
export function useRightPanelExclusion({
|
||||
inspectorCollapsed,
|
||||
onToggleAIChat,
|
||||
onToggleInspector,
|
||||
showAIChat,
|
||||
}: RightPanelExclusionOptions) {
|
||||
const [showTableOfContents, setShowTableOfContents] = useState(false)
|
||||
const closeTableOfContents = useCallback(() => setShowTableOfContents(false), [])
|
||||
|
||||
const handleToggleInspectorPanel = useCallback(() => {
|
||||
prepareRightPanelOpen('properties', {
|
||||
closeTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleAIChat,
|
||||
onToggleInspector,
|
||||
showAIChat,
|
||||
})
|
||||
onToggleInspector()
|
||||
}, [closeTableOfContents, inspectorCollapsed, onToggleAIChat, onToggleInspector, showAIChat])
|
||||
|
||||
const handleToggleAIChatPanel = useCallback(() => {
|
||||
prepareRightPanelOpen('ai', {
|
||||
closeTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleAIChat,
|
||||
onToggleInspector,
|
||||
showAIChat,
|
||||
})
|
||||
onToggleAIChat?.()
|
||||
}, [closeTableOfContents, inspectorCollapsed, onToggleAIChat, onToggleInspector, showAIChat])
|
||||
|
||||
const handleToggleTableOfContents = useCallback(() => {
|
||||
trackEvent('table_of_contents_toggled', { open: showTableOfContents ? 0 : 1 })
|
||||
toggleTableOfContentsPanel({
|
||||
closeTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleAIChat,
|
||||
onToggleInspector,
|
||||
openTableOfContents: () => setShowTableOfContents(true),
|
||||
showAIChat,
|
||||
showTableOfContents,
|
||||
})
|
||||
}, [closeTableOfContents, inspectorCollapsed, onToggleAIChat, onToggleInspector, showAIChat, showTableOfContents])
|
||||
|
||||
return {
|
||||
handleToggleAIChatPanel,
|
||||
handleToggleInspectorPanel,
|
||||
handleToggleTableOfContents,
|
||||
showTableOfContents,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { preProcessWikilinks, injectWikilinks } from '../utils/wikilinks'
|
||||
import { preProcessMathMarkdown, injectMathInBlocks } from '../utils/mathMarkdown'
|
||||
import { preProcessMermaidMarkdown, injectMermaidInBlocks } from '../utils/mermaidMarkdown'
|
||||
import { preProcessTldrawMarkdown, injectTldrawInBlocks } from '../utils/tldrawMarkdown'
|
||||
import { injectDurableEditorMarkdownBlocks, preProcessDurableEditorMarkdown } from '../utils/editorDurableMarkdown'
|
||||
import { resolveImageUrls } from '../utils/vaultImages'
|
||||
import { repairMalformedEditorBlocks } from './editorBlockRepair'
|
||||
import { inferCodeBlockLanguages } from '../utils/codeBlockLanguage'
|
||||
@@ -134,9 +133,8 @@ async function parseMarkdownBlocks(
|
||||
}
|
||||
|
||||
function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath): PreprocessedMarkdown {
|
||||
const withTldraw = preProcessTldrawMarkdown({ markdown })
|
||||
const withMermaid = preProcessMermaidMarkdown({ markdown: withTldraw })
|
||||
const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid
|
||||
const withDurableBlocks = preProcessDurableEditorMarkdown({ markdown })
|
||||
const withImages = vaultPath ? resolveImageUrls(withDurableBlocks, vaultPath) : withDurableBlocks
|
||||
const withWikilinks = preProcessWikilinks(withImages)
|
||||
return preProcessMathMarkdown({ markdown: withWikilinks })
|
||||
}
|
||||
@@ -144,8 +142,7 @@ function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath)
|
||||
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
|
||||
const withWikilinks = injectWikilinks(blocks)
|
||||
const withMath = injectMathInBlocks(withWikilinks)
|
||||
const withMermaid = injectMermaidInBlocks(withMath)
|
||||
return injectTldrawInBlocks(withMermaid) as EditorBlocks
|
||||
return injectDurableEditorMarkdownBlocks(withMath) as EditorBlocks
|
||||
}
|
||||
|
||||
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
|
||||
|
||||
@@ -13,6 +13,10 @@ describe('mockFrontmatterHelpers', () => {
|
||||
window.__mockContent = {}
|
||||
})
|
||||
|
||||
function frontmatterDelimiterLineCount(content: string): number {
|
||||
return content.split(/\r?\n/).filter((line) => line === '---').length
|
||||
}
|
||||
|
||||
describe('updateMockFrontmatter', () => {
|
||||
it('updates an existing string property', () => {
|
||||
window.__mockContent = {
|
||||
@@ -156,6 +160,27 @@ describe('mockFrontmatterHelpers', () => {
|
||||
expect(result).not.toContain('sidebar_label: Legacy')
|
||||
expect(result).not.toContain('archived: false')
|
||||
})
|
||||
|
||||
it('updates existing CRLF frontmatter without adding a second block', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': [
|
||||
'---',
|
||||
'type: Note',
|
||||
'related_to: "[[Alpha Project]]"',
|
||||
'---',
|
||||
'# CRLF Inbox Syntax',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', '_organized', true)
|
||||
|
||||
expect(frontmatterDelimiterLineCount(result)).toBe(2)
|
||||
expect(result).toContain('type: Note\r\n')
|
||||
expect(result).toContain('related_to: "[[Alpha Project]]"\r\n')
|
||||
expect(result).toContain('_organized: true\r\n')
|
||||
expect(result).toContain('\r\n---\r\n# CRLF Inbox Syntax')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteMockFrontmatterProperty', () => {
|
||||
|
||||
@@ -8,6 +8,13 @@ type YamlKey = string
|
||||
type YamlValue = string
|
||||
type YamlLine = string
|
||||
type ReplacementLine = string | null
|
||||
type LineEnding = '\n' | '\r\n'
|
||||
|
||||
interface ParsedFrontmatter {
|
||||
fm: MarkdownContent
|
||||
rest: MarkdownContent
|
||||
lineEnding: LineEnding
|
||||
}
|
||||
|
||||
function canonicalWriteKey(key: FrontmatterKey): FrontmatterKey {
|
||||
return canonicalFrontmatterWriteKey(key)
|
||||
@@ -24,11 +31,28 @@ function formatYamlKey(key: FrontmatterKey): YamlKey {
|
||||
return key.includes(' ') ? `"${key}"` : key
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: MarkdownContent): { fm: MarkdownContent; rest: MarkdownContent } | null {
|
||||
if (!content.startsWith('---\n')) return null
|
||||
const fmEnd = content.indexOf('\n---', 4)
|
||||
function frontmatterOpening(content: MarkdownContent): { bodyStart: number; lineEnding: LineEnding } | null {
|
||||
if (content.startsWith('---\r\n')) return { bodyStart: 5, lineEnding: '\r\n' }
|
||||
if (content.startsWith('---\n')) return { bodyStart: 4, lineEnding: '\n' }
|
||||
return null
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: MarkdownContent): ParsedFrontmatter | null {
|
||||
const opening = frontmatterOpening(content)
|
||||
if (!opening) return null
|
||||
const afterOpening = content.slice(opening.bodyStart)
|
||||
if (afterOpening.startsWith('---')) {
|
||||
return { fm: '', rest: afterOpening.slice(3), lineEnding: opening.lineEnding }
|
||||
}
|
||||
|
||||
const closeMarker = `${opening.lineEnding}---`
|
||||
const fmEnd = afterOpening.indexOf(closeMarker)
|
||||
if (fmEnd === -1) return null
|
||||
return { fm: content.slice(4, fmEnd), rest: content.slice(fmEnd + 4) }
|
||||
return {
|
||||
fm: afterOpening.slice(0, fmEnd),
|
||||
rest: afterOpening.slice(fmEnd + closeMarker.length),
|
||||
lineEnding: opening.lineEnding,
|
||||
}
|
||||
}
|
||||
|
||||
function formatKeyValue(yamlKey: YamlKey, yamlValue: YamlValue, isArray: boolean): YamlLine {
|
||||
@@ -87,6 +111,14 @@ function hasMatchingKey(lines: YamlLine[], key: FrontmatterKey): boolean {
|
||||
return lines.some(line => lineMatchesKey(line, key))
|
||||
}
|
||||
|
||||
function frontmatterLines(fm: MarkdownContent, lineEnding: LineEnding): YamlLine[] {
|
||||
return fm === '' ? [] : fm.split(lineEnding)
|
||||
}
|
||||
|
||||
function formatFrontmatterBlock(lines: YamlLine[], lineEnding: LineEnding, rest: MarkdownContent): MarkdownContent {
|
||||
return `---${lineEnding}${lines.join(lineEnding)}${lineEnding}---${rest}`
|
||||
}
|
||||
|
||||
function processKeyInLines(lines: YamlLine[], key: FrontmatterKey, replacement: ReplacementLine): YamlLine[] {
|
||||
const newLines: YamlLine[] = []
|
||||
let i = 0
|
||||
@@ -114,16 +146,16 @@ export function updateMockFrontmatter(path: VaultPath, key: FrontmatterKey, valu
|
||||
return `---\n${formatKeyValue(yamlKey, yamlValue, isArray)}\n---\n${content}`
|
||||
}
|
||||
|
||||
const { fm, rest } = parsed
|
||||
const lines = fm.split('\n')
|
||||
const { fm, rest, lineEnding } = parsed
|
||||
const lines = frontmatterLines(fm, lineEnding)
|
||||
const replacement = formatKeyValue(yamlKey, yamlValue, isArray)
|
||||
|
||||
if (hasMatchingKey(lines, key)) {
|
||||
const newLines = processKeyInLines(lines, key, replacement)
|
||||
return `---\n${newLines.join('\n')}\n---${rest}`
|
||||
return formatFrontmatterBlock(newLines, lineEnding, rest)
|
||||
}
|
||||
|
||||
return `---\n${fm}\n${replacement}\n---${rest}`
|
||||
return formatFrontmatterBlock([...lines, replacement], lineEnding, rest)
|
||||
}
|
||||
|
||||
export function deleteMockFrontmatterProperty(path: VaultPath, key: FrontmatterKey): MarkdownContent {
|
||||
@@ -131,7 +163,7 @@ export function deleteMockFrontmatterProperty(path: VaultPath, key: FrontmatterK
|
||||
const parsed = parseFrontmatter(content)
|
||||
if (!parsed) return content
|
||||
|
||||
const { fm, rest } = parsed
|
||||
const newLines = processKeyInLines(fm.split('\n'), key, null)
|
||||
return `---\n${newLines.join('\n')}\n---${rest}`
|
||||
const { fm, rest, lineEnding } = parsed
|
||||
const newLines = processKeyInLines(frontmatterLines(fm, lineEnding), key, null)
|
||||
return formatFrontmatterBlock(newLines, lineEnding, rest)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,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 { serializeDurableEditorBlocks } from '../utils/editorDurableMarkdown'
|
||||
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
|
||||
import { portableImageUrls } from '../utils/vaultImages'
|
||||
import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle'
|
||||
@@ -123,7 +123,7 @@ function findActiveTab(options: {
|
||||
|
||||
function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): string {
|
||||
const restored = restoreWikilinksInBlocks(editor.document)
|
||||
return compactMarkdown(serializeMermaidAwareBlocks(editor, restored))
|
||||
return compactMarkdown(serializeDurableEditorBlocks(editor, restored))
|
||||
}
|
||||
|
||||
function trySerializeEditorBody(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { uploadImageFile, useImageDrop } from './useImageDrop'
|
||||
import { clipboardImageFiles, uploadImageFile, useImageDrop } from './useImageDrop'
|
||||
import { createRef } from 'react'
|
||||
|
||||
let tauriMode = false
|
||||
@@ -105,6 +105,45 @@ describe('uploadImageFile', () => {
|
||||
|
||||
tauriMode = false
|
||||
})
|
||||
|
||||
it('uses a MIME-derived filename for unnamed Tauri clipboard images', async () => {
|
||||
tauriMode = true
|
||||
|
||||
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-clipboard-image.webp')
|
||||
vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/vault/attachments/123-clipboard-image.webp')
|
||||
|
||||
const file = new File([new Uint8Array([0x52, 0x49])], '', { type: 'image/webp' })
|
||||
|
||||
const url = await uploadImageFile(file, '/vault')
|
||||
expect(invoke).toHaveBeenCalledWith('save_image', {
|
||||
vaultPath: '/vault',
|
||||
filename: 'clipboard-image.webp',
|
||||
data: expect.any(String),
|
||||
})
|
||||
expect(url).toBe('asset://localhost/vault/attachments/123-clipboard-image.webp')
|
||||
|
||||
tauriMode = false
|
||||
})
|
||||
})
|
||||
|
||||
describe('clipboardImageFiles', () => {
|
||||
it('extracts image files when clipboard data also exposes a text data URL', () => {
|
||||
const file = new File(['webp-data'], 'paste.webp', { type: 'image/webp' })
|
||||
const items = [{
|
||||
getAsFile: () => file,
|
||||
kind: 'file',
|
||||
type: 'image/webp',
|
||||
}]
|
||||
const clipboardData = {
|
||||
files: Object.assign([file], { item: (index: number) => [file][index] }),
|
||||
getData: (format: string) => format === 'text/plain' ? 'data:image/webp;base64,AAAA' : '',
|
||||
items: Object.assign(items, { length: items.length }),
|
||||
types: ['text/plain', 'Files'],
|
||||
}
|
||||
|
||||
expect(clipboardImageFiles(clipboardData)).toEqual([file])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useImageDrop', () => {
|
||||
|
||||
@@ -5,8 +5,34 @@ import { useTauriDragDropEvent, type TauriDragDropEvent } from './useTauriDragDr
|
||||
|
||||
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff']
|
||||
const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
|
||||
'image/bmp': 'bmp',
|
||||
'image/gif': 'gif',
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/png': 'png',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/tiff': 'tiff',
|
||||
'image/webp': 'webp',
|
||||
}
|
||||
|
||||
type ImageUrlHandler = (url: string) => void
|
||||
type ClipboardFileItem = {
|
||||
getAsFile: () => File | null
|
||||
kind: string
|
||||
type: string
|
||||
}
|
||||
type ClipboardFileItems = {
|
||||
length: number
|
||||
[index: number]: ClipboardFileItem | undefined
|
||||
}
|
||||
type ClipboardFiles = {
|
||||
length: number
|
||||
[index: number]: File | undefined
|
||||
}
|
||||
type ClipboardImageData = {
|
||||
files?: ClipboardFiles
|
||||
items?: ClipboardFileItems
|
||||
} | null | undefined
|
||||
|
||||
function hasImageFiles(dt: DataTransfer): boolean {
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
@@ -20,6 +46,57 @@ function isImagePath(path: string): boolean {
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
function fileExtension(filename: string): string | null {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
return extension && extension !== filename.toLowerCase() && IMAGE_EXTENSIONS.includes(extension)
|
||||
? extension
|
||||
: null
|
||||
}
|
||||
|
||||
function imageExtensionForFile(file: File): string | null {
|
||||
const filenameExtension = fileExtension(file.name)
|
||||
if (filenameExtension) return filenameExtension
|
||||
|
||||
return IMAGE_EXTENSION_BY_MIME_TYPE[file.type.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
function imageAttachmentFilename(file: File): string {
|
||||
const filename = file.name.trim()
|
||||
const extension = imageExtensionForFile(file)
|
||||
if (!filename) return extension ? `clipboard-image.${extension}` : 'clipboard-image'
|
||||
if (fileExtension(filename)) return filename
|
||||
return extension ? `${filename}.${extension}` : filename
|
||||
}
|
||||
|
||||
function uniqueImageFiles(files: File[]): File[] {
|
||||
return files.filter((file, index) => (
|
||||
file.type.startsWith('image/') && files.indexOf(file) === index
|
||||
))
|
||||
}
|
||||
|
||||
function imageFileFromClipboardItem(item: ClipboardFileItem): File | null {
|
||||
if (item.kind !== 'file' || !item.type.startsWith('image/')) return null
|
||||
|
||||
return item.getAsFile()
|
||||
}
|
||||
|
||||
function clipboardItemImageFiles(items?: ClipboardFileItems): File[] {
|
||||
return Array
|
||||
.from({ length: items?.length ?? 0 }, (_, index) => items?.[index])
|
||||
.flatMap(item => item ? [imageFileFromClipboardItem(item)].filter((file): file is File => file !== null) : [])
|
||||
}
|
||||
|
||||
function clipboardFileListImageFiles(files?: ClipboardFiles): File[] {
|
||||
return uniqueImageFiles(Array.from({ length: files?.length ?? 0 }, (_, index) => files?.[index]).filter((file): file is File => Boolean(file)))
|
||||
}
|
||||
|
||||
export function clipboardImageFiles(clipboardData: ClipboardImageData): File[] {
|
||||
const itemFiles = uniqueImageFiles(clipboardItemImageFiles(clipboardData?.items))
|
||||
return itemFiles.length > 0
|
||||
? itemFiles
|
||||
: clipboardFileListImageFiles(clipboardData?.files)
|
||||
}
|
||||
|
||||
/** Upload an image file — saves to vault/attachments in Tauri, returns data URL in browser */
|
||||
export async function uploadImageFile(file: File, vaultPath?: string): Promise<string> {
|
||||
if (isTauri() && vaultPath) {
|
||||
@@ -30,7 +107,7 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
|
||||
const base64 = btoa(binary)
|
||||
const savedPath = await invoke<string>('save_image', {
|
||||
vaultPath,
|
||||
filename: file.name,
|
||||
filename: imageAttachmentFilename(file),
|
||||
data: base64,
|
||||
})
|
||||
return convertFileSrc(savedPath)
|
||||
|
||||
@@ -124,12 +124,57 @@ describe('useMainWindowSizeConstraints', () => {
|
||||
})
|
||||
|
||||
it('sends the grow-to-fit payload through the native command helper', async () => {
|
||||
await applyMainWindowSizeConstraints(1200)
|
||||
await applyMainWindowSizeConstraints(1200, { growToFit: false })
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', {
|
||||
minWidth: 1200,
|
||||
minHeight: 400,
|
||||
growToFit: true,
|
||||
growToFit: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not request native grow-to-fit on Windows', async () => {
|
||||
const originalUserAgent = navigator.userAgent
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
})
|
||||
|
||||
try {
|
||||
renderHook(() => useMainWindowSizeConstraints({
|
||||
sidebarVisible: true,
|
||||
noteListVisible: true,
|
||||
inspectorCollapsed: false,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', {
|
||||
minWidth: 1160,
|
||||
minHeight: 400,
|
||||
growToFit: false,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: originalUserAgent,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps grow-to-fit enabled on non-Windows platforms', async () => {
|
||||
renderHook(() => useMainWindowSizeConstraints({
|
||||
sidebarVisible: true,
|
||||
noteListVisible: true,
|
||||
inspectorCollapsed: false,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', {
|
||||
minWidth: 1160,
|
||||
minHeight: 400,
|
||||
growToFit: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isWindows } from '../utils/platform'
|
||||
|
||||
const MAIN_WINDOW_MIN_HEIGHT = 400
|
||||
const EDITOR_ONLY_MAIN_WINDOW_MIN_WIDTH = 480
|
||||
@@ -45,11 +46,12 @@ type MainWindowSizeConstraintsOptions = MainWindowPaneVisibility & {
|
||||
|
||||
export async function applyMainWindowSizeConstraints(
|
||||
minWidth: number,
|
||||
options: { growToFit?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await invoke('update_current_window_min_size', {
|
||||
minWidth,
|
||||
minHeight: MAIN_WINDOW_MIN_HEIGHT,
|
||||
growToFit: true,
|
||||
growToFit: options.growToFit ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,7 +80,7 @@ export function useMainWindowSizeConstraints({
|
||||
|
||||
void (async () => {
|
||||
if (cancelled) return
|
||||
await applyMainWindowSizeConstraints(minWidth)
|
||||
await applyMainWindowSizeConstraints(minWidth, { growToFit: !isWindows() })
|
||||
})().catch((err) => console.warn('[window] Size constraints failed:', err))
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { VaultEntry } from '../types'
|
||||
import { RAPID_CREATE_NOTE_SETTLE_MS } from './useNoteCreation'
|
||||
import { useNoteActions } from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
import { GITIGNORED_VISIBILITY_APPLIED_EVENT } from '../lib/gitignoredVisibilityEvents'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
@@ -163,6 +164,31 @@ describe('useNoteActions hook', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the active tab open when gitignored visibility reports a /tmp alias', async () => {
|
||||
const activeEntry = makeEntry({
|
||||
path: '/private/tmp/tolaria-vault/active.md',
|
||||
filename: 'active.md',
|
||||
title: 'Active',
|
||||
})
|
||||
const { result } = renderActions([activeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(activeEntry)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(GITIGNORED_VISIBILITY_APPLIED_EVENT, {
|
||||
detail: {
|
||||
hide: true,
|
||||
visiblePaths: ['/tmp/tolaria-vault/active.md'],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/private/tmp/tolaria-vault/active.md')
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter calls updateEntry with mapped patch', async () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename, reloadVaultAfterRename,
|
||||
} from './useNoteRename'
|
||||
import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { findByNotePath, notePathFilename, notePathsMatch } from '../utils/notePathIdentity'
|
||||
|
||||
export interface NoteActionsConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
@@ -89,18 +90,20 @@ interface RenameAfterTitleChangeParams {
|
||||
}
|
||||
|
||||
async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise<void> {
|
||||
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
|
||||
const oldTitle = deps.tabsRef.current.find(t => notePathsMatch(t.entry.path, path))?.entry.title
|
||||
const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle })
|
||||
if (result.new_path !== path) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? ''
|
||||
if (!notePathsMatch(result.new_path, path)) {
|
||||
const newFilename = notePathFilename(result.new_path)
|
||||
deps.onPathRenamed?.(path, result.new_path)
|
||||
deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial<VaultEntry> & { path: string })
|
||||
const newContent = await loadNoteContent({ path: result.new_path })
|
||||
deps.setTabs(prev => prev.map(t => t.entry.path === path
|
||||
deps.setTabs(prev => prev.map(t => notePathsMatch(t.entry.path, path)
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent }
|
||||
: t))
|
||||
if (deps.activeTabPathRef.current === path) deps.handleSwitchTab(result.new_path)
|
||||
const otherTabPaths = deps.tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path)
|
||||
if (notePathsMatch(deps.activeTabPathRef.current, path)) deps.handleSwitchTab(result.new_path)
|
||||
const otherTabPaths = deps.tabsRef.current
|
||||
.filter(t => !notePathsMatch(t.entry.path, path) && !notePathsMatch(t.entry.path, result.new_path))
|
||||
.map(t => t.entry.path)
|
||||
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent })
|
||||
}
|
||||
await reloadVaultAfterRename(deps.reloadVault)
|
||||
@@ -252,7 +255,7 @@ function useGitignoredVisibilityTabCleanup({
|
||||
const handleVisibilityApplied = (event: Event) => {
|
||||
const { hide, visiblePaths } = (event as GitignoredVisibilityAppliedEvent).detail
|
||||
const activePath = activeTabPathRef.current
|
||||
if (!hide || !activePath || visiblePaths.includes(activePath)) return
|
||||
if (!hide || !activePath || visiblePaths.some((path) => notePathsMatch(path, activePath))) return
|
||||
closeAllTabs()
|
||||
setToastMessage('Closed hidden Gitignored file')
|
||||
}
|
||||
@@ -353,7 +356,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
})
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
||||
setTabs((prev) => prev.map((t) => notePathsMatch(t.entry.path, path) ? { ...t, content: newContent } : t))
|
||||
}, [setTabs])
|
||||
|
||||
const creation = useNoteCreation(config, { openTabWithContent })
|
||||
@@ -374,7 +377,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) },
|
||||
callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => findByNotePath(entries, p) },
|
||||
options,
|
||||
}),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildNoteContent,
|
||||
resolveNewNote,
|
||||
resolveNewType,
|
||||
planNewTypeCreation,
|
||||
DEFAULT_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './useNoteCreation'
|
||||
@@ -318,4 +319,58 @@ describe('resolveNewType', () => {
|
||||
expect(entry.path).toBe('/other/vault/responsibility.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
|
||||
it('normalizes the built-in Notes label to the Note type definition', () => {
|
||||
const { entry, content } = resolveNewType({ typeName: 'Notes', vaultPath: '/my/vault' })
|
||||
|
||||
expect(entry.path).toBe('/my/vault/note.md')
|
||||
expect(entry.filename).toBe('note.md')
|
||||
expect(entry.title).toBe('Note')
|
||||
expect(content).toContain('# Note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('planNewTypeCreation', () => {
|
||||
it('blocks creating a type when a same-slug non-Type note already exists', () => {
|
||||
const plan = planNewTypeCreation({
|
||||
entries: [makeEntry({ path: '/my/vault/tasks.md', filename: 'tasks.md', title: 'Tasks', isA: 'Note' })],
|
||||
typeName: 'Tasks',
|
||||
vaultPath: '/my/vault',
|
||||
})
|
||||
|
||||
expect(plan).toEqual({
|
||||
status: 'blocked',
|
||||
message: 'Cannot create type "Tasks" because tasks.md already exists',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks type collisions case-insensitively for cross-platform vaults', () => {
|
||||
const plan = planNewTypeCreation({
|
||||
entries: [makeEntry({ path: '/my/vault/TASKS.md', filename: 'TASKS.md', title: 'Tasks', isA: 'Note' })],
|
||||
typeName: 'tasks',
|
||||
vaultPath: '/my/vault',
|
||||
})
|
||||
|
||||
expect(plan.status).toBe('blocked')
|
||||
})
|
||||
|
||||
it('does not treat an existing notes.md note as a collision for the built-in Notes label', () => {
|
||||
const plan = planNewTypeCreation({
|
||||
entries: [makeEntry({ path: '/my/vault/notes.md', filename: 'notes.md', title: 'Meeting Notes', isA: 'Note' })],
|
||||
typeName: 'Notes',
|
||||
vaultPath: '/my/vault',
|
||||
})
|
||||
|
||||
expect(plan).toEqual({
|
||||
status: 'create',
|
||||
resolved: expect.objectContaining({
|
||||
entry: expect.objectContaining({
|
||||
path: '/my/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Type',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
resolveNewNote,
|
||||
resolveNewType,
|
||||
resolveTemplate,
|
||||
resolveTypeInstanceDefaults,
|
||||
DEFAULT_TEMPLATES,
|
||||
RAPID_CREATE_NOTE_SETTLE_MS,
|
||||
planNewNoteCreation,
|
||||
useNoteCreation,
|
||||
} from './useNoteCreation'
|
||||
import type { NoteCreationConfig } from './useNoteCreation'
|
||||
@@ -169,6 +171,44 @@ describe('resolveNewNote', () => {
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).not.toContain('status:')
|
||||
})
|
||||
|
||||
it('applies valued properties and relationships from the type entry to newly created instances', () => {
|
||||
const typeEntry = makeEntry({
|
||||
title: 'Book',
|
||||
isA: 'Type',
|
||||
properties: {
|
||||
Rating: 5,
|
||||
'start date': null,
|
||||
},
|
||||
relationships: {
|
||||
Author: ['[[person/frank-herbert]]'],
|
||||
},
|
||||
})
|
||||
const defaults = resolveTypeInstanceDefaults({ entries: [typeEntry], typeName: 'Book' })
|
||||
const { entry, content } = resolveNewNote({
|
||||
title: 'Dune',
|
||||
type: 'Book',
|
||||
vaultPath: '/vault',
|
||||
defaults,
|
||||
})
|
||||
|
||||
expect(content).toContain('Rating: 5')
|
||||
expect(content).toContain('Author: "[[person/frank-herbert]]"')
|
||||
expect(content).not.toContain('start date:')
|
||||
expect(entry.properties).toEqual({ Rating: 5 })
|
||||
expect(entry.relationships).toEqual({ Author: ['[[person/frank-herbert]]'] })
|
||||
})
|
||||
|
||||
it('blocks creation when macOS /tmp aliases point at the same note path', () => {
|
||||
const plan = planNewNoteCreation({
|
||||
entries: [makeEntry({ path: '/private/tmp/tolaria-vault/briefing.md', filename: 'briefing.md' })],
|
||||
title: 'Briefing',
|
||||
type: 'Note',
|
||||
vaultPath: '/tmp/tolaria-vault',
|
||||
})
|
||||
|
||||
expect(plan.status).toBe('blocked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
@@ -584,11 +624,48 @@ describe('useNoteCreation hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Note" because note.md already exists')
|
||||
})
|
||||
|
||||
it('handleCreateType lets disk creation decide when a stale entry collides with the target type path', async () => {
|
||||
it('handleCreateType creates the built-in Note type when notes.md is an existing ordinary note', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke)
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const existingNotes = makeEntry({
|
||||
path: '/test/vault/notes.md',
|
||||
filename: 'notes.md',
|
||||
title: 'Meeting Notes',
|
||||
isA: 'Note',
|
||||
})
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existingNotes]), tabDeps))
|
||||
|
||||
let created = false
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateType('Notes')
|
||||
})
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
|
||||
path: '/test/vault/note.md',
|
||||
})
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
|
||||
path: '/test/vault/note.md',
|
||||
content: '---\ntype: Type\n---\n\n# Note\n',
|
||||
})
|
||||
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Type',
|
||||
}))
|
||||
expect(openTabWithContent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/note.md',
|
||||
title: 'Note',
|
||||
isA: 'Type',
|
||||
}), expect.stringContaining('# Note'))
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateType blocks when a loaded non-Type entry collides with the target type path', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const staleEntry = makeEntry({
|
||||
path: '/test/vault/pttep.md',
|
||||
filename: 'pttep.md',
|
||||
@@ -597,26 +674,16 @@ describe('useNoteCreation hook', () => {
|
||||
})
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([staleEntry]), tabDeps))
|
||||
|
||||
let created = false
|
||||
let created = true
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateType('PTTEP')
|
||||
})
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
|
||||
path: '/test/vault/pttep.md',
|
||||
})
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
|
||||
path: '/test/vault/pttep.md',
|
||||
content: expect.stringContaining('type: Type'),
|
||||
})
|
||||
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/pttep.md',
|
||||
filename: 'pttep.md',
|
||||
title: 'PTTEP',
|
||||
isA: 'Type',
|
||||
}))
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
expect(created).toBe(false)
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalled()
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "PTTEP" because pttep.md already exists')
|
||||
})
|
||||
|
||||
it('handleCreateType writes new type entries to the vault root even when older type entries live in a folder', async () => {
|
||||
|
||||
@@ -6,6 +6,9 @@ import { slugifyNoteStem as slugify } from '../utils/noteSlug'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { cacheNoteContent } from './useTabManagement'
|
||||
import { findByCollidingNotePath, joinVaultPath, notePathFilename } from '../utils/notePathIdentity'
|
||||
import { canonicalFrontmatterKey } from '../utils/systemMetadata'
|
||||
import { canonicalizeTypeName } from '../utils/vaultTypes'
|
||||
|
||||
export interface NewEntryParams {
|
||||
path: string
|
||||
@@ -87,6 +90,15 @@ export interface NoteContentParams {
|
||||
status: string | null
|
||||
template?: string | null
|
||||
initialEmptyHeading?: boolean
|
||||
defaults?: TypeInstanceDefault[]
|
||||
}
|
||||
|
||||
type DefaultValue = string | number | boolean | string[]
|
||||
|
||||
export interface TypeInstanceDefault {
|
||||
key: string
|
||||
value: DefaultValue
|
||||
kind: 'property' | 'relationship'
|
||||
}
|
||||
|
||||
function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams, 'template' | 'initialEmptyHeading'>): string {
|
||||
@@ -96,11 +108,103 @@ function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams
|
||||
return template ? `\n${template}` : ''
|
||||
}
|
||||
|
||||
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false }: NoteContentParams): string {
|
||||
function isDefaultablePropertyValue(value: unknown): value is string | number | boolean {
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
return typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function relationshipDefaultValue(refs: string[]): DefaultValue | null {
|
||||
if (refs.length === 0) return null
|
||||
return refs.length === 1 ? refs[0] : refs
|
||||
}
|
||||
|
||||
function resolveTypeEntry({ entries, typeName }: TemplateLookupParams): VaultEntry | undefined {
|
||||
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
|
||||
}
|
||||
|
||||
function collectPropertyDefaults(typeEntry: VaultEntry): TypeInstanceDefault[] {
|
||||
return Object.entries(typeEntry.properties ?? {}).flatMap(([key, value]) => (
|
||||
isDefaultablePropertyValue(value) ? [{ key, value, kind: 'property' as const }] : []
|
||||
))
|
||||
}
|
||||
|
||||
function collectRelationshipDefaults(typeEntry: VaultEntry): TypeInstanceDefault[] {
|
||||
return Object.entries(typeEntry.relationships ?? {}).flatMap(([key, refs]) => {
|
||||
const value = relationshipDefaultValue(refs)
|
||||
return value ? [{ key, value, kind: 'relationship' as const }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function appendUniqueDefault(defaults: TypeInstanceDefault[], seenKeys: Set<string>, defaultValue: TypeInstanceDefault) {
|
||||
const canonicalKey = canonicalFrontmatterKey(defaultValue.key)
|
||||
if (canonicalKey === 'type' || canonicalKey === 'title' || seenKeys.has(canonicalKey)) return
|
||||
seenKeys.add(canonicalKey)
|
||||
defaults.push(defaultValue)
|
||||
}
|
||||
|
||||
export function resolveTypeInstanceDefaults(params: TemplateLookupParams): TypeInstanceDefault[] {
|
||||
const typeEntry = resolveTypeEntry(params)
|
||||
if (!typeEntry) return []
|
||||
|
||||
const defaults: TypeInstanceDefault[] = []
|
||||
const seenKeys = new Set<string>()
|
||||
const candidateDefaults = [
|
||||
...collectPropertyDefaults(typeEntry),
|
||||
...collectRelationshipDefaults(typeEntry),
|
||||
]
|
||||
candidateDefaults.forEach((defaultValue) => appendUniqueDefault(defaults, seenKeys, defaultValue))
|
||||
return defaults
|
||||
}
|
||||
|
||||
function hasOuterWhitespace(value: string): boolean {
|
||||
return value.trim() !== value
|
||||
}
|
||||
|
||||
function isYamlWikilink(value: string): boolean {
|
||||
return /^\[\[.*\]\]$/.test(value)
|
||||
}
|
||||
|
||||
function isAmbiguousYamlScalar(value: string): boolean {
|
||||
return /^(?:true|false|null|[-+]?\d+(?:\.\d+)?)$/i.test(value)
|
||||
}
|
||||
|
||||
function shouldQuoteYamlString(value: string): boolean {
|
||||
return [
|
||||
hasOuterWhitespace,
|
||||
isYamlWikilink,
|
||||
isAmbiguousYamlScalar,
|
||||
(candidate: string) => candidate.includes(':'),
|
||||
].some((check) => check(value))
|
||||
}
|
||||
|
||||
function formatYamlScalar(value: string | number | boolean): string {
|
||||
if (typeof value !== 'string') return String(value)
|
||||
if (shouldQuoteYamlString(value)) return JSON.stringify(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function appendDefaultFrontmatterLines(lines: string[], defaults: TypeInstanceDefault[]) {
|
||||
const existingKeys = new Set(lines.map((line) => canonicalFrontmatterKey(line.split(':', 1)[0])))
|
||||
|
||||
for (const { key, value } of defaults) {
|
||||
const canonicalKey = canonicalFrontmatterKey(key)
|
||||
if (existingKeys.has(canonicalKey)) continue
|
||||
existingKeys.add(canonicalKey)
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}:`)
|
||||
value.forEach((item) => lines.push(` - ${formatYamlScalar(item)}`))
|
||||
} else {
|
||||
lines.push(`${key}: ${formatYamlScalar(value)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false, defaults = [] }: NoteContentParams): string {
|
||||
const lines = ['---']
|
||||
if (title) lines.push(`title: ${title}`)
|
||||
lines.push(`type: ${type}`)
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
appendDefaultFrontmatterLines(lines, defaults)
|
||||
lines.push('---')
|
||||
const body = buildNoteBody({ template, initialEmptyHeading })
|
||||
return `${lines.join('\n')}\n${body}`
|
||||
@@ -111,13 +215,18 @@ export interface NewNoteParams {
|
||||
type: string
|
||||
vaultPath: string
|
||||
template?: string | null
|
||||
defaults?: TypeInstanceDefault[]
|
||||
}
|
||||
|
||||
export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewNote({ title, type, vaultPath, template, defaults = [] }: NewNoteParams): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(title)
|
||||
const status = null
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status })
|
||||
return { entry, content: buildNoteContent({ title, type, status, template }) }
|
||||
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title, type, status })
|
||||
return applyTypeDefaults({
|
||||
entry,
|
||||
content: buildNoteContent({ title, type, status, template, defaults }),
|
||||
defaults,
|
||||
})
|
||||
}
|
||||
|
||||
export interface NewTypeParams {
|
||||
@@ -125,14 +234,55 @@ export interface NewTypeParams {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
const TYPE_CREATION_ALIASES = new Map<string, string>([
|
||||
['notes', 'Note'],
|
||||
])
|
||||
|
||||
export function normalizeTypeCreationName(typeName: string): string {
|
||||
const trimmed = typeName.trim()
|
||||
return TYPE_CREATION_ALIASES.get(trimmed.toLowerCase()) ?? canonicalizeTypeName(trimmed) ?? trimmed
|
||||
}
|
||||
|
||||
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(typeName)
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` }
|
||||
const normalizedTypeName = normalizeTypeCreationName(typeName)
|
||||
const slug = slugify(normalizedTypeName)
|
||||
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title: normalizedTypeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${normalizedTypeName}\n` }
|
||||
}
|
||||
|
||||
type ResolvedEntry = { entry: VaultEntry; content: string }
|
||||
|
||||
function relationshipRefs(value: DefaultValue): string[] {
|
||||
return Array.isArray(value) ? value : [String(value)]
|
||||
}
|
||||
|
||||
function applyTypeDefaults({
|
||||
entry,
|
||||
content,
|
||||
defaults,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
defaults: TypeInstanceDefault[]
|
||||
}): ResolvedEntry {
|
||||
if (defaults.length === 0) return { entry, content }
|
||||
|
||||
const relationships = { ...entry.relationships }
|
||||
const properties = { ...entry.properties }
|
||||
for (const defaultValue of defaults) {
|
||||
if (defaultValue.kind === 'relationship') {
|
||||
relationships[defaultValue.key] = relationshipRefs(defaultValue.value)
|
||||
continue
|
||||
}
|
||||
properties[defaultValue.key] = defaultValue.value as string | number | boolean
|
||||
}
|
||||
|
||||
return {
|
||||
entry: { ...entry, relationships, properties },
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
interface BlockedCreationPlan {
|
||||
status: 'blocked'
|
||||
message: string
|
||||
@@ -151,22 +301,17 @@ interface ExistingTypeCreationPlan {
|
||||
export type NoteCreationPlan = BlockedCreationPlan | ReadyCreationPlan
|
||||
export type TypeCreationPlan = BlockedCreationPlan | ExistingTypeCreationPlan | ReadyCreationPlan
|
||||
|
||||
function normalizeComparablePath(path: string): string {
|
||||
return path.replace(/\\/g, '/').toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function findPathCollision(entries: VaultEntry[], path: string): VaultEntry | undefined {
|
||||
const target = normalizeComparablePath(path)
|
||||
return entries.find((entry) => normalizeComparablePath(entry.path) === target)
|
||||
return findByCollidingNotePath(entries, path)
|
||||
}
|
||||
|
||||
function buildCreationCollisionMessage({ noun, title, path }: { noun: 'note' | 'type'; title: string; path: string }): string {
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const filename = notePathFilename(path)
|
||||
return `Cannot create ${noun} "${title}" because ${filename} already exists`
|
||||
}
|
||||
|
||||
function findEquivalentTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
const trimmed = typeName.trim()
|
||||
const trimmed = normalizeTypeCreationName(typeName)
|
||||
const targetSlug = slugify(trimmed)
|
||||
return entries.find((entry) =>
|
||||
entry.isA === 'Type' && (entry.title === trimmed || slugify(entry.title) === targetSlug)
|
||||
@@ -179,8 +324,9 @@ export function planNewNoteCreation({
|
||||
type,
|
||||
vaultPath,
|
||||
template,
|
||||
defaults,
|
||||
}: NewNoteParams & { entries: VaultEntry[] }): NoteCreationPlan {
|
||||
const resolved = resolveNewNote({ title, type, vaultPath, template })
|
||||
const resolved = resolveNewNote({ title, type, vaultPath, template, defaults })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
@@ -200,6 +346,14 @@ export function planNewTypeCreation({
|
||||
if (existingType) return { status: 'existing', entry: existingType }
|
||||
|
||||
const resolved = resolveNewType({ typeName, vaultPath })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
@@ -310,7 +464,8 @@ async function createNamedNote({
|
||||
creationPath,
|
||||
}: NoteCreationRequest): Promise<boolean> {
|
||||
const template = resolveTemplate({ entries, typeName: type })
|
||||
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template })
|
||||
const defaults = resolveTypeInstanceDefaults({ entries, typeName: type })
|
||||
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template, defaults })
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
@@ -464,16 +619,21 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
|
||||
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
|
||||
const title = slug_to_title(slug)
|
||||
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
|
||||
const defaults = resolveTypeInstanceDefaults({ entries: deps.entries, typeName: noteType })
|
||||
const status = null
|
||||
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
|
||||
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
|
||||
const didPersist = await persistImmediateEntry(deps, entry, content)
|
||||
const entry = buildNewEntry({ path: joinVaultPath(deps.vaultPath, `${slug}.md`), slug, title, type: noteType, status })
|
||||
const resolved = applyTypeDefaults({
|
||||
entry,
|
||||
content: buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true, defaults }),
|
||||
defaults,
|
||||
})
|
||||
const didPersist = await persistImmediateEntry(deps, resolved.entry, resolved.content)
|
||||
if (!didPersist) return false
|
||||
|
||||
cacheNoteContent(entry.path, content, entry)
|
||||
deps.openTabWithContent(entry, content)
|
||||
addEntryWithMock(entry, content, deps.addEntry)
|
||||
signalFocusEditor({ path: entry.path, selectTitle: true })
|
||||
cacheNoteContent(resolved.entry.path, resolved.content, resolved.entry)
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
signalFocusEditor({ path: resolved.entry.path, selectTitle: true })
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -127,11 +127,13 @@ describe('useNoteRename hook', () => {
|
||||
))
|
||||
|
||||
const runHandleRenameNote = async ({
|
||||
path = '/vault/old.md',
|
||||
entries = [],
|
||||
renameResult = { new_path: '/vault/new.md', updated_files: 0, failed_updates: 0 },
|
||||
activePath = null,
|
||||
onEntryRenamed = vi.fn(),
|
||||
}: {
|
||||
path?: string
|
||||
entries?: VaultEntry[]
|
||||
renameResult?: RenameNoteResult
|
||||
activePath?: string | null
|
||||
@@ -142,7 +144,7 @@ describe('useNoteRename hook', () => {
|
||||
|
||||
const { result } = renderUseNoteRename(entries)
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote('/vault/old.md', 'New', '/vault', onEntryRenamed)
|
||||
await result.current.handleRenameNote(path, 'New', '/vault', onEntryRenamed)
|
||||
})
|
||||
|
||||
return { onEntryRenamed }
|
||||
@@ -196,6 +198,17 @@ describe('useNoteRename hook', () => {
|
||||
expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md')
|
||||
})
|
||||
|
||||
it('switches active tab when macOS /tmp aliases identify the renamed note', async () => {
|
||||
await runHandleRenameNote({
|
||||
path: '/tmp/vault/old.md',
|
||||
entries: [makeEntry({ path: '/private/tmp/vault/old.md' })],
|
||||
renameResult: { new_path: '/tmp/vault/new.md', updated_files: 0, failed_updates: 0 },
|
||||
activePath: '/private/tmp/vault/old.md',
|
||||
})
|
||||
|
||||
expect(handleSwitchTab).toHaveBeenCalledWith('/tmp/vault/new.md')
|
||||
})
|
||||
|
||||
it('handleRenameFilename renames the file while preserving the existing title', async () => {
|
||||
const entry = makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md', title: 'Project Kickoff' })
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
@@ -331,4 +344,33 @@ describe('useNoteRename hook', () => {
|
||||
)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Moved to "projects" and updated 1 note')
|
||||
})
|
||||
|
||||
it('normalizes folder move targets before sending them to the backend', async () => {
|
||||
const entry = makeEntry({ path: '/vault/notes/project-kickoff.md', filename: 'project-kickoff.md', title: 'Project Kickoff' })
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_folder') {
|
||||
return {
|
||||
new_path: '/vault/projects/active/project-kickoff.md',
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
}
|
||||
}
|
||||
if (cmd === 'get_note_content') return '# Project Kickoff\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [entry], setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMoveNoteToFolder('/vault/notes/project-kickoff.md', String.raw`/projects\active/`, '/vault', vi.fn())
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_folder', expect.objectContaining({
|
||||
folder_path: 'projects/active',
|
||||
}))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Moved to "active"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { slugify } from './useNoteCreation'
|
||||
import {
|
||||
findByNotePath,
|
||||
normalizeVaultRelativePath,
|
||||
notePathFilename,
|
||||
notePathsMatch,
|
||||
vaultRelativePathLabel,
|
||||
} from '../utils/notePathIdentity'
|
||||
|
||||
interface RenameResult {
|
||||
new_path: string
|
||||
@@ -98,12 +105,12 @@ export async function performMoveNoteToFolder({
|
||||
}
|
||||
|
||||
export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
||||
const filename = newPath.split('/').pop() ?? entry.filename
|
||||
const filename = notePathFilename(newPath)
|
||||
return { ...entry, path: newPath, filename, title: newTitle }
|
||||
}
|
||||
|
||||
export function buildFilenameRenamedEntry(entry: VaultEntry, newPath: string): VaultEntry {
|
||||
const filename = newPath.split('/').pop() ?? entry.filename
|
||||
const filename = notePathFilename(newPath)
|
||||
return { ...entry, path: newPath, filename }
|
||||
}
|
||||
|
||||
@@ -156,8 +163,7 @@ export function renameToastMessage(updatedFiles: number, failedUpdates = 0): str
|
||||
}
|
||||
|
||||
function folderLabel(params: { folderPath: string }): string {
|
||||
const trimmed = params.folderPath.trim().replace(/^\/+|\/+$/g, '')
|
||||
return trimmed.split('/').filter(Boolean).at(-1) ?? trimmed
|
||||
return vaultRelativePathLabel(params.folderPath)
|
||||
}
|
||||
|
||||
function moveToastMessage(folderPath: string, updatedFiles: number, failedUpdates = 0): string {
|
||||
@@ -195,8 +201,8 @@ interface Tab {
|
||||
}
|
||||
|
||||
function findRenameEntry(entries: VaultEntry[], tabs: Tab[], path: string): VaultEntry | undefined {
|
||||
return entries.find((entry) => entry.path === path)
|
||||
?? tabs.find((tab) => tab.entry.path === path)?.entry
|
||||
return findByNotePath(entries, path)
|
||||
?? tabs.find((tab) => notePathsMatch(tab.entry.path, path))?.entry
|
||||
}
|
||||
|
||||
function renameErrorMessage(err: unknown): string {
|
||||
@@ -260,9 +266,11 @@ function useRenameResultApplier(
|
||||
const entry = findRenameEntry(entries, currentTabs, oldPath)
|
||||
const newContent = await loadNoteContent({ path: result.new_path })
|
||||
const newEntry = buildEntry(entry, result.new_path)
|
||||
const otherTabPaths = currentTabs.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path)
|
||||
setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab))
|
||||
if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path)
|
||||
const otherTabPaths = currentTabs
|
||||
.filter((tab) => !notePathsMatch(tab.entry.path, oldPath) && !notePathsMatch(tab.entry.path, result.new_path))
|
||||
.map((tab) => tab.entry.path)
|
||||
setTabs((prev) => prev.map((tab) => notePathsMatch(tab.entry.path, oldPath) ? { entry: newEntry, content: newContent } : tab))
|
||||
if (notePathsMatch(activeTabPathRef.current, oldPath)) handleSwitchTab(result.new_path)
|
||||
onEntryRenamed(oldPath, newEntry, newContent)
|
||||
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent })
|
||||
await reloadVaultAfterRename(reloadVault)
|
||||
@@ -310,7 +318,7 @@ async function runRenameAction({
|
||||
}): Promise<RenameResult | null> {
|
||||
try {
|
||||
const result = await perform()
|
||||
if (allowUnchangedResult && result.new_path === path) return result
|
||||
if (allowUnchangedResult && notePathsMatch(result.new_path, path)) return result
|
||||
await applyRenameResult(path, result, buildEntry, onEntryRenamed, { successMessage })
|
||||
return result
|
||||
} catch (err) {
|
||||
@@ -365,7 +373,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
const normalizedFolderPath = folderPath.trim().replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFolderPath = normalizeVaultRelativePath(folderPath)
|
||||
return runRenameAction({
|
||||
path,
|
||||
perform: () => performMoveNoteToFolder({ path, folderPath: normalizedFolderPath, vaultPath }),
|
||||
|
||||
@@ -122,6 +122,43 @@ describe('usePropertyPanelState', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('derives missing instance property placeholders from its type entry', () => {
|
||||
const entries = [
|
||||
makeEntry({
|
||||
title: 'Book',
|
||||
isA: 'Type',
|
||||
properties: {
|
||||
'start date': null,
|
||||
Rating: 5,
|
||||
},
|
||||
}),
|
||||
makeEntry({
|
||||
title: 'Dune',
|
||||
isA: 'Book',
|
||||
properties: {
|
||||
Rating: 4,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePropertyPanelState({
|
||||
entries,
|
||||
entryIsA: 'Book',
|
||||
frontmatter: {
|
||||
Rating: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyEntries).toEqual([
|
||||
['Rating', 4],
|
||||
])
|
||||
expect(result.current.typeDerivedPropertyEntries).toEqual([
|
||||
['start date', null],
|
||||
])
|
||||
})
|
||||
|
||||
it('saves scalar and list properties through the correct handlers', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
removeDisplayModeOverride,
|
||||
} from '../utils/propertyTypes'
|
||||
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
|
||||
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
import { canonicalFrontmatterKey, canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
// Keys to skip showing in Properties (handled by dedicated UI or internal)
|
||||
// Compared case-insensitively via isVisibleProperty()
|
||||
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_archived', 'archived', 'archived_at', '_favorite', '_favorite_index', '_organized'])
|
||||
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
|
||||
|
||||
type PropertyEntry = [string, FrontmatterValue]
|
||||
|
||||
function coerceValue(raw: string): FrontmatterValue {
|
||||
if (raw.toLowerCase() === 'true') return true
|
||||
@@ -88,8 +91,8 @@ function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
|
||||
return !isHiddenPropertyKey(key) && !containsWikilinks(value)
|
||||
}
|
||||
|
||||
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, FrontmatterValue][] {
|
||||
const result: [string, FrontmatterValue][] = []
|
||||
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): PropertyEntry[] {
|
||||
const result: PropertyEntry[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
@@ -104,6 +107,46 @@ function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, F
|
||||
return result
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[] | undefined, entryIsA: string | null): VaultEntry | undefined {
|
||||
if (!entryIsA || entryIsA === 'Type') return undefined
|
||||
return entries?.find((entry) => entry.isA === 'Type' && entry.title === entryIsA)
|
||||
}
|
||||
|
||||
function isRelationshipSchemaKey(key: string): boolean {
|
||||
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
|
||||
}
|
||||
|
||||
function buildExistingFrontmatterKeys(frontmatter: ParsedFrontmatter): Set<string> {
|
||||
return new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
|
||||
}
|
||||
|
||||
function buildTypeDerivedPropertyEntries({
|
||||
entries,
|
||||
entryIsA,
|
||||
frontmatter,
|
||||
}: {
|
||||
entries: VaultEntry[] | undefined
|
||||
entryIsA: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
}): PropertyEntry[] {
|
||||
const typeEntry = findTypeEntry(entries, entryIsA)
|
||||
if (!typeEntry) return []
|
||||
|
||||
const existingKeys = buildExistingFrontmatterKeys(frontmatter)
|
||||
const result: PropertyEntry[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const [key, value] of Object.entries(typeEntry.properties ?? {})) {
|
||||
const canonicalKey = canonicalFrontmatterKey(key)
|
||||
if (existingKeys.has(canonicalKey) || seen.has(canonicalKey) || isRelationshipSchemaKey(key)) continue
|
||||
if (!isVisibleProperty([key, value])) continue
|
||||
seen.add(canonicalKey)
|
||||
result.push([key, value])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function addTagValues(tagsByKey: Map<string, Set<string>>, key: string, value: unknown) {
|
||||
if (!Array.isArray(value)) return
|
||||
|
||||
@@ -197,6 +240,10 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
|
||||
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries])
|
||||
const propertyEntries = useMemo(() => buildVisiblePropertyEntries(frontmatter), [frontmatter])
|
||||
const typeDerivedPropertyEntries = useMemo(
|
||||
() => buildTypeDerivedPropertyEntries({ entries, entryIsA, frontmatter }),
|
||||
[entries, entryIsA, frontmatter],
|
||||
)
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
@@ -204,6 +251,12 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
|
||||
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
|
||||
|
||||
const handleSaveTypeDerivedValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
if (!onUpdateProperty || newValue.trim() === '') return
|
||||
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
|
||||
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
|
||||
|
||||
const handleSaveList = useCallback((key: string, newItems: string[]) => {
|
||||
if (!onUpdateProperty) return
|
||||
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
|
||||
@@ -226,7 +279,7 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
|
||||
return {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries, typeDerivedPropertyEntries,
|
||||
handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const defaultSettings: Settings = {
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
default_ai_agent: null,
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
@@ -45,6 +46,7 @@ const savedSettings: Settings = {
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
default_ai_agent: null,
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
@@ -101,6 +103,7 @@ function changedSettings(): Settings {
|
||||
theme_mode: null,
|
||||
ui_language: 'zh-CN',
|
||||
note_width_mode: 'wide',
|
||||
sidebar_type_pluralization_enabled: false,
|
||||
default_ai_agent: null,
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
|
||||
@@ -46,6 +46,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
default_ai_agent: null,
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
@@ -66,6 +67,7 @@ function normalizeSettings(settings: Settings): Settings {
|
||||
theme_mode: normalizeThemeMode(settings.theme_mode),
|
||||
ui_language: serializeUiLanguagePreference(settings.ui_language),
|
||||
note_width_mode: normalizeNoteWidthMode(settings.note_width_mode),
|
||||
sidebar_type_pluralization_enabled: settings.sidebar_type_pluralization_enabled ?? null,
|
||||
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
|
||||
default_ai_target: settings.default_ai_target?.trim() || null,
|
||||
ai_model_providers: aiModelProviders.length > 0 ? aiModelProviders : null,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
prefetchNoteContent as prefetchNoteContentInMemory,
|
||||
} from './noteContentCache'
|
||||
import { clearParsedNoteBlockCache } from './editorParsedBlockCache'
|
||||
import { notePathsMatch } from '../utils/notePathIdentity'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -89,18 +90,6 @@ function resetRequestedPathIfStillPending(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeComparablePath(path: string): string {
|
||||
return path
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
|
||||
.replace(/\/+$/u, '')
|
||||
}
|
||||
|
||||
function pathsMatch(leftPath: string | null, rightPath: string | null): boolean {
|
||||
if (!leftPath || !rightPath) return false
|
||||
return normalizeComparablePath(leftPath) === normalizeComparablePath(rightPath)
|
||||
}
|
||||
|
||||
function setSingleTab(
|
||||
tabsRef: React.MutableRefObject<Tab[]>,
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
|
||||
@@ -123,8 +112,8 @@ function isAlreadyViewingPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
path: string,
|
||||
) {
|
||||
return pathsMatch(activeTabPathRef.current, path)
|
||||
|| tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path))
|
||||
return notePathsMatch(activeTabPathRef.current, path)
|
||||
|| tabsRef.current.some((tab) => notePathsMatch(tab.entry.path, path))
|
||||
}
|
||||
|
||||
function startEntryNavigation(options: {
|
||||
@@ -203,8 +192,8 @@ function shouldApplyLoadedEntry(options: {
|
||||
|
||||
if (navSeqRef.current !== seq) return false
|
||||
if (forceReload) return true
|
||||
if (!pathsMatch(activeTabPathRef.current, path)) return true
|
||||
const openTab = tabsRef.current.find((tab) => pathsMatch(tab.entry.path, path))
|
||||
if (!notePathsMatch(activeTabPathRef.current, path)) return true
|
||||
const openTab = tabsRef.current.find((tab) => notePathsMatch(tab.entry.path, path))
|
||||
return !openTab || openTab.content !== content
|
||||
}
|
||||
|
||||
@@ -472,7 +461,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
) => {
|
||||
const seq = ++beforeNavigateSeqRef.current
|
||||
const currentPath = activeTabPathRef.current
|
||||
if (beforeNavigate && currentPath && !pathsMatch(currentPath, targetPath)) {
|
||||
if (beforeNavigate && currentPath && !notePathsMatch(currentPath, targetPath)) {
|
||||
try {
|
||||
markNoteOpenTrace(targetPath, 'beforeNavigateStart')
|
||||
await beforeNavigate(currentPath, targetPath)
|
||||
@@ -491,7 +480,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
requestedActiveTabPathRef.current = entry.path
|
||||
const alreadyViewingDirtyEntry = pathsMatch(entry.path, activeTabPathRef.current)
|
||||
const alreadyViewingDirtyEntry = notePathsMatch(entry.path, activeTabPathRef.current)
|
||||
&& !!hasUnsavedChanges?.(entry.path)
|
||||
if (!alreadyViewingDirtyEntry) {
|
||||
beginNoteOpenTrace(entry.path, 'select-note')
|
||||
@@ -532,7 +521,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
requestedActiveTabPathRef.current = entry.path
|
||||
const replacingDifferentEntry = !pathsMatch(entry.path, activeTabPathRef.current)
|
||||
const replacingDifferentEntry = !notePathsMatch(entry.path, activeTabPathRef.current)
|
||||
if (replacingDifferentEntry) {
|
||||
beginNoteOpenTrace(entry.path, 'replace-active-tab')
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface VaultBridgeDeps {
|
||||
closeAllTabs: () => void
|
||||
replaceActiveTab: (entry: VaultEntry) => Promise<void>
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
shouldKeepActiveEditorMounted?: () => boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
activeTabPath: string | null
|
||||
getActiveTabPath?: () => string | null
|
||||
@@ -33,6 +34,7 @@ export function useVaultBridge({
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
hasUnsavedChanges,
|
||||
shouldKeepActiveEditorMounted,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
getActiveTabPath,
|
||||
@@ -50,6 +52,7 @@ export function useVaultBridge({
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
shouldKeepActiveEditorMounted,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
@@ -62,6 +65,7 @@ export function useVaultBridge({
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
shouldKeepActiveEditorMounted,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user